fix(host): review round 6 — progressive selection-anchored landing; anchor on actual parent entries; focus-flag hygiene

This commit is contained in:
creatixchu
2026-07-29 21:34:30 +08:00
parent a1d752f799
commit eac3b378fa
9 changed files with 182 additions and 64 deletions

View File

@@ -261,8 +261,9 @@
}
/* Show-hidden toggle: a subtle fixed-label text button left of the gap;
* the pressed state seats a check glyph before the label (Menu's selected
* vocabulary) instead of flipping the wording. */
* the pressed state seats a check glyph after the label (Menu's selected
* vocabulary; trailing so the label never shifts) instead of flipping the
* wording. */
.showHiddenToggle {
display: inline-flex;
align-items: center;

View File

@@ -5,7 +5,10 @@
* breadcrumb, and a click-to-edit path zone; below it a Miller view — one
* full-width level until a row is selected, then two columns splitting the
* row evenly (256px floor; level | selected folder's children) around a
* hairline divider. Selecting in the
* hairline divider. Navigations land selection-anchored: a crumb jump or a
* submitted path commits the target immediately, then re-selects it 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
* create dialog targeting the selected folder (or the level itself) and
* selects the created folder. Open adopts the selected folder, falling back
@@ -215,12 +218,27 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
}, [supersede, listDirectory])
/**
* Replace the whole view with a freshly navigated level. Away from the
* display root the landing keeps the navigated directory SELECTED inside
* its parent level (left pane = parent, right pane = its children), so a
* crumb jump or a submitted path reads as stepping back one pane instead
* of collapsing to a single column; the display root (the home level, or
* a chain with no parent) keeps the single wide level.
* Launch a follow-up listing under the CURRENT supersession seq: a newer
* intent aborts it like the leg it continues, and it supersedes nothing.
*/
const continueScan = useCallback((path: string): Promise<DirectoryListing> => {
const controller = new AbortController()
scanController.current = controller
return listDirectory(path, controller.signal)
}, [listDirectory])
/**
* Replace the whole view with a freshly navigated level. The target level
* commits the moment it arrives (single wide level: the editor closes and
* loading ends on this first settlement, so an Enter-submitted navigation
* is never withdrawn waiting on anything further). Away from the display
* root — the same collapse the crumb header renders, so crumbs and pane
* 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.
*/
const navigate = useCallback((path?: string) => {
const { seq, scan } = launchListing(path)
@@ -228,46 +246,38 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
setError(null)
scan.then((target) => {
if (seq !== requestSeq.current) return
setParent(target)
setSelected(null)
setChild(null)
setLoading(false)
setPathDraft(null)
// Arity is label-independent: only the collapsed chain's depth decides.
if (displayCrumbs(target, '').length < 2) return
const parentCrumb = target.crumbs.at(-2)
const anchor = target.crumbs.at(-1)
if (target.path === target.home || parentCrumb === undefined
/* v8 ignore next -- narrowing: the anchor crumb exists whenever a parent crumb does (root-to-target inclusive chain). */
|| anchor === undefined) {
setParent(target)
setSelected(null)
setChild(null)
setLoading(false)
setPathDraft(null)
return
}
// Two-pane landing: the parent leg runs under the same supersession
// scope (a newer intent aborts it like the first leg).
const controller = new AbortController()
scanController.current = controller
listDirectory(parentCrumb.path, controller.signal).then((parentLevel) => {
/* v8 ignore next -- narrowing: a two-deep display chain implies a parent crumb (root-to-target inclusive). */
if (parentCrumb === undefined) return
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)
const match = parentLevel.entries.find(entry => fold(entry.path) === fold(target.path))
if (match === undefined) return
setParent(parentLevel)
setSelected(anchor)
setSelected(match)
setChild(target)
setLoading(false)
setPathDraft(null)
}, () => {
if (seq !== requestSeq.current) return
// The target listed fine and is what the user asked for; a parent
// leg failure quietly falls back to the single-pane landing rather
// than surfacing an error for a level nobody requested.
setParent(target)
setSelected(null)
setChild(null)
setLoading(false)
setPathDraft(null)
// Swallows the parent-leg failure (its abort included): the
// committed single-pane landing stands, and nobody asked to see
// the parent level.
})
}, (reason: unknown) => {
if (seq !== requestSeq.current) return
setLoading(false)
setError(failureText(reason))
})
}, [launchListing, listDirectory])
}, [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
@@ -302,6 +312,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
// An unreadable selection cannot be the committing target while the
// breadcrumb still names the level: fall back to the single pane.
setSelected(null)
// Clearing the selection can unmount the very row the pick parked
// focus on (a dot-revealed hidden row re-hides); the refocus effect
// re-parks on the edit zone only if focus actually fell to body.
refocusEditZone.current = true
})
}, [launchListing, pathDraft])
@@ -350,6 +364,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
setPathDraft(null)
setFolderDraft(null)
setCreateError(null)
// A close mid-flight (failed Enter, then Cancel) may leave refocus
// flags armed; retire them so a later render cannot consume them.
refocusPick.current = false
refocusEditZone.current = false
}, [open, navigate, supersede])
/** The folder a create or Open acts on: the selection, else the listed level. */
@@ -436,6 +454,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
}
if (refocusEditZone.current) {
refocusEditZone.current = false
// Re-park only when the close actually dropped focus to body; focus
// the user parked elsewhere (a surviving row) stays theirs.
if (document.activeElement !== document.body) return
const zone = editZoneRef.current
/* v8 ignore next -- narrowing guard: crumb mode renders the edit zone whenever the editor just closed. */
if (zone === null) return
@@ -483,8 +504,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
event.stopPropagation()
// Escape while the input holds focus is about to unmount it; with
// focus already parked on a row, that row survives the cancel and
// keeps focus naturally.
if (document.activeElement === pathInputRef.current) refocusEditZone.current = true
// keeps focus naturally. Assignment (not a conditional set) also
// retires a stale flag a failed or still-upgrading Enter left.
refocusEditZone.current = document.activeElement === pathInputRef.current
cancelPathEdit()
}}
// Focus leaving THIS dialog card while editing cancels like Escape.