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_runtime
|
||||||
pkg_client_ui_slash --> pkg_client_ui_slots
|
pkg_client_ui_slash --> pkg_client_ui_slots
|
||||||
pkg_client_ui_slash --> pkg_invariants
|
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_brand
|
||||||
pkg_helper --> pkg_invariants
|
pkg_helper --> pkg_invariants
|
||||||
pkg_helper --> pkg_subprocess
|
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_primitives
|
||||||
pkg_client_ui_theme --> pkg_client_ui_slots
|
pkg_client_ui_theme --> pkg_client_ui_slots
|
||||||
pkg_client_ui_theme --> pkg_invariants
|
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_brand
|
||||||
pkg_lsp --> pkg_invariants
|
pkg_lsp --> pkg_invariants
|
||||||
pkg_lsp --> pkg_llm
|
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-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-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-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) |
|
| [`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) |
|
| [`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) |
|
| [`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-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-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-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) |
|
| [`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) |
|
| [`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) |
|
| [`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.
|
* Render a centered modal over a blurred page mask.
|
||||||
* @param props.open - whether the dialog is showing.
|
* @param props.open - whether the dialog is showing.
|
||||||
* @param props.onClose - Escape or mask click.
|
* @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.description - optional supporting sentence under the title.
|
||||||
* @param props.children - body (inputs, etc.).
|
* @param props.children - body (inputs, etc.).
|
||||||
* @param props.footer - action row (Cancel / Create).
|
* @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.
|
* @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
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
title: string
|
title: string
|
||||||
@@ -26,6 +29,7 @@ export function Modal({ open, onClose, title, description, children, footer, cla
|
|||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
footer?: ReactNode
|
footer?: ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
|
headless?: boolean
|
||||||
}) {
|
}) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
@@ -47,19 +51,25 @@ export function Modal({ open, onClose, title, description, children, footer, cla
|
|||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
>
|
>
|
||||||
<div className={css.content}>
|
{headless
|
||||||
<div className={css.header}>
|
? children
|
||||||
<h2 className={css.title}>{title}</h2>
|
: (
|
||||||
<button type="button" className={css.close} aria-label="Close" onClick={onClose}>
|
<>
|
||||||
<IconCloseOutline16 size={14} />
|
<div className={css.content}>
|
||||||
</button>
|
<div className={css.header}>
|
||||||
</div>
|
<h2 className={css.title}>{title}</h2>
|
||||||
{description !== undefined && description !== '' && (
|
<button type="button" className={css.close} aria-label="Close" onClick={onClose}>
|
||||||
<p className={css.description}>{description}</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,21 +1,40 @@
|
|||||||
/* Directory-browser dialog (figma 802-56979). The shared Modal owns the mask,
|
/* Directory-browser dialog (figma 802-56979). The shared Modal renders
|
||||||
* card, and title row; this module widens the card and rebuilds the figma
|
* headless here — mask, card, Escape only — and this module owns the figma
|
||||||
* header/footer separators with bleed margins inside the 24px content column. */
|
* 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%);
|
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 {
|
.crumbBar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
min-height: 32px;
|
min-height: 20px;
|
||||||
margin: -12px -24px 0;
|
|
||||||
padding: 0 24px 12px;
|
|
||||||
border-bottom: 1px solid var(--dsw-alias-border-l3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.crumbSeat {
|
.crumbSeat {
|
||||||
@@ -65,7 +84,7 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 28px;
|
height: 24px;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
border: 1px solid var(--dsw-alias-border-l2);
|
border: 1px solid var(--dsw-alias-border-l2);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -76,12 +95,12 @@
|
|||||||
color: var(--dsw-alias-label-primary);
|
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 {
|
.level {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
margin-top: -4px;
|
padding: 16px 24px 0;
|
||||||
max-height: 320px;
|
max-height: 320px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
@@ -164,15 +183,12 @@
|
|||||||
color: var(--dsw-alias-state-error-primary);
|
color: var(--dsw-alias-state-error-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Footer: the l3 separator above the action row, New-folder pinned left
|
/* Footer: l3 separator on top, pt12 px24, New-folder pinned left. */
|
||||||
* (bleeds across the card; 12px stays below, matching the figma card pad). */
|
|
||||||
.footerBar {
|
.footerBar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
width: calc(100% + 48px);
|
padding: 12px 24px 0;
|
||||||
margin: 0 -24px -12px;
|
|
||||||
padding: 12px 24px;
|
|
||||||
border-top: 1px solid var(--dsw-alias-border-l3);
|
border-top: 1px solid var(--dsw-alias-border-l3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|||||||
}, [open, navigate])
|
}, [open, navigate])
|
||||||
|
|
||||||
const confirmFolder = (): void => {
|
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
|
if (listing === null || folderDraft === null || creatingFolder) return
|
||||||
const name = folderDraft.trim()
|
const name = folderDraft.trim()
|
||||||
if (name === '') return
|
if (name === '') return
|
||||||
@@ -126,78 +127,61 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={t('browser.title')}
|
title={t('browser.title')}
|
||||||
className={clsx(css.dialog)}
|
className={clsx(css.dialog)}
|
||||||
footer={(
|
headless
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div className={css.crumbBar}>
|
<div className={css.header}>
|
||||||
{pathDraft === null
|
<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} />}
|
{crumbs.map((crumb, index) => (
|
||||||
<button
|
<span key={crumb.path} className={css.crumbSeat}>
|
||||||
type="button"
|
{index > 0 && <IconChevronRightOutline14 size={12} className={css.crumbChevron} />}
|
||||||
className={css.crumb}
|
<button
|
||||||
disabled={busy}
|
type="button"
|
||||||
onClick={() => { navigate(crumb.path) }}
|
className={css.crumb}
|
||||||
>
|
disabled={busy}
|
||||||
{crumb.name}
|
onClick={() => { navigate(crumb.path) }}
|
||||||
</button>
|
>
|
||||||
</span>
|
{crumb.name}
|
||||||
))}
|
</button>
|
||||||
{/* The empty zone right of the crumbs is the path-edit affordance. */}
|
</span>
|
||||||
<button
|
))}
|
||||||
type="button"
|
{/* The empty zone right of the crumbs is the path-edit affordance. */}
|
||||||
className={css.crumbEditZone}
|
<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')}
|
aria-label={t('browser.editPath')}
|
||||||
disabled={listing === null || busy}
|
autoFocus
|
||||||
onClick={() => { if (listing !== null) setPathDraft(listing.path) }}
|
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>
|
||||||
: (
|
|
||||||
<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')}>
|
<div className={css.level} role="list" aria-label={t('browser.title')}>
|
||||||
{folderDraft !== null && listing !== null && (
|
{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>}
|
{loading && <div className={css.status} role="status">{t('browser.loading')}</div>}
|
||||||
{error !== null && <div className={css.error} role="alert">{error}</div>}
|
{error !== null && <div className={css.error} role="alert">{error}</div>}
|
||||||
</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>
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,9 +76,10 @@ export function WorkspaceCreateFlow({
|
|||||||
if (!open) return
|
if (!open) return
|
||||||
let stale = false
|
let stale = false
|
||||||
directoryPickerKind().then(
|
directoryPickerKind().then(
|
||||||
// The wire type is the closed two-kind union today; a fetch failure is
|
// The wire kind is an open string (a merge-added capability advertises
|
||||||
// the reachable 'unknown' arm (an unadvertisable host hides the entry).
|
// before this client knows it): anything but the two known kinds hides
|
||||||
(kind) => { if (!stale) setPickerKind(kind) },
|
// the entry, as does a fetch failure.
|
||||||
|
(kind) => { if (!stale) setPickerKind(kind === 'dialog' || kind === 'browse' ? kind : 'unknown') },
|
||||||
() => { if (!stale) setPickerKind('unknown') },
|
() => { if (!stale) setPickerKind('unknown') },
|
||||||
)
|
)
|
||||||
return () => { stale = true }
|
return () => { stale = true }
|
||||||
|
|||||||
@@ -147,6 +147,88 @@ describe('DirectoryBrowser', () => {
|
|||||||
expect(screen.getAllByRole<HTMLButtonElement>('button', { name: 'browser.open' }).at(-1)!.disabled).toBe(true)
|
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 () => {
|
it('starts back at home on reopen', async () => {
|
||||||
const b = mount()
|
const b = mount()
|
||||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||||
|
|||||||
@@ -280,6 +280,58 @@ describe('WorkspacePicker', () => {
|
|||||||
expect(b.onPick).not.toHaveBeenCalled()
|
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 () => {
|
it('hides the local-folder entry when the picker kind is unknown', async () => {
|
||||||
mount([], vi.fn(), vi.fn(), {
|
mount([], vi.fn(), vi.fn(), {
|
||||||
directoryPickerKind: vi.fn(async () => { throw new Error('unreachable host') }),
|
directoryPickerKind: vi.fn(async () => { throw new Error('unreachable host') }),
|
||||||
|
|||||||
Reference in New Issue
Block a user