fix(host): review round 9 — universal pick refocus; always-armed scan controller; graceful close-race guards
This commit is contained in:
@@ -2,13 +2,14 @@
|
|||||||
* The in-app workspace-directory browser (figma Harness 813-23126 family): a
|
* The in-app workspace-directory browser (figma Harness 813-23126 family): a
|
||||||
* 680×500 dialog (clamped to short/narrow viewports — the Miller row scrolls
|
* 680×500 dialog (clamped to short/narrow viewports — the Miller row scrolls
|
||||||
* sideways, the columns scroll down) whose header carries the title, the selection-path
|
* sideways, the columns scroll down) whose header carries the title, the selection-path
|
||||||
* breadcrumb, and a click-to-edit path zone; below it a Miller view — one
|
* breadcrumb, and a click-to-edit path zone; below it a Miller view of one
|
||||||
* full-width level until a row is selected, then two columns splitting the
|
* or two columns splitting the row evenly (256px floor; level | selected
|
||||||
* row evenly (256px floor; level | selected folder's children) around a
|
* folder's children) around a hairline divider — the display root and
|
||||||
* hairline divider. Navigations land selection-anchored: a crumb jump or a
|
* degraded landings keep the single wide level, while any selection opens
|
||||||
* submitted path commits the target immediately, then re-selects it in its
|
* the second pane, including the one a navigation lands with: a crumb jump
|
||||||
* parent level once that level arrives, so stepping back keeps two panes
|
* or a submitted path commits the target immediately, then re-selects it
|
||||||
* away from the display root. Selecting in the
|
* in its parent level once that level arrives, so stepping back keeps two
|
||||||
|
* panes away from the display root. Selecting in the
|
||||||
* right column shifts the view one level deeper. "New folder" opens a nested
|
* right column shifts the view one level deeper. "New folder" opens a nested
|
||||||
* create dialog targeting the selected folder (or the level itself) and
|
* create dialog targeting the selected folder (or the level itself) and
|
||||||
* selects the created folder. Open adopts the selected folder, falling back
|
* selects the created folder. Open adopts the selected folder, falling back
|
||||||
@@ -56,14 +57,15 @@ function failureText(error: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Case-folds a path for comparisons under the listing's platform: backslash
|
* Case-folds a path for comparisons under the given separator's platform:
|
||||||
* (Windows) paths compare case-insensitively — a typed path legally differs
|
* backslash (Windows) paths compare case-insensitively — a typed path
|
||||||
* in case from the host's stamped one — while slash platforms compare
|
* legally differs in case from the host's stamped one — while slash
|
||||||
* exactly (the filesystem may be case-sensitive; macOS typed-case drift
|
* platforms compare exactly (the filesystem may be case-sensitive; only a
|
||||||
* degrades to the single-pane landing instead of a wrong match).
|
* FINAL-segment macOS case drift misses parent-entry matching and keeps
|
||||||
|
* the single-pane landing, since parent entry paths inherit the typed
|
||||||
|
* prefix).
|
||||||
*/
|
*/
|
||||||
function foldPathFor(listing: DirectoryListing): (value: string) => string {
|
function foldPathFor(sep: '\\' | '/'): (value: string) => string {
|
||||||
const sep = separatorOf(listing)
|
|
||||||
return value => (sep === '\\' ? value.toLowerCase() : value)
|
return value => (sep === '\\' ? value.toLowerCase() : value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +76,7 @@ function foldPathFor(listing: DirectoryListing): (value: string) => string {
|
|||||||
* Windows path still collapses to the Home crumb.
|
* Windows path still collapses to the Home crumb.
|
||||||
*/
|
*/
|
||||||
function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryEntry[] {
|
function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryEntry[] {
|
||||||
const fold = foldPathFor(listing)
|
const fold = foldPathFor(separatorOf(listing))
|
||||||
const homeIndex = listing.crumbs.findIndex(crumb => fold(crumb.path) === fold(listing.home))
|
const homeIndex = listing.crumbs.findIndex(crumb => fold(crumb.path) === fold(listing.home))
|
||||||
if (homeIndex === -1) return listing.crumbs
|
if (homeIndex === -1) return listing.crumbs
|
||||||
const tail = listing.crumbs.slice(homeIndex + 1)
|
const tail = listing.crumbs.slice(homeIndex + 1)
|
||||||
@@ -109,7 +111,7 @@ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string
|
|||||||
const sep = separatorOf(listing)
|
const sep = separatorOf(listing)
|
||||||
const cut = draft.lastIndexOf(sep)
|
const cut = draft.lastIndexOf(sep)
|
||||||
if (cut === -1) return null
|
if (cut === -1) return null
|
||||||
const fold = foldPathFor(listing)
|
const fold = foldPathFor(sep)
|
||||||
const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}`
|
const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}`
|
||||||
return fold(draft.slice(0, cut + 1)) === fold(level) ? draft.slice(cut + 1) : null
|
return fold(draft.slice(0, cut + 1)) === fold(level) ? draft.slice(cut + 1) : null
|
||||||
}
|
}
|
||||||
@@ -195,8 +197,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|||||||
const requestSeq = useRef(0)
|
const requestSeq = useRef(0)
|
||||||
// The in-flight listing's controller: superseding intent aborts the wire
|
// The in-flight listing's controller: superseding intent aborts the wire
|
||||||
// request too — the Host stops scanning — instead of only discarding the
|
// request too — the Host stops scanning — instead of only discarding the
|
||||||
// eventual result while the scan keeps consuming host resources.
|
// eventual result while the scan keeps consuming host resources. Always
|
||||||
const scanController = useRef<AbortController | null>(null)
|
// holds a controller (a settled or aborted one between scans) so no
|
||||||
|
// consumer needs a null guard.
|
||||||
|
const scanController = useRef<AbortController>(new AbortController())
|
||||||
// Bumped on every open/close edge: settlements from a previous open (a
|
// Bumped on every open/close edge: settlements from a previous open (a
|
||||||
// pending creation included) must never mutate a reopened dialog.
|
// pending creation included) must never mutate a reopened dialog.
|
||||||
const openGeneration = useRef(0)
|
const openGeneration = useRef(0)
|
||||||
@@ -212,7 +216,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
requestSeq.current += 1
|
requestSeq.current += 1
|
||||||
openGeneration.current += 1
|
openGeneration.current += 1
|
||||||
scanController.current?.abort()
|
scanController.current.abort()
|
||||||
}, [])
|
}, [])
|
||||||
const compositionGuard = {
|
const compositionGuard = {
|
||||||
onCompositionStart: () => { composingRef.current = true },
|
onCompositionStart: () => { composingRef.current = true },
|
||||||
@@ -221,8 +225,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|||||||
|
|
||||||
/** Newer intent wins: invalidate the pending listing's settlement AND abort its wire request. */
|
/** Newer intent wins: invalidate the pending listing's settlement AND abort its wire request. */
|
||||||
const supersede = useCallback((): number => {
|
const supersede = useCallback((): number => {
|
||||||
scanController.current?.abort()
|
scanController.current.abort()
|
||||||
scanController.current = null
|
|
||||||
return ++requestSeq.current
|
return ++requestSeq.current
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -257,12 +260,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|||||||
// Abort whatever the slot last tracked before overwriting it (the
|
// Abort whatever the slot last tracked before overwriting it (the
|
||||||
// caller's settled leg: a no-op) — the slot must never silently strand
|
// caller's settled leg: a no-op) — the slot must never silently strand
|
||||||
// a live scan, the exact waste supersede() exists to prevent.
|
// a live scan, the exact waste supersede() exists to prevent.
|
||||||
const displaced = scanController.current
|
scanController.current.abort()
|
||||||
// Inverted so the live abort below stays in coverage: a supersede
|
|
||||||
// would have bumped the seq before any follow-up could run.
|
|
||||||
/* v8 ignore next -- narrowing guard: the landing's target leg installed a controller first. */
|
|
||||||
if (displaced === null) throw new Error('continueScan launched before any leg installed a controller')
|
|
||||||
displaced.abort()
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
scanController.current = controller
|
scanController.current = controller
|
||||||
return listDirectory(path, controller.signal)
|
return listDirectory(path, controller.signal)
|
||||||
@@ -306,16 +304,18 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|||||||
// Windows resolves a typed path preserving its case; anchor on the
|
// Windows resolves a typed path preserving its case; anchor on the
|
||||||
// parent level's actual entry so selection comparisons hold (slash
|
// parent level's actual entry so selection comparisons hold (slash
|
||||||
// platforms compare exactly — see foldPathFor).
|
// platforms compare exactly — see foldPathFor).
|
||||||
const fold = foldPathFor(parentLevel)
|
const fold = foldPathFor(separatorOf(parentLevel))
|
||||||
const match = parentLevel.entries.find(entry => fold(entry.path) === fold(target.path))
|
const match = parentLevel.entries.find(entry => fold(entry.path) === fold(target.path))
|
||||||
if (match === undefined) return
|
if (match === undefined) return
|
||||||
// The upgrade replaces every committed row node; if focus lives
|
// The upgrade replaces every committed row node; if focus lives
|
||||||
// among them (Tab reached the rows during the parent leg), arm the
|
// among them (Tab reached the rows during the parent leg), arm the
|
||||||
// refocus effect so it re-parks on the re-selected row.
|
// refocus effect so it re-parks on the re-selected row.
|
||||||
const rowHost = millerRowRef.current
|
const rowHost = millerRowRef.current
|
||||||
// Inverted so the live contains() probe below stays in coverage.
|
// A close race can clear the ref before the close effect's
|
||||||
/* v8 ignore next -- narrowing guard: the committed landing just rendered the miller row. */
|
// supersede runs (commit precedes passive effects): drop the
|
||||||
if (rowHost === null) throw new Error('parent-leg upgrade before the miller row rendered')
|
// upgrade, the dialog is going away.
|
||||||
|
/* v8 ignore next -- close-race guard: the commit-to-effect window is not deterministically reproducible. */
|
||||||
|
if (rowHost === null) return
|
||||||
if (rowHost.contains(document.activeElement)) refocusPick.current = true
|
if (rowHost.contains(document.activeElement)) refocusPick.current = true
|
||||||
setParent(parentLevel)
|
setParent(parentLevel)
|
||||||
setSelected(match)
|
setSelected(match)
|
||||||
@@ -336,9 +336,13 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|||||||
const select = useCallback((entry: DirectoryEntry) => {
|
const select = useCallback((entry: DirectoryEntry) => {
|
||||||
const { seq, scan } = launchListing(entry.path)
|
const { seq, scan } = launchListing(entry.path)
|
||||||
// A pick while the path editor is open adopts the (filtered) row and
|
// A pick while the path editor is open adopts the (filtered) row and
|
||||||
// closes the editor — the draft served its purpose. Focus re-parks on
|
// closes the editor — the draft served its purpose. EVERY pick re-parks
|
||||||
// the selection after commit (see the refocus effect below).
|
// focus on the selection after commit (see the refocus effect below):
|
||||||
if (pathDraft !== null) refocusPick.current = true
|
// a left-pane pick lands on the very row that was clicked (a near
|
||||||
|
// no-op), while a right-pane advance and a create landing replace the
|
||||||
|
// picked button's column entirely and would otherwise drop focus to
|
||||||
|
// body.
|
||||||
|
refocusPick.current = true
|
||||||
setPathDraft(null)
|
setPathDraft(null)
|
||||||
setSelected(entry)
|
setSelected(entry)
|
||||||
setChild(null)
|
setChild(null)
|
||||||
@@ -477,12 +481,13 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|||||||
const row = millerRowRef.current
|
const row = millerRowRef.current
|
||||||
if (row !== null && childPath !== undefined) row.scrollLeft = row.scrollWidth
|
if (row !== null && childPath !== undefined) row.scrollLeft = row.scrollWidth
|
||||||
}, [childPath])
|
}, [childPath])
|
||||||
// Every editor exit that would drop focus to body re-parks it after
|
// Every pick and editor exit that would drop focus to body re-parks it
|
||||||
// commit, so keyboard traversal stays inside the dialog (the Modal has no
|
// after commit, so keyboard traversal stays inside the dialog (the Modal
|
||||||
// focus trap): a pick lands on the selection's row — aria-current in the
|
// has no focus trap): a pick lands on the selection's row — aria-current
|
||||||
// freshly rendered left pane, which survives even a right-pane advance
|
// in the freshly rendered left pane, which survives even a right-pane
|
||||||
// replacing the picked button's column — while Enter and an input-focused
|
// advance or a create landing replacing the picked button's column —
|
||||||
// Escape land on the crumb edit zone that replaces the input.
|
// while Enter and an input-focused Escape land on the crumb edit zone
|
||||||
|
// that replaces the input.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pathDraft !== null) return
|
if (pathDraft !== null) return
|
||||||
if (refocusPick.current) {
|
if (refocusPick.current) {
|
||||||
@@ -492,10 +497,14 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|||||||
/* v8 ignore next -- narrowing guard: the miller row is mounted whenever a pick just committed. */
|
/* v8 ignore next -- narrowing guard: the miller row is mounted whenever a pick just committed. */
|
||||||
if (rowHost === null) return
|
if (rowHost === null) return
|
||||||
const row = rowHost.querySelector<HTMLButtonElement>('button[aria-current="true"]')
|
const row = rowHost.querySelector<HTMLButtonElement>('button[aria-current="true"]')
|
||||||
/* v8 ignore next -- narrowing guard: the pick that set the flag just rendered its aria-current row. */
|
if (row !== null) {
|
||||||
if (row === null) return
|
row.focus()
|
||||||
row.focus()
|
return
|
||||||
return
|
}
|
||||||
|
// The pick lost its row (a truncated relist after Create can drop
|
||||||
|
// the created directory outside the window): fall through to the
|
||||||
|
// edit-zone parking below instead of leaving focus where it fell.
|
||||||
|
refocusEditZone.current = true
|
||||||
}
|
}
|
||||||
if (refocusEditZone.current) {
|
if (refocusEditZone.current) {
|
||||||
refocusEditZone.current = false
|
refocusEditZone.current = false
|
||||||
|
|||||||
@@ -629,6 +629,56 @@ describe('DirectoryBrowser', () => {
|
|||||||
expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy()
|
expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('a plain right-pane advance parks focus on the new selection (no editor involved)', async () => {
|
||||||
|
mount()
|
||||||
|
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||||
|
fireEvent.click(rowButton(screen.getByRole('listitem')))
|
||||||
|
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||||
|
// Keyboard reached the right pane; the advance replaces that whole
|
||||||
|
// column, so focus re-parks on the new left pane's selected row.
|
||||||
|
const row = rowButton(within(columns()[1]!).getByRole('listitem'))
|
||||||
|
row.focus()
|
||||||
|
fireEvent.click(row)
|
||||||
|
await waitFor(() => { expect(document.activeElement?.textContent).toBe('harness') })
|
||||||
|
expect(document.activeElement?.getAttribute('aria-current')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a create landing parks focus on the created row, or the edit zone when the relist lost it', async () => {
|
||||||
|
// First create: the relist contains the created directory.
|
||||||
|
const b = mount()
|
||||||
|
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||||
|
b.listDirectory.mockImplementation(async (path?: string) => {
|
||||||
|
// The created directory is not in listingFor's fixed tree: serve its
|
||||||
|
// level before the fixture lookup can reject the unknown path.
|
||||||
|
if (path === `${HOME}/fresh`) return { ...listingFor(HOME), path: `${HOME}/fresh`, entries: [] }
|
||||||
|
const base = listingFor(path)
|
||||||
|
if (path === HOME) {
|
||||||
|
return { ...base, entries: [...base.entries, { name: 'fresh', path: `${HOME}/fresh`, hidden: false }] }
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
||||||
|
fireEvent.change(screen.getByLabelText('browser.folderName'), { target: { value: 'fresh' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'browser.create' }))
|
||||||
|
await waitFor(() => { expect(document.activeElement?.textContent).toBe('fresh') })
|
||||||
|
expect(document.activeElement?.getAttribute('aria-current')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a create landing whose truncated relist lost the created row parks on the edit zone', async () => {
|
||||||
|
const b = mount()
|
||||||
|
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||||
|
// The relist window misses the created directory (truncated tail).
|
||||||
|
b.listDirectory.mockImplementation(async (path?: string) => ({ ...listingFor(path), truncated: true }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
||||||
|
fireEvent.change(screen.getByLabelText('browser.folderName'), { target: { value: 'zzz-tail' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'browser.create' }))
|
||||||
|
// No aria-current row exists for the selection: focus falls back to the
|
||||||
|
// crumb edit zone instead of staying wherever it fell.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('a right-pane pick while editing parks focus on the advanced selection', async () => {
|
it('a right-pane pick while editing parks focus on the advanced selection', async () => {
|
||||||
mount()
|
mount()
|
||||||
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||||
|
|||||||
Reference in New Issue
Block a user