fix(host): review round 8 — platform-folded draft matching; honest slash-platform boundary; coverage-visible guards
This commit is contained in:
@@ -95,20 +95,23 @@ function separatorOf(listing: DirectoryListing): '\\' | '/' {
|
||||
}
|
||||
|
||||
/**
|
||||
* The path draft's final segment, when its directory part is exactly the
|
||||
* level `listing` lists — the segment the level prefix-filters on while the
|
||||
* user types. Any other draft (no separator yet, or naming some other
|
||||
* directory) leaves the level unfiltered. The directory part compares
|
||||
* exactly (it is the host's own path text, reached by seeding or erasing);
|
||||
* only the name filter downstream is case-insensitive.
|
||||
* The path draft's final segment, when its directory part names the level
|
||||
* `listing` lists — the segment the level prefix-filters on while the user
|
||||
* types. Any other draft (no separator yet, or naming some other directory)
|
||||
* leaves the level unfiltered. The directory part compares under the
|
||||
* platform fold (exact on slash platforms; Windows folds case, since an
|
||||
* upgraded selection may carry the actual entry's case while the level
|
||||
* below still carries the typed one); the name filter downstream is
|
||||
* case-insensitive everywhere.
|
||||
*/
|
||||
function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null {
|
||||
if (draft === null) return null
|
||||
const sep = separatorOf(listing)
|
||||
const cut = draft.lastIndexOf(sep)
|
||||
if (cut === -1) return null
|
||||
const fold = foldPathFor(listing)
|
||||
const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}`
|
||||
return draft.slice(0, cut + 1) === level ? draft.slice(cut + 1) : null
|
||||
return fold(draft.slice(0, cut + 1)) === fold(level) ? draft.slice(cut + 1) : null
|
||||
}
|
||||
|
||||
/** One column of folder rows (the Miller view renders one or two of these). */
|
||||
@@ -231,6 +234,21 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
return { seq, scan: listDirectory(path, controller.signal) }
|
||||
}, [supersede, listDirectory])
|
||||
|
||||
// The miller row's scroll host, shared by the pin and refocus effects
|
||||
// below and read by navigate's upgrade leg (declared ahead of both).
|
||||
const millerRowRef = useRef<HTMLDivElement | null>(null)
|
||||
// Focus parking (consumed by the refocus effect below): 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)
|
||||
const editZoneRef = useRef<HTMLButtonElement | null>(null)
|
||||
|
||||
/**
|
||||
* Launch a follow-up listing under the CURRENT supersession seq: a newer
|
||||
* intent aborts it like the leg it continues, and it supersedes nothing.
|
||||
@@ -240,8 +258,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
// 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()
|
||||
// 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()
|
||||
scanController.current = controller
|
||||
return listDirectory(path, controller.signal)
|
||||
@@ -256,10 +277,13 @@ 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 (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.
|
||||
* pane (Windows folds case; on slash platforms only a FINAL-segment case
|
||||
* drift misses the match and keeps the single-pane landing — parent
|
||||
* entries inherit the typed prefix, so ancestor-segment drift still
|
||||
* matches, at the cost of the Home collapse). 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)
|
||||
@@ -289,9 +313,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
// 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
|
||||
// Inverted so the live contains() probe below stays in coverage.
|
||||
/* 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
|
||||
if (rowHost === null) throw new Error('parent-leg upgrade before the miller row rendered')
|
||||
if (rowHost.contains(document.activeElement)) refocusPick.current = true
|
||||
setParent(parentLevel)
|
||||
setSelected(match)
|
||||
setChild(target)
|
||||
@@ -307,18 +332,6 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
})
|
||||
}, [launchListing, continueScan])
|
||||
|
||||
// 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)
|
||||
const editZoneRef = useRef<HTMLButtonElement | null>(null)
|
||||
|
||||
/** Select a row of the listed level and preview its children on the right. */
|
||||
const select = useCallback((entry: DirectoryEntry) => {
|
||||
const { seq, scan } = launchListing(entry.path)
|
||||
@@ -455,8 +468,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
}, [crumbTail])
|
||||
// On viewports too narrow for both fixed panes the Miller row scrolls;
|
||||
// whenever a child preview lands, pin it into view the way the crumb tail
|
||||
// pins — otherwise descent is unreachable on a phone-width window.
|
||||
const millerRowRef = useRef<HTMLDivElement | null>(null)
|
||||
// pins — otherwise descent is unreachable on a phone-width window. On a
|
||||
// parent-leg upgrade the refocus effect below runs after this pin and its
|
||||
// row.focus() may scroll the selected LEFT row back into view: for that
|
||||
// one landing, focus placement wins over the child pin by design.
|
||||
const childPath = child?.path
|
||||
useEffect(() => {
|
||||
const row = millerRowRef.current
|
||||
@@ -712,8 +727,6 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
onClick={() => { setShowHidden(prev => !prev) }}
|
||||
>
|
||||
{t('browser.showHidden')}
|
||||
{/* Trailing check (Menu's selected vocabulary): the label never
|
||||
* shifts when the pressed state toggles. */}
|
||||
{showHidden && <IconCheckOutline16 size={14} />}
|
||||
</button>
|
||||
<span className={css.footerGap} />
|
||||
|
||||
@@ -356,7 +356,7 @@ describe('DirectoryBrowser', () => {
|
||||
path: TYPED,
|
||||
home: ROOT,
|
||||
crumbs: [{ name: 'C:\\', path: ROOT, hidden: false }, { name: 'users', path: TYPED, hidden: false }],
|
||||
entries: [],
|
||||
entries: [{ name: 'Alpha', path: `${TYPED}\\Alpha`, hidden: false }],
|
||||
truncated: false,
|
||||
}
|
||||
mount({ listDirectory: vi.fn(async (path?: string) => (path === TYPED ? winUsers : winRoot)) })
|
||||
@@ -370,6 +370,16 @@ describe('DirectoryBrowser', () => {
|
||||
expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true')
|
||||
})
|
||||
expect(within(columns()[0]!).getByText('Users')).toBeTruthy()
|
||||
// The editor seeds from the actual-cased selection while the child
|
||||
// level still carries the typed case: the draft's directory part folds
|
||||
// per platform, so the right pane keeps prefix-filtering.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
|
||||
expect(input.value).toBe('C:\\Users\\')
|
||||
fireEvent.change(input, { target: { value: 'C:\\Users\\a' } })
|
||||
expect(within(columns()[1]!).getByText('Alpha')).toBeTruthy()
|
||||
fireEvent.change(input, { target: { value: 'C:\\Users\\z' } })
|
||||
expect(within(columns()[1]!).queryAllByRole('listitem')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('re-parks focus on the edit zone when a failed pick unmounts a dot-revealed row', async () => {
|
||||
|
||||
Reference in New Issue
Block a user