fix(host): review round 18 — draft dirs fully normalized; JSDoc reattached; third parking target documented

This commit is contained in:
creatixchu
2026-07-30 01:28:55 +08:00
parent 12d302ea59
commit 9545914c10
5 changed files with 53 additions and 34 deletions

View File

@@ -72,6 +72,15 @@ function foldPathFor(sep: '\\' | '/'): (value: string) => string {
return value => (sep === '\\' ? value.toLowerCase() : value)
}
/**
* Folds separators to the platform's canonical one: win32 treats a forward
* slash as a separator too (resolve() folds them the same way), while
* POSIX must not — a backslash there is a name character.
*/
function foldSeparatorsFor(sep: '\\' | '/'): (value: string) => string {
return value => (sep === '\\' ? value.replaceAll('/', sep) : value)
}
/**
* Lexically normalizes an absolute host path for comparisons: folds
* forward slashes on Windows (win32 accepts either), collapses repeated
@@ -82,15 +91,6 @@ function foldPathFor(sep: '\\' | '/'): (value: string) => string {
* backend's own paths arrive already resolved. A lexical mirror only:
* symlinks are the backend's business.
*/
/**
* Folds separators to the platform's canonical one: win32 treats a forward
* slash as a separator too (resolve() folds them the same way), while
* POSIX must not — a backslash there is a name character.
*/
function foldSeparatorsFor(sep: '\\' | '/'): (value: string) => string {
return value => (sep === '\\' ? value.replaceAll('/', sep) : value)
}
function normalizePathFor(sep: '\\' | '/'): (value: string) => string {
const foldSeparators = foldSeparatorsFor(sep)
return (raw) => {
@@ -161,12 +161,13 @@ function separatorOf(listing: DirectoryListing): '\\' | '/' {
* 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 — and folds forward slashes, which
* win32 and the backend both accept in typed paths); the name filter
* downstream is case-insensitive everywhere.
* leaves the level unfiltered. The directory part compares lexically
* normalized (dot segments, repeated separators, and win32 forward
* slashes all match what Enter would navigate to) and under the platform
* case fold (exact on slash platforms; Windows folds, 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
@@ -175,8 +176,10 @@ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string
const cut = folded.lastIndexOf(sep)
if (cut === -1) return null
const fold = foldPathFor(sep)
const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}`
return fold(folded.slice(0, cut + 1)) === fold(level) ? folded.slice(cut + 1) : null
const normalize = normalizePathFor(sep)
return fold(normalize(folded.slice(0, cut + 1))) === fold(normalize(listing.path))
? folded.slice(cut + 1)
: null
}
/** One column of folder rows (the Miller view renders one or two of these). */
@@ -312,25 +315,31 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
// whose new level dropped the focused row, a failed pick, and every
// create-dialog exit, whose close-time parking is also what a failed
// relist inherits) parks on the crumb edit zone, each 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.
// actually fell to body. One parking bypasses both flags: the
// show-hidden toggle's click reclaims focus onto itself, synchronously,
// when the click finds focus among the rows. 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 editZoneRef = useRef<HTMLButtonElement | null>(null)
/**
* Whether the focused element sits among the miller rows — probed before
* a landing replaces the row nodes, to decide focus parking. A probe
* only: it never gates the landing itself — a torn-down ref in a close
* race merely skips the parking, and committing the landing into a
* closing dialog is safe (the component already renders null, and the
* open effect resets parent/selected/child on the next open).
* a landing replaces the row nodes to decide focus parking, and by the
* show-hidden toggle's click to decide whether to reclaim the native
* focus outcome. A probe only: it never gates its caller — a torn-down
* ref in a landing's close race merely skips the parking, and
* committing the landing into a closing dialog is safe (the component
* already renders null, and the open effect resets parent/selected/child
* on the next open).
* @returns true when `document.activeElement` is inside the miller row.
*/
const focusInMillerRows = useCallback((): boolean => {
const rowHost = millerRowRef.current
/* v8 ignore next -- close-race guard: the commit-to-effect window is not deterministically reproducible. */
// Only the landing callers can race a close (commit precedes the reset
// effect); the toggle's click caller always finds the host mounted.
/* v8 ignore next -- close-race guard: not deterministically reproducible. */
if (rowHost === null) return false
return rowHost.contains(document.activeElement)
}, [])
@@ -843,10 +852,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
onMouseDown={draftPending ? (event) => { event.preventDefault() } : undefined}
onClick={(event) => {
// The suppression above exists to protect the INPUT's focus;
// when focus is instead on a row that this very toggle may
// re-hide, restore the native outcome — the clicked toggle
// keeps focus in the card (the editing-time refocus effect
// deliberately stays out of the way).
// with focus among the rows instead, hand back the native
// click outcome wholesale — the clicked toggle takes focus
// and stays in the card. Accepted cost: this also moves
// focus off a row the toggle would NOT have hidden; tracking
// which rows a direction change unmounts is not worth it.
if (focusInMillerRows()) event.currentTarget.focus()
setShowHidden(prev => !prev)
}}

View File

@@ -541,6 +541,9 @@ describe('DirectoryBrowser', () => {
// them); the filter folds them instead of going silent.
fireEvent.change(input, { target: { value: 'C:/Users/a' } })
expect(within(columns()[1]!).getByText('Alpha')).toBeTruthy()
// Dot segments normalize on win32 too.
fireEvent.change(input, { target: { value: 'C:\\Users\\.\\a' } })
expect(within(columns()[1]!).getByText('Alpha')).toBeTruthy()
})
it('re-parks focus on the edit zone when a failed pick unmounts a dot-revealed row', async () => {
@@ -673,6 +676,12 @@ describe('DirectoryBrowser', () => {
// A draft naming some other directory (or none) leaves the level whole.
fireEvent.change(input, { target: { value: 'no-separator' } })
expect(screen.getByRole('listitem').textContent).toBe('Documents')
// Dot segments and repeated separators are legal for Enter, so the
// filter's directory comparison normalizes them the same way.
fireEvent.change(input, { target: { value: `${HOME}/./do` } })
expect(screen.getByRole('listitem').textContent).toBe('Documents')
fireEvent.change(input, { target: { value: `${HOME}//do` } })
expect(screen.getByRole('listitem').textContent).toBe('Documents')
})
it('filters the child pane in two-pane mode and follows the draft back up a level', async () => {