diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css
index 3b681de32a..d4fa986371 100644
--- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css
+++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css
@@ -55,8 +55,9 @@
align-items: stretch;
flex: 1 1 0;
min-height: 0;
- /* Columns already end in an 8px scrollbar clearance, so the divider only
- * needs a slim gap of its own on each side. */
+ /* 12px of row gap on each side of the divider; the left side reads wider
+ * by the column's trailing 8px scrollbar clearance, which is deliberate —
+ * the thumb needs that room, the right pane's rows do not. */
gap: 12px;
overflow-x: auto;
scrollbar-width: none;
diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx
index b924c072bb..9c3dc66fbb 100644
--- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx
+++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx
@@ -65,9 +65,12 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE
}
/**
- * The listing's platform separator, read from the host-stamped home path —
- * never sniffed from typed text or entry paths, where a backslash is a legal
- * POSIX name character rather than a platform fact.
+ * The listing's platform separator, inferred from the home path the host
+ * stamped — never from typed text or entry paths, where a backslash is a
+ * legal POSIX name character. Still a heuristic at the last step: a POSIX
+ * home directory whose own name contains a backslash would misread.
+ * TODO: replace with a host-stamped `separator` field on the wire
+ * DirectoryListing so the platform fact travels verbatim.
*/
function separatorOf(listing: DirectoryListing): '\\' | '/' {
return listing.home.includes('\\') ? '\\' : '/'
@@ -91,15 +94,20 @@ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string
}
/** One column of folder rows (the Miller view renders one or two of these). */
-function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPrefix }: {
+function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPrefix, pathEditing }: {
entries: readonly DirectoryEntry[]
selectedPath: string | null
busy: boolean
onPick: (entry: DirectoryEntry) => void
showHidden: boolean
filterPrefix: string | null
+ pathEditing: boolean
}) {
const visible = entries.filter((entry) => {
+ // The selection is exempt from both filters: it anchors the two-pane
+ // view (crumbs and the child pane point at it), so neither the hidden
+ // filter after a dot-reveal pick nor a prefix miss may orphan it.
+ if (entry.path === selectedPath) return true
if (filterPrefix !== null && !entry.name.toLowerCase().startsWith(filterPrefix.toLowerCase())) return false
// A dot-led prefix names hidden entries explicitly, so matching ones
// surface even while the toggle keeps the rest hidden.
@@ -118,10 +126,11 @@ function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPr
aria-current={selected || undefined}
className={clsx(css.row, selected && css.rowSelected)}
disabled={busy}
- // Keep focus where it is (the path editor, notably): a focus
- // steal on mousedown would blur-cancel the editor, unmount the
- // filtered rows mid-gesture, and drop this very click.
- onMouseDown={(event) => { event.preventDefault() }}
+ // While the path editor is open, keep focus in it: a focus
+ // steal on mousedown would blur the editor and (in engines
+ // where the blur lands before our guards) drop this click.
+ // Outside editing, rows keep native focus behavior.
+ onMouseDown={pathEditing ? (event) => { event.preventDefault() } : undefined}
onClick={() => { onPick(entry) }}
>
{selected
@@ -457,16 +466,19 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
cancelPathEdit()
}
}}
- // Clicking anywhere outside the editor reads as leaving it:
- // focus loss cancels the edit like Escape. Enter keeps focus
+ // Focus leaving the DIALOG reads as leaving the editor and
+ // cancels like Escape. Three guarded non-cancel paths: window
+ // or tab focus loss (document no longer focused); a focus
+ // move that stays inside the dialog card (keyboard Tab onto
+ // the filtered rows or the footer toggle); and pointer paths,
+ // where rows and the toggle suppress focus steal on mousedown
+ // while editing so their click lands first. Enter keeps focus
// in the input while its navigation is in flight, so a
- // submitted path is never withdrawn by this handler; rows and
- // the show-hidden toggle suppress focus steal on mousedown so
- // a click on them lands before any cancel. Window/tab focus
- // loss also fires blur in some engines — only a focus move
- // within a focused document reads as leaving the editor.
- onBlur={() => {
+ // submitted path is never withdrawn here.
+ onBlur={(event) => {
if (!document.hasFocus()) return
+ if (event.relatedTarget instanceof HTMLElement
+ && event.relatedTarget.closest('[role="dialog"]') !== null) return
cancelPathEdit()
}}
/>
@@ -483,6 +495,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
onPick={select}
showHidden={showHidden}
filterPrefix={draftPrefixFor(parent, pathDraft)}
+ pathEditing={draftPending}
/>
)}
{twoPane && }
@@ -494,6 +507,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
onPick={advance}
showHidden={showHidden}
filterPrefix={draftPrefixFor(child, pathDraft)}
+ pathEditing={draftPending}
/>
)}
@@ -523,9 +537,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
aria-pressed={showHidden}
disabled={parentInert}
// The toggle composes with the path editor (dot-led prefixes and
- // this filter interleave): don't steal focus, so toggling never
- // blur-cancels a draft mid-thought.
- onMouseDown={(event) => { event.preventDefault() }}
+ // this filter interleave): while editing, don't steal focus, so
+ // toggling never blur-cancels a draft mid-thought. Outside editing
+ // it keeps native focus behavior.
+ onMouseDown={draftPending ? (event) => { event.preventDefault() } : undefined}
onClick={() => { setShowHidden(prev => !prev) }}
>
{showHidden && }
diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx
index af3aff5385..419572734c 100644
--- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx
+++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx
@@ -29,6 +29,18 @@ function listingFor(path?: string): DirectoryListing {
],
truncated: false,
},
+ [`${HOME}/.config`]: {
+ path: `${HOME}/.config`,
+ home: HOME,
+ crumbs: [
+ { name: '/', path: '/', hidden: false },
+ { name: 'home', path: '/home', hidden: false },
+ { name: 'u', path: HOME, hidden: false },
+ { name: '.config', path: `${HOME}/.config`, hidden: true },
+ ],
+ entries: [],
+ truncated: false,
+ },
[DOCS]: {
path: DOCS,
home: HOME,
@@ -261,23 +273,58 @@ describe('DirectoryBrowser', () => {
expect(within(columns()[1]!).queryAllByRole('listitem')).toHaveLength(0)
expect(within(columns()[0]!).getByText('Documents')).toBeTruthy()
// Erasing back into the parent's own path moves the filter to the LEFT
- // pane and releases the right one.
+ // pane and releases the right one. The selected row is exempt (it
+ // anchors the two-pane view), so it alone survives the miss.
fireEvent.change(input, { target: { value: `${HOME}/zz` } })
- expect(within(columns()[0]!).queryAllByRole('listitem')).toHaveLength(0)
+ expect(within(columns()[0]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents'])
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
})
- it('keeps the path editor open when blur comes from window focus loss', async () => {
+ it('keeps the draft and filter through window focus loss and in-dialog focus moves', async () => {
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: `${HOME}/do` } })
// A blur while the document itself lost focus (window switch, dev-tools
- // focus) must not discard the draft.
+ // focus) must not discard the draft: value and filter both survive.
const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(false)
fireEvent.blur(input)
- expect(screen.getByLabelText('browser.editPath', { selector: 'input' })).toBeTruthy()
hasFocus.mockRestore()
+ expect(screen.getByLabelText('browser.editPath', { selector: 'input' }).value).toBe(`${HOME}/do`)
+ expect(screen.getByRole('listitem').textContent).toBe('Documents')
+ // A keyboard focus move that stays inside the dialog (Tab onto the
+ // filtered row) keeps the draft too — the results stay reachable.
+ fireEvent.blur(input, { relatedTarget: rowButton(screen.getByRole('listitem')) })
+ expect(screen.getByLabelText('browser.editPath', { selector: 'input' }).value).toBe(`${HOME}/do`)
+ // Toggling show-hidden mid-edit suppresses focus steal: the draft and
+ // its filter survive the toggle in both directions.
+ const toggle = screen.getByRole('button', { name: 'browser.showHidden' })
+ fireEvent.mouseDown(toggle)
+ fireEvent.click(toggle)
+ expect(toggle.getAttribute('aria-pressed')).toBe('true')
+ expect(screen.getByLabelText('browser.editPath', { selector: 'input' }).value).toBe(`${HOME}/do`)
+ expect(screen.getByRole('listitem').textContent).toBe('Documents')
+ // Focus landing outside the dialog cancels like Escape.
+ fireEvent.blur(input, { relatedTarget: document.body })
+ expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
+ })
+
+ it('a picked dot-revealed hidden row stays visible as the selection', async () => {
+ 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: `${HOME}/.co` } })
+ const row = rowButton(screen.getByRole('listitem'))
+ expect(row.textContent).toBe('.config')
+ fireEvent.mouseDown(row)
+ fireEvent.click(row)
+ // The pick cleared the draft (and with it the dot-reveal), but the
+ // selection is exempt from the hidden filter: the anchor row survives.
+ expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
+ await waitFor(() => { expect(columns()).toHaveLength(2) })
+ expect(within(columns()[0]!).getByText('.config')).toBeTruthy()
})
it('picking a filtered row adopts it and closes the path editor', async () => {