mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(web): align the directory browser with the figma frame and merge the seam tip
The dialog now owns the figma structure through a new headless Modal mode (mask/card/Escape stay shared): header block with the title and crumbs 8px apart above the l3 separator (no close chrome — the figma frame has none), 16px to the level, and the 12px card bottom. The picker-kind narrowing returns for the merged open describe kind — an unrecognized advertised kind hides the local-folder entry, now covered alongside the stale-navigation failure arm and the unmount races.
This commit is contained in:
@@ -292,10 +292,6 @@ flowchart TD
|
||||
pkg_client_ui_slash --> pkg_client_runtime
|
||||
pkg_client_ui_slash --> pkg_client_ui_slots
|
||||
pkg_client_ui_slash --> pkg_invariants
|
||||
pkg_client_ui_workspace --> pkg_client_runtime
|
||||
pkg_client_ui_workspace --> pkg_client_ui_primitives
|
||||
pkg_client_ui_workspace --> pkg_client_ui_slots
|
||||
pkg_client_ui_workspace --> pkg_invariants
|
||||
pkg_helper --> pkg_brand
|
||||
pkg_helper --> pkg_invariants
|
||||
pkg_helper --> pkg_subprocess
|
||||
@@ -350,6 +346,11 @@ flowchart TD
|
||||
pkg_client_ui_theme --> pkg_client_ui_primitives
|
||||
pkg_client_ui_theme --> pkg_client_ui_slots
|
||||
pkg_client_ui_theme --> pkg_invariants
|
||||
pkg_client_ui_workspace --> pkg_client_locale
|
||||
pkg_client_ui_workspace --> pkg_client_runtime
|
||||
pkg_client_ui_workspace --> pkg_client_ui_primitives
|
||||
pkg_client_ui_workspace --> pkg_client_ui_slots
|
||||
pkg_client_ui_workspace --> pkg_invariants
|
||||
pkg_lsp --> pkg_brand
|
||||
pkg_lsp --> pkg_invariants
|
||||
pkg_lsp --> pkg_llm
|
||||
@@ -944,7 +945,6 @@ flowchart TD
|
||||
| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) |
|
||||
| [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
|
||||
| [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) |
|
||||
@@ -961,6 +961,7 @@ flowchart TD
|
||||
| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
|
||||
@@ -12,13 +12,16 @@ import css from './Modal.module.css'
|
||||
* Render a centered modal over a blurred page mask.
|
||||
* @param props.open - whether the dialog is showing.
|
||||
* @param props.onClose - Escape or mask click.
|
||||
* @param props.title - dialog heading.
|
||||
* @param props.title - dialog heading (aria-label in every mode).
|
||||
* @param props.description - optional supporting sentence under the title.
|
||||
* @param props.children - body (inputs, etc.).
|
||||
* @param props.footer - action row (Cancel / Create).
|
||||
* @param props.headless - render children directly in the card (no default
|
||||
* header/close/body chrome) for dialogs whose figma frame owns its own
|
||||
* header structure; mask, card, Escape, and aria-label remain.
|
||||
* @returns null when closed; otherwise the overlay tree.
|
||||
*/
|
||||
export function Modal({ open, onClose, title, description, children, footer, className }: {
|
||||
export function Modal({ open, onClose, title, description, children, footer, className, headless = false }: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title: string
|
||||
@@ -26,6 +29,7 @@ export function Modal({ open, onClose, title, description, children, footer, cla
|
||||
children?: ReactNode
|
||||
footer?: ReactNode
|
||||
className?: string
|
||||
headless?: boolean
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
@@ -47,19 +51,25 @@ export function Modal({ open, onClose, title, description, children, footer, cla
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
>
|
||||
<div className={css.content}>
|
||||
<div className={css.header}>
|
||||
<h2 className={css.title}>{title}</h2>
|
||||
<button type="button" className={css.close} aria-label="Close" onClick={onClose}>
|
||||
<IconCloseOutline16 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{description !== undefined && description !== '' && (
|
||||
<p className={css.description}>{description}</p>
|
||||
{headless
|
||||
? children
|
||||
: (
|
||||
<>
|
||||
<div className={css.content}>
|
||||
<div className={css.header}>
|
||||
<h2 className={css.title}>{title}</h2>
|
||||
<button type="button" className={css.close} aria-label="Close" onClick={onClose}>
|
||||
<IconCloseOutline16 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{description !== undefined && description !== '' && (
|
||||
<p className={css.description}>{description}</p>
|
||||
)}
|
||||
{children !== undefined && <div className={css.body}>{children}</div>}
|
||||
</div>
|
||||
{footer !== undefined && <div className={css.footer}>{footer}</div>}
|
||||
</>
|
||||
)}
|
||||
{children !== undefined && <div className={css.body}>{children}</div>}
|
||||
</div>
|
||||
{footer !== undefined && <div className={css.footer}>{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,21 +1,40 @@
|
||||
/* Directory-browser dialog (figma 802-56979). The shared Modal owns the mask,
|
||||
* card, and title row; this module widens the card and rebuilds the figma
|
||||
* header/footer separators with bleed margins inside the 24px content column. */
|
||||
/* Directory-browser dialog (figma 802-56979). The shared Modal renders
|
||||
* headless here — mask, card, Escape only — and this module owns the figma
|
||||
* frame exactly: header (title + crumbs, l3 separator), one directory level,
|
||||
* and the bordered footer. Card: w600 r24, bottom pad 12, no close chrome. */
|
||||
|
||||
.dialog {
|
||||
/* Doubled class beats Modal's own .dialog regardless of stylesheet order. */
|
||||
.dialog.dialog {
|
||||
width: min(600px, 100%);
|
||||
padding: 0 0 12px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Header block: pl24 pr14 pt22 pb12, 8px between title row and crumb row. */
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 22px 14px 12px 24px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
min-height: 28px;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 510;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Breadcrumb bar sits visually inside the header block: bleed to the card
|
||||
* edges, close the header's 12px bottom pad, draw the l3 separator. */
|
||||
.crumbBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 32px;
|
||||
margin: -12px -24px 0;
|
||||
padding: 0 24px 12px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l3);
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.crumbSeat {
|
||||
@@ -65,7 +84,7 @@
|
||||
box-sizing: border-box;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 8px;
|
||||
@@ -76,12 +95,12 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* One directory level: 28px rows, r6, folder icon + name + enter chevron. */
|
||||
/* One directory level: content column pt16 px24, 28px rows with 2px gaps. */
|
||||
.level {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-top: -4px;
|
||||
padding: 16px 24px 0;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -164,15 +183,12 @@
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* Footer: the l3 separator above the action row, New-folder pinned left
|
||||
* (bleeds across the card; 12px stays below, matching the figma card pad). */
|
||||
/* Footer: l3 separator on top, pt12 px24, New-folder pinned left. */
|
||||
.footerBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: calc(100% + 48px);
|
||||
margin: 0 -24px -12px;
|
||||
padding: 12px 24px;
|
||||
padding: 12px 24px 0;
|
||||
border-top: 1px solid var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
}, [open, navigate])
|
||||
|
||||
const confirmFolder = (): void => {
|
||||
/* v8 ignore next -- reentry fence: the inline row only renders with a listing and a draft, and the input disables while creating. */
|
||||
if (listing === null || folderDraft === null || creatingFolder) return
|
||||
const name = folderDraft.trim()
|
||||
if (name === '') return
|
||||
@@ -126,78 +127,61 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
onClose={onClose}
|
||||
title={t('browser.title')}
|
||||
className={clsx(css.dialog)}
|
||||
footer={(
|
||||
<div className={css.footerBar}>
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={<IconPlusOutline16 size={14} />}
|
||||
disabled={listing === null || busy || folderDraft !== null}
|
||||
onClick={() => { setFolderDraft('') }}
|
||||
>
|
||||
{t('browser.newFolder')}
|
||||
</Button>
|
||||
<span className={css.footerGap} />
|
||||
<Button variant="outline" className={css.footerAction} disabled={busy} onClick={onClose}>{t('browser.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={css.footerAction}
|
||||
disabled={listing === null || loading || busy}
|
||||
onClick={() => { if (listing !== null) onOpen(listing.path) }}
|
||||
>
|
||||
{t('browser.open')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
headless
|
||||
>
|
||||
<div className={css.crumbBar}>
|
||||
{pathDraft === null
|
||||
? (
|
||||
<>
|
||||
{crumbs.map((crumb, index) => (
|
||||
<span key={crumb.path} className={css.crumbSeat}>
|
||||
{index > 0 && <IconChevronRightOutline14 size={12} className={css.crumbChevron} />}
|
||||
<button
|
||||
type="button"
|
||||
className={css.crumb}
|
||||
disabled={busy}
|
||||
onClick={() => { navigate(crumb.path) }}
|
||||
>
|
||||
{crumb.name}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{/* The empty zone right of the crumbs is the path-edit affordance. */}
|
||||
<button
|
||||
type="button"
|
||||
className={css.crumbEditZone}
|
||||
<div className={css.header}>
|
||||
<h2 className={css.title}>{t('browser.title')}</h2>
|
||||
<div className={css.crumbBar}>
|
||||
{pathDraft === null
|
||||
? (
|
||||
<>
|
||||
{crumbs.map((crumb, index) => (
|
||||
<span key={crumb.path} className={css.crumbSeat}>
|
||||
{index > 0 && <IconChevronRightOutline14 size={12} className={css.crumbChevron} />}
|
||||
<button
|
||||
type="button"
|
||||
className={css.crumb}
|
||||
disabled={busy}
|
||||
onClick={() => { navigate(crumb.path) }}
|
||||
>
|
||||
{crumb.name}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{/* The empty zone right of the crumbs is the path-edit affordance. */}
|
||||
<button
|
||||
type="button"
|
||||
className={css.crumbEditZone}
|
||||
aria-label={t('browser.editPath')}
|
||||
disabled={listing === null || busy}
|
||||
/* v8 ignore next -- narrowing guard: the zone disables while the listing is null. */
|
||||
onClick={() => { if (listing !== null) setPathDraft(listing.path) }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<input
|
||||
className={css.pathInput}
|
||||
value={pathDraft}
|
||||
aria-label={t('browser.editPath')}
|
||||
disabled={listing === null || busy}
|
||||
onClick={() => { if (listing !== null) setPathDraft(listing.path) }}
|
||||
autoFocus
|
||||
disabled={busy}
|
||||
onChange={(event) => { setPathDraft(event.target.value) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
const target = pathDraft.trim()
|
||||
if (target !== '') navigate(target)
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation()
|
||||
setPathDraft(null)
|
||||
setError(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<input
|
||||
className={css.pathInput}
|
||||
value={pathDraft}
|
||||
aria-label={t('browser.editPath')}
|
||||
autoFocus
|
||||
disabled={busy}
|
||||
onChange={(event) => { setPathDraft(event.target.value) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
const target = pathDraft.trim()
|
||||
if (target !== '') navigate(target)
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation()
|
||||
setPathDraft(null)
|
||||
setError(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={css.level} role="list" aria-label={t('browser.title')}>
|
||||
{folderDraft !== null && listing !== null && (
|
||||
@@ -241,6 +225,27 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
{loading && <div className={css.status} role="status">{t('browser.loading')}</div>}
|
||||
{error !== null && <div className={css.error} role="alert">{error}</div>}
|
||||
</div>
|
||||
<div className={css.footerBar}>
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={<IconPlusOutline16 size={14} />}
|
||||
disabled={listing === null || busy || folderDraft !== null}
|
||||
onClick={() => { setFolderDraft('') }}
|
||||
>
|
||||
{t('browser.newFolder')}
|
||||
</Button>
|
||||
<span className={css.footerGap} />
|
||||
<Button variant="outline" className={clsx(css.footerAction)} disabled={busy} onClick={onClose}>{t('browser.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={clsx(css.footerAction)}
|
||||
disabled={listing === null || loading || busy}
|
||||
/* v8 ignore next -- narrowing guard: Open disables while the listing is null. */
|
||||
onClick={() => { if (listing !== null) onOpen(listing.path) }}
|
||||
>
|
||||
{t('browser.open')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -76,9 +76,10 @@ export function WorkspaceCreateFlow({
|
||||
if (!open) return
|
||||
let stale = false
|
||||
directoryPickerKind().then(
|
||||
// The wire type is the closed two-kind union today; a fetch failure is
|
||||
// the reachable 'unknown' arm (an unadvertisable host hides the entry).
|
||||
(kind) => { if (!stale) setPickerKind(kind) },
|
||||
// The wire kind is an open string (a merge-added capability advertises
|
||||
// before this client knows it): anything but the two known kinds hides
|
||||
// the entry, as does a fetch failure.
|
||||
(kind) => { if (!stale) setPickerKind(kind === 'dialog' || kind === 'browse' ? kind : 'unknown') },
|
||||
() => { if (!stale) setPickerKind('unknown') },
|
||||
)
|
||||
return () => { stale = true }
|
||||
|
||||
@@ -147,6 +147,88 @@ describe('DirectoryBrowser', () => {
|
||||
expect(screen.getAllByRole<HTMLButtonElement>('button', { name: 'browser.open' }).at(-1)!.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('renders the full ancestry when the listing sits outside the home subtree', async () => {
|
||||
const outside: DirectoryListing = {
|
||||
path: '/srv/data',
|
||||
home: HOME,
|
||||
crumbs: [
|
||||
{ name: '/', path: '/', hidden: false },
|
||||
{ name: 'srv', path: '/srv', hidden: false },
|
||||
{ name: 'data', path: '/srv/data', hidden: false },
|
||||
],
|
||||
entries: [],
|
||||
}
|
||||
mount({ listDirectory: vi.fn(async () => outside) })
|
||||
await waitFor(() => { expect(screen.getByRole('button', { name: 'data' })).toBeTruthy() })
|
||||
expect(screen.getByRole('button', { name: '/' })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: 'browser.home' })).toBeNull()
|
||||
})
|
||||
|
||||
it('folds non-typed failures into readable text (Error message, String otherwise)', async () => {
|
||||
const b = mount({ listDirectory: vi.fn(async () => { throw new Error('socket down') }) })
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('socket down') })
|
||||
b.view.rerender(<DirectoryBrowser {...b.props} open={false} />)
|
||||
const raw = mount({ listDirectory: vi.fn(async () => { throw 'raw failure' }) })
|
||||
await waitFor(() => { expect(screen.getAllByRole('alert').at(-1)!.textContent).toBe('raw failure') })
|
||||
expect(raw.onOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels the inline folder row with Escape and ignores a blank name', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
||||
const input = screen.getByLabelText('browser.newFolder')
|
||||
fireEvent.change(input, { target: { value: ' ' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(b.createDirectory).not.toHaveBeenCalled()
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
expect(screen.queryByLabelText('browser.newFolder')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores a blank path draft on Enter', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText('browser.editPath')
|
||||
fireEvent.change(input, { target: { value: ' ' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
// Only the initial home listing ran; the blank draft navigated nowhere.
|
||||
expect(b.listDirectory).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('drops a stale listing that resolves after a newer navigation', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
// The next navigation (into Documents) hangs; a Home-crumb jump supersedes it.
|
||||
let resolveSlow!: (value: DirectoryListing) => void
|
||||
const slow = new Promise<DirectoryListing>((settle) => { resolveSlow = settle })
|
||||
b.listDirectory.mockReturnValueOnce(slow)
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
|
||||
await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(3) })
|
||||
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') })
|
||||
resolveSlow(listingFor(`${HOME}/Documents`))
|
||||
await new Promise(settle => setTimeout(settle, 0))
|
||||
// The stale Documents listing did not clobber the newer Home level.
|
||||
expect(screen.getByRole('listitem').textContent).toBe('Documents')
|
||||
})
|
||||
|
||||
it('drops a stale failure that rejects after a newer navigation', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
let rejectSlow!: (reason: unknown) => void
|
||||
const slow = new Promise<DirectoryListing>((_settle, fail) => { rejectSlow = fail })
|
||||
b.listDirectory.mockReturnValueOnce(slow)
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
|
||||
await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(3) })
|
||||
rejectSlow(new Error('too late to matter'))
|
||||
await new Promise(settle => setTimeout(settle, 0))
|
||||
// The superseded failure surfaces no alert over the newer level.
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
expect(screen.getByRole('listitem').textContent).toBe('Documents')
|
||||
})
|
||||
|
||||
it('starts back at home on reopen', async () => {
|
||||
const b = mount()
|
||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||
|
||||
@@ -280,6 +280,58 @@ describe('WorkspacePicker', () => {
|
||||
expect(b.onPick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops a picker-kind failure that lands after unmount', async () => {
|
||||
let rejectKind!: (reason: unknown) => void
|
||||
const pending = new Promise<'dialog'>((_settle, fail) => { rejectKind = fail })
|
||||
const b = mount([], vi.fn(), vi.fn(), { directoryPickerKind: vi.fn(() => pending) })
|
||||
b.view.unmount()
|
||||
await act(async () => {
|
||||
rejectKind(new Error('gone'))
|
||||
await pending.catch(() => {})
|
||||
})
|
||||
expect(b.onPick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops a picker-kind resolution that lands after unmount', async () => {
|
||||
let resolveKind!: (kind: 'dialog') => void
|
||||
const pending = new Promise<'dialog'>((settle) => { resolveKind = settle })
|
||||
const b = mount([], vi.fn(), vi.fn(), { directoryPickerKind: vi.fn(() => pending) })
|
||||
b.view.unmount()
|
||||
await act(async () => {
|
||||
resolveKind('dialog')
|
||||
await pending
|
||||
})
|
||||
expect(b.onPick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a browse adoption failure thrown as a plain string', async () => {
|
||||
const b = mount([], vi.fn(async () => { throw 'disk detached' }), vi.fn(), {
|
||||
directoryPickerKind: vi.fn(async () => 'browse' as const),
|
||||
})
|
||||
await chooseLocalFolder()
|
||||
await waitFor(() => { expect(screen.getByRole('dialog', { name: 'browser.title' })).toBeTruthy() })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.open' }))
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('disk detached') })
|
||||
expect(b.onPick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a native picker Error by its message', async () => {
|
||||
const b = mount([], vi.fn(), vi.fn(async () => { throw new Error('no chooser installed') }))
|
||||
await chooseLocalFolder()
|
||||
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('no chooser installed') })
|
||||
expect(b.createWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides the local-folder entry for an unrecognized advertised kind', async () => {
|
||||
mount([], vi.fn(), vi.fn(), {
|
||||
directoryPickerKind: vi.fn(async () => 'electron-native'),
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
|
||||
})
|
||||
expect(screen.getByRole('menuitem', { name: 'Create a new workspace' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('hides the local-folder entry when the picker kind is unknown', async () => {
|
||||
mount([], vi.fn(), vi.fn(), {
|
||||
directoryPickerKind: vi.fn(async () => { throw new Error('unreachable host') }),
|
||||
|
||||
Reference in New Issue
Block a user