fix(host): review round 7 — upgrade re-parks displaced row focus; case-folded display root; boundary notes

This commit is contained in:
creatixchu
2026-07-29 21:57:06 +08:00
parent eac3b378fa
commit 3224ad92af
5 changed files with 100 additions and 17 deletions

View File

@@ -55,13 +55,27 @@ function failureText(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/**
* Case-folds a path for comparisons under the listing's platform: backslash
* (Windows) paths compare case-insensitively — a typed path legally differs
* in case from the host's stamped one — while slash platforms compare
* exactly (the filesystem may be case-sensitive; macOS typed-case drift
* degrades to the single-pane landing instead of a wrong match).
*/
function foldPathFor(listing: DirectoryListing): (value: string) => string {
const sep = separatorOf(listing)
return value => (sep === '\\' ? value.toLowerCase() : value)
}
/**
* Breadcrumb rows for display: inside the home subtree the chain starts at a
* localized Home crumb; outside it the full ancestry shows, the root labeled
* by its own path.
* by its own path. The home comparison folds per platform so a typed-case
* Windows path still collapses to the Home crumb.
*/
function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryEntry[] {
const homeIndex = listing.crumbs.findIndex(crumb => crumb.path === listing.home)
const fold = foldPathFor(listing)
const homeIndex = listing.crumbs.findIndex(crumb => fold(crumb.path) === fold(listing.home))
if (homeIndex === -1) return listing.crumbs
const tail = listing.crumbs.slice(homeIndex + 1)
return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail]
@@ -222,6 +236,12 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
* intent aborts it like the leg it continues, and it supersedes nothing.
*/
const continueScan = useCallback((path: string): Promise<DirectoryListing> => {
// Abort whatever the slot last tracked before overwriting it (the
// caller's settled leg: a no-op) — the slot must never silently strand
// a live scan, the exact waste supersede() exists to prevent.
const displaced = scanController.current
/* v8 ignore next -- narrowing guard: the landing's target leg installed a controller before any follow-up runs. */
if (displaced !== null) displaced.abort()
const controller = new AbortController()
scanController.current = controller
return listDirectory(path, controller.signal)
@@ -236,9 +256,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
* shape never disagree — a parent leg then upgrades the landing in place:
* the target's ACTUAL parent-level entry re-selected (left pane = parent,
* right pane = the target), so a crumb jump reads as stepping back one
* pane. A failed parent leg, or a truncated parent window that lacks the
* target, leaves the committed single-pane landing — the upgrade must
* never orphan the selection it exists to anchor.
* pane (Windows folds case; slash-platform typed-case drift degrades to
* the single-pane landing). A failed parent leg, or a truncated parent
* window that lacks the target, leaves the committed single-pane landing
* — the upgrade must never orphan the selection it exists to anchor.
*/
const navigate = useCallback((path?: string) => {
const { seq, scan } = launchListing(path)
@@ -259,11 +280,18 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
continueScan(parentCrumb.path).then((parentLevel) => {
if (seq !== requestSeq.current) return
// Windows resolves a typed path preserving its case; anchor on the
// parent level's actual entry so selection comparisons hold.
const sep = separatorOf(parentLevel)
const fold = (value: string): string => (sep === '\\' ? value.toLowerCase() : value)
// parent level's actual entry so selection comparisons hold (slash
// platforms compare exactly — see foldPathFor).
const fold = foldPathFor(parentLevel)
const match = parentLevel.entries.find(entry => fold(entry.path) === fold(target.path))
if (match === undefined) return
// The upgrade replaces every committed row node; if focus lives
// among them (Tab reached the rows during the parent leg), arm the
// refocus effect so it re-parks on the re-selected row.
const rowHost = millerRowRef.current
/* v8 ignore next -- narrowing guard: the committed landing just rendered the miller row. */
const focusInRows = rowHost !== null && rowHost.contains(document.activeElement)
if (focusInRows) refocusPick.current = true
setParent(parentLevel)
setSelected(match)
setChild(target)
@@ -279,11 +307,13 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
})
}, [launchListing, continueScan])
// Editor-close focus parking (consumed by the refocus effect below the
// miller-row ref): a pick parks on the selection's row, Enter and an
// input-focused Escape park on the crumb edit zone that replaces the
// input. Pointer-out cancels never set (or clear) these — yanking focus
// back from wherever the user clicked would be worse than the fall.
// Focus parking (consumed by the refocus effect below the miller-row
// ref): a pick — and a parent-leg upgrade that displaces focused rows —
// parks on the selection's row; Enter, an input-focused Escape, and a
// failed pick whose row unmounts park on the crumb edit zone (the latter
// only when focus actually fell to body). Pointer-out cancels never set
// (or clear) these — yanking focus back from wherever the user clicked
// would be worse than the fall.
const refocusPick = useRef(false)
const refocusEditZone = useRef(false)
const pathInputRef = useRef<HTMLInputElement | null>(null)

View File

@@ -270,6 +270,59 @@ describe('DirectoryBrowser', () => {
expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull()
})
it('re-parks focus on the re-selected row when the upgrade displaces focused rows', async () => {
const settlers: ((value: DirectoryListing) => void)[] = []
const listDirectory = vi.fn(async (path?: string) => {
if (path === HOME) {
return new Promise<DirectoryListing>((resolve) => { settlers.push(resolve) })
}
return listingFor(path)
})
mount({ listDirectory })
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: DOCS } })
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
// The committed landing is interactive; Tab reaches its rows while the
// parent leg is still in flight.
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
rowButton(screen.getByRole('listitem')).focus()
await waitFor(() => { expect(settlers).toHaveLength(1) })
// The upgrade replaces every committed row node; focus re-parks on the
// re-selected row instead of falling to body.
await act(async () => { settlers[0]!(listingFor(HOME)) })
await waitFor(() => { expect(columns()).toHaveLength(2) })
expect(document.activeElement?.textContent).toBe('Documents')
expect(document.activeElement?.getAttribute('aria-current')).toBe('true')
})
it('collapses a typed-case Windows home to the display root (single pane, Home crumb)', async () => {
const CANON = 'C:\\Users\\Alice'
const TYPED = 'c:\\users\\alice'
const typedHome: DirectoryListing = {
path: TYPED,
home: CANON,
crumbs: [
{ name: 'C:\\', path: 'C:\\', hidden: false },
{ name: 'users', path: 'c:\\users', hidden: false },
{ name: 'alice', path: TYPED, hidden: false },
],
entries: [{ name: 'Desktop', path: `${CANON}\\Desktop`, hidden: false }],
truncated: false,
}
const canonHome: DirectoryListing = { ...typedHome, path: CANON, crumbs: typedHome.crumbs }
mount({ listDirectory: vi.fn(async (path?: string) => (path === TYPED ? typedHome : canonHome)) })
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: TYPED } })
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
// Case-folded home comparison: the typed-case home is still the display
// root — single pane, collapsed Home crumb, no parent leg.
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Desktop') })
expect(columns()).toHaveLength(1)
expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy()
})
it('keeps the single-pane landing when the truncated parent level lacks the target', async () => {
const listDirectory = vi.fn(async (path?: string) => {
// The parent leg names HOME explicitly; serve it a truncated window