fix(host): review round 7 — upgrade re-parks displaced row focus; case-folded display root; boundary notes
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user