revert: restore the reviewer-approved tree (97a192d7b)

ZiyaZhang approved 97a192d7b; the subsequent bot-review rounds (7-24)
landed after that approval and were not requested by a human reviewer.
This commit restores the approved tree verbatim as a forward commit
(pushed history stays intact). git diff 97a192d7b is empty.
This commit is contained in:
creatixchu
2026-07-30 09:22:16 +08:00
parent 53e85101b9
commit b46dcb16cb
24 changed files with 149 additions and 771 deletions

View File

@@ -52,6 +52,24 @@
/* Deep chains scroll inside the trail (the effect pins the tail into view)
* so the edit zone to the right never leaves the bar. */
/* The Miller columns keep their own row so a status/error line below never
* competes with the fixed column widths for horizontal space. */
/* A narrow viewport shrinks the dialog below two fixed panes; the row
* scrolls horizontally (the effect pins the child pane into view) so
* descent never hides behind the Modal's clipping. */
.millerRow {
display: flex;
align-items: stretch;
flex: 1 1 0;
min-height: 0;
/* 12px of row gap on each side of the divider; the left side reads wider
* by the column's trailing 8px scrollbar clearance, which is deliberate —
* the thumb needs that room, the right pane's rows do not. */
gap: 12px;
overflow-x: auto;
scrollbar-width: none;
}
.crumbTrail {
display: flex;
align-items: center;
@@ -62,12 +80,6 @@
scrollbar-width: none;
}
/* Pseudo-element-path engines (see .millerRow's twin rule): the 20px crumb
* bar has no room for a bar at all. */
.crumbTrail::-webkit-scrollbar {
display: none;
}
.crumbSeat {
display: inline-flex;
align-items: center;
@@ -134,36 +146,11 @@
flex-direction: column;
flex: 1 1 0;
min-height: 0;
/* Right inset is slimmer than the left: the trailing column's own
* scrollbar clearance (see .column) makes up the optical difference. */
/* Right inset is slimmer than the left: the trailing column's own 8px
* scrollbar clearance makes up the optical difference. */
padding: 16px 16px 16px 24px;
}
/* The Miller columns keep their own row so a status/error line below never
* competes with the fixed column widths for horizontal space. */
/* A narrow viewport shrinks the dialog below two fixed panes; the row
* scrolls horizontally (the effect pins the child pane into view) so
* descent never hides behind the Modal's clipping. */
.millerRow {
display: flex;
align-items: stretch;
flex: 1 1 0;
min-height: 0;
/* 12px of row gap on each side of the divider; the left side reads wider
* by the column's trailing scrollbar clearance (see .column) — the thumb
* needs that room, the right pane's rows do not. */
gap: 12px;
overflow-x: auto;
scrollbar-width: none;
}
/* Engines that predate scrollbar-width take the pseudo-element path (the
* two are mutually exclusive by construction — see ui-theme's scrollbar
* contract); hide the row's horizontal bar there too. */
.millerRow::-webkit-scrollbar {
display: none;
}
/* Columns split the row evenly around the divider (a solo column takes the
* whole row); 256px is the floor below which the row scrolls (scrollbar
* hidden, the effect pins the child pane into view) instead of squeezing
@@ -305,12 +292,6 @@
color: var(--dsw-alias-label-primary);
}
/* Flex-none: the nowrap label refuses to shrink, which would leave the
* glyph as the only compressible item under wrap or narrow-viewport clamp. */
.toggleCheck {
flex: none;
}
.footerGap {
flex: 1 1 0;
}

View File

@@ -2,20 +2,16 @@
* The in-app workspace-directory browser (figma Harness 813-23126 family): a
* 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
* breadcrumb, and a click-to-edit path zone; below it a Miller view of one
* or two columns splitting the row evenly (256px floor; level | selected
* folder's children) around a hairline divider — the display root and
* degraded landings keep the single wide level, while any selection opens
* the second pane, including the one a navigation lands with: 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
* 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. 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 — unless a newer pick or crumb jump supersedes
* the post-create relist, in which case neither the level nor the selection
* refreshes (see closeCreateDialog's two-stage parking for the matching
* focus story). Open adopts the selected folder, falling back
* selects the created folder. Open adopts the selected folder, falling back
* to the listed level. Pure consumer of the injected browse calls — the
* owning flow decides what "Open" means and owns the workspace-creation
* error surface. Hidden entries are host-flagged and hidden by default; the
@@ -37,16 +33,11 @@ import css from './DirectoryBrowser.module.css'
/** Owner-supplied browser props: browse calls, pick semantics, and copy. */
export interface DirectoryBrowserProps {
/** Dialog visibility (owner-local; closing resets the per-open state, so a reopen starts clean on its first frame). */
/** Dialog visibility (owner-local; closed unmounts nothing but resets on reopen). */
open: boolean
/** List one directory level (absent path = the Host home directory); the signal aborts a superseded scan on the wire. */
listDirectory: (path?: string, signal?: AbortSignal) => Promise<DirectoryListing>
/**
* Create one child directory under an existing parent; the returned path
* is verbatim the child's `entries[].path` in the parent's next listing
* (`IWorkspaces.createDirectory`'s contract) — the create landing anchors
* its selection and focus on that equality.
*/
/** Create one child directory under an existing parent. */
createDirectory: (path: string, name: string) => Promise<string>
/** The operator confirmed a directory (the selection, else the listed level). */
onOpen: (path: string) => void
@@ -64,126 +55,46 @@ function failureText(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/**
* Case-folds a path for comparisons under the given separator'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; only a
* 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(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 a typed absolute path for comparisons against the
* backend's resolved ones (every listing path arrives in the
* DirectoryListing contract's canonical shape; only the DRAFT side, the
* one path a user types, needs this): collapses repeated and trailing
* separators, drops `.` segments, and applies `..` without ever crossing
* the root — POSIX's `/`, a drive's `C:`, or UNC's `\\server\share` pair —
* mirroring resolve()'s lexical behavior. Expects separators already
* folded to `sep` (foldSeparatorsFor); a lexical mirror only, symlinks are
* the backend's business.
*/
function normalizePathFor(sep: '\\' | '/'): (value: string) => string {
return (value) => {
const unc = sep === '\\' && value.startsWith(`${sep}${sep}`)
const rawSegments = (unc ? value.slice(2) : value).split(sep)
// Empty segments are separator noise everywhere except POSIX's leading
// root marker, which must survive as the first segment; scrubbing them
// up front keeps a doubled separator from being locked into the UNC
// server + share root below.
const segments = unc ? rawSegments.filter(segment => segment !== '') : rawSegments
// The unpoppable root: POSIX's leading empty segment / the drive
// segment, or UNC's server + share pair.
const rootLength = unc ? 2 : 1
const out = segments.slice(0, rootLength)
for (const segment of segments.slice(rootLength)) {
if (segment === '' || segment === '.') continue
if (segment === '..') {
if (out.length > rootLength) out.pop()
continue
}
out.push(segment)
}
// A bare root keeps (or regains) the trailing separator resolve()
// emits for `/`, `C:\`, and `\\server\share\`.
return `${unc ? sep + sep : ''}${out.join(sep)}${out.length === rootLength ? sep : ''}`
}
}
/**
* 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. `home` and every crumb path arrive in the same resolved
* shape (the wire contract), so only the platform case fold remains — a
* typed-case Windows chain still collapses to the Home crumb.
* by its own path.
*/
function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryEntry[] {
const fold = foldPathFor(separatorOf(listing))
const home = fold(listing.home)
const homeIndex = listing.crumbs.findIndex(crumb => fold(crumb.path) === home)
const homeIndex = listing.crumbs.findIndex(crumb => crumb.path === listing.home)
if (homeIndex === -1) return listing.crumbs
const tail = listing.crumbs.slice(homeIndex + 1)
return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail]
}
/**
* The listing's platform separator, read from the host-resolved root crumb
* (`/`, `C:\`, `\\server\share\`) — exact for every root form the backend
* emits, and immune to backslashes inside POSIX names (which the home text
* may legally carry; the wire contract already excludes non-canonical
* shapes elsewhere).
* The listing's platform separator, inferred from the home path the host
* stamped — never from typed text or entry paths, where a backslash is a
* legal POSIX name character. Still a heuristic at the last step: a POSIX
* home directory whose own name contains a backslash would misread.
* TODO: replace with a host-stamped `separator` field on the wire
* DirectoryListing so the platform fact travels verbatim (the trade-off is
* recorded in the directory-picker capability seam Agent Note).
*/
function separatorOf(listing: DirectoryListing): '\\' | '/' {
const rootCrumb = listing.crumbs.at(0)
// The seam type allows an empty chain (this backend never emits one, but
// create-target naming supports it, see targetName): degrade to a
// best-effort read of the home text — the pre-root-crumb heuristic, with
// its backslash-in-a-POSIX-name blind spot.
if (rootCrumb === undefined) return listing.home.includes('\\') ? '\\' : '/'
return rootCrumb.path.includes('\\') ? '\\' : '/'
return listing.home.includes('\\') ? '\\' : '/'
}
/**
* 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. Only the directory part is lexically
* normalized (dot segments, repeated separators, and win32 forward slashes
* all match what Enter would navigate to) and platform-case-folded (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 FINAL segment stays a literal name prefix — a lone `.` reads
* as the dot-reveal, `..` matches no entry (Enter still navigates it) —
* and the name filter downstream is case-insensitive everywhere.
* 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.
*/
function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null {
if (draft === null) return null
const sep = separatorOf(listing)
const folded = foldSeparatorsFor(sep)(draft)
const cut = folded.lastIndexOf(sep)
const cut = draft.lastIndexOf(sep)
if (cut === -1) return null
const fold = foldPathFor(sep)
const normalize = normalizePathFor(sep)
return fold(normalize(folded.slice(0, cut + 1))) === fold(listing.path)
? folded.slice(cut + 1)
: null
const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}`
return draft.slice(0, cut + 1) === level ? draft.slice(cut + 1) : null
}
/** One column of folder rows (the Miller view renders one or two of these). */
@@ -224,10 +135,10 @@ function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPr
// where the blur lands before our guards) drop this click.
// Outside editing, rows keep native focus behavior.
onMouseDown={pathEditing ? (event) => { event.preventDefault() } : undefined}
// Focus parking happens after commit (the DirectoryBrowser
// refocus effect): a right-pane pick replaces this very
// column, so focusing the clicked node here would still fall
// to body.
// Editing-time focus parking happens after commit (the
// DirectoryBrowser refocus effect): a right-pane pick replaces
// this very column, so focusing the clicked node here would
// still fall to body.
onClick={() => { onPick(entry) }}
>
{selected
@@ -258,7 +169,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
const [error, setError] = useState<string | null>(null)
// Path-edit state: null = breadcrumb mode; a string = the draft being typed.
const [pathDraft, setPathDraft] = useState<string | null>(null)
// Show-hidden toggle state (pure client-side filter, reset on close).
// Show-hidden toggle state (pure client-side filter, reset on each open).
const [showHidden, setShowHidden] = useState(false)
// Create-folder state: null = closed; a string = the nested dialog's draft.
const [folderDraft, setFolderDraft] = useState<string | null>(null)
@@ -267,13 +178,8 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
const requestSeq = useRef(0)
// The in-flight listing's controller: superseding intent aborts the wire
// request too — the Host stops scanning — instead of only discarding the
// eventual result while the scan keeps consuming host resources. Always
// holds a controller so no consumer needs a null guard: initially a
// placeholder that the first supersede aborts unused (minted lazily —
// useRef evaluates its argument every render), afterwards the latest
// scan's, settled or aborted between scans.
const [initialScanController] = useState(() => new AbortController())
const scanController = useRef<AbortController>(initialScanController)
// eventual result while the scan keeps consuming host resources.
const scanController = useRef<AbortController | null>(null)
// Bumped on every open/close edge: settlements from a previous open (a
// pending creation included) must never mutate a reopened dialog.
const openGeneration = useRef(0)
@@ -289,7 +195,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
useEffect(() => () => {
requestSeq.current += 1
openGeneration.current += 1
scanController.current.abort()
scanController.current?.abort()
}, [])
const compositionGuard = {
onCompositionStart: () => { composingRef.current = true },
@@ -298,7 +204,8 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
/** Newer intent wins: invalidate the pending listing's settlement AND abort its wire request. */
const supersede = useCallback((): number => {
scanController.current.abort()
scanController.current?.abort()
scanController.current = null
return ++requestSeq.current
}, [])
@@ -310,54 +217,11 @@ 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; every other displacing exit (Enter, Escape, a landing
// 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. 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, 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 close edge's
* supersede() fences every later settlement, and the same close effect
* zeroes parent/selected/child for the one frame that can slip between
* the close render and its effect.
* @returns true when `document.activeElement` is inside the miller row.
*/
const focusInMillerRows = useCallback((): boolean => {
const rowHost = millerRowRef.current
// 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)
}, [])
/**
* 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> => {
// 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.
scanController.current.abort()
const controller = new AbortController()
scanController.current = controller
return listDirectory(path, controller.signal)
@@ -372,13 +236,9 @@ 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; 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.
* 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)
@@ -386,13 +246,6 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
setError(null)
scan.then((target) => {
if (seq !== requestSeq.current) return
// The landing replaces every row key; a slow jump leaves the OLD
// rows tabbable meanwhile (parentInert excludes loading), so focus
// may live among them. With no selection yet the edit zone is the
// park target (body-guarded, like every other exit). The probe never
// gates the commit below — stranding the dialog in loading over a
// focus check would be far worse than a skipped parking.
if (focusInMillerRows()) refocusEditZone.current = true
setParent(target)
setSelected(null)
setChild(null)
@@ -406,15 +259,11 @@ 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 (slash
// platforms compare exactly — see foldPathFor).
const fold = foldPathFor(separatorOf(parentLevel))
// 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
// 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.
if (focusInMillerRows()) refocusPick.current = true
setParent(parentLevel)
setSelected(match)
setChild(target)
@@ -428,33 +277,25 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
setLoading(false)
setError(failureText(reason))
})
}, [launchListing, continueScan, focusInMillerRows])
}, [launchListing, continueScan])
/**
* Close the nested create dialog. Its unmount drops focus to body (the
* Modal has no focus trap), so every exit — Escape, mask, Cancel, and a
* successful create — arms the body-guarded edit-zone parking. A
* successful create therefore parks in TWO stages: the edit zone on this
* close, then the relist's select() re-parks on the created row one RTT
* later — deliberately re-parking even focus the user moved during the
* relist window, and doubling as the parking a failed relist inherits.
*/
const closeCreateDialog = useCallback(() => {
setFolderDraft(null)
refocusEditZone.current = true
}, [])
// 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.
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)
// A pick while the path editor is open adopts the (filtered) row and
// closes the editor — the draft served its purpose. EVERY pick re-parks
// focus on the selection after commit (see the refocus effect below):
// 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
// closes the editor — the draft served its purpose. Focus re-parks on
// the selection after commit (see the refocus effect below).
if (pathDraft !== null) refocusPick.current = true
setPathDraft(null)
setSelected(entry)
setChild(null)
@@ -476,7 +317,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
// re-parks on the edit zone only if focus actually fell to body.
refocusEditZone.current = true
})
}, [launchListing])
}, [launchListing, pathDraft])
/** Abandon path editing (Escape or clicking away) and restore the crumb view. */
const cancelPathEdit = useCallback(() => {
@@ -506,26 +347,19 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
}, [child, select])
// Every open starts fresh at the Host home directory; closing invalidates
// any in-flight response so a late arrival cannot repopulate a closed
// dialog. The per-open state resets live on the CLOSE edge: resetting on
// open would let the reopen's first commit paint one frame of the stale
// view (revealed hidden rows, a pressed toggle) before this passive
// effect runs. No automated gate observes that ordering (act() hides the
// frame in tests) — this comment is the guard; read it before moving
// these back.
// any in-flight response so a late arrival cannot repopulate a closed dialog.
useEffect(() => {
openGeneration.current += 1
if (open) {
setParent(null)
setSelected(null)
setChild(null)
setCreatingFolder(false)
setShowHidden(false)
navigate()
return
}
supersede()
setParent(null)
setSelected(null)
setChild(null)
setCreatingFolder(false)
setShowHidden(false)
setLoading(false)
setError(null)
setPathDraft(null)
setFolderDraft(null)
@@ -557,20 +391,19 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
// the fresh dialog or issue a relist against the stale target.
if (generation !== openGeneration.current) return
setCreatingFolder(false)
closeCreateDialog()
setFolderDraft(null)
// Land like a right-column pick (figma 802:57446 → 813:23278 flow): the
// create target becomes the listed level and the new folder its selection.
const { seq, scan } = launchListing(targetPath)
setLoading(true)
scan.then((level) => {
// The nested dialog closed before this relist launched, so the card
// is interactive meanwhile: a pick or crumb jump supersedes it.
/* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */
if (seq !== requestSeq.current) return
setParent(level)
setLoading(false)
select({ name, path: createdPath, hidden: false })
}, (reason: unknown) => {
// Same interactive-window fence as the success branch above.
/* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */
if (seq !== requestSeq.current) return
setLoading(false)
setError(failureText(reason))
@@ -592,29 +425,19 @@ 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. The
// refocus effect's row.focus() and this pin can fight on such viewports,
// and whichever commit runs later wins by design: on a parent-leg
// upgrade (one commit) focus placement runs after the pin and keeps the
// selected LEFT row in view; on a plain advance or create landing the
// child arrives in a later commit, so the pin runs after the focus and
// descent reachability wins.
// pins — otherwise descent is unreachable on a phone-width window.
const millerRowRef = useRef<HTMLDivElement | null>(null)
const childPath = child?.path
useEffect(() => {
const row = millerRowRef.current
if (row !== null && childPath !== undefined) row.scrollLeft = row.scrollWidth
}, [childPath])
// Every pick and editor exit that would drop focus to body re-parks it
// after commit, so THIS DIALOG'S OWN node replacements never leak focus
// out of the card: a pick lands on the selection's row — aria-current in
// the freshly rendered left pane, which survives even a right-pane
// advance or a create landing replacing the picked button's column —
// while the edit-zone exits enumerated at the flag declarations fall
// back to the crumb edit zone. Outside the guarantee: the Modal has no
// focus trap, so tabbing past the card's edge legitimately leaves, and
// the owner's adopt window (busy inerts every control; browsers blur
// disabled elements to body) gets no parking — the owner closes the
// dialog either way.
// Every editor exit that would drop focus to body re-parks it after
// commit, so keyboard traversal stays inside the dialog (the Modal has no
// focus trap): a pick lands on the selection's row — aria-current in the
// freshly rendered left pane, which survives even a right-pane advance
// replacing the picked button's column — while Enter and an input-focused
// Escape land on the crumb edit zone that replaces the input.
useEffect(() => {
if (pathDraft !== null) return
if (refocusPick.current) {
@@ -624,14 +447,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
/* v8 ignore next -- narrowing guard: the miller row is mounted whenever a pick just committed. */
if (rowHost === null) return
const row = rowHost.querySelector<HTMLButtonElement>('button[aria-current="true"]')
if (row !== null) {
row.focus()
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
/* v8 ignore next -- narrowing guard: the pick that set the flag just rendered its aria-current row. */
if (row === null) return
row.focus()
return
}
if (refocusEditZone.current) {
refocusEditZone.current = false
@@ -639,9 +458,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
// the user parked elsewhere (a surviving row) stays theirs.
if (document.activeElement !== document.body) return
const zone = editZoneRef.current
// The effect already returned while a draft is open, and the close
// reset cleared both flags — so crumb mode's zone is always mounted.
/* v8 ignore next -- narrowing guard: crumb mode always renders the edit zone. */
/* v8 ignore next -- narrowing guard: crumb mode renders the edit zone whenever the editor just closed. */
if (zone === null) return
zone.focus()
}
@@ -685,13 +502,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
// document listener — the same containment the input previously
// provided for itself.
event.stopPropagation()
// The cancel may unmount whatever holds focus — the input, or a
// dot-revealed row the cleared draft re-hides. Arm the parking
// unconditionally: the refocus effect's body guard already
// distinguishes a surviving focused row (left alone) from focus
// that actually fell. Assignment (not a conditional set) also
// 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. Assignment (not a conditional set) also
// retires a stale flag a failed or still-upgrading Enter left.
refocusEditZone.current = true
refocusEditZone.current = document.activeElement === pathInputRef.current
cancelPathEdit()
}}
// Focus leaving THIS dialog card while editing cancels like Escape.
@@ -775,6 +590,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
value={pathDraft}
aria-label={t('browser.editPath')}
autoFocus
ref={pathInputRef}
disabled={parentInert}
onChange={(event) => {
// Editing the draft supersedes any in-flight navigation:
@@ -863,19 +679,12 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
// toggling never blur-cancels a draft mid-thought. Outside editing
// it keeps native focus behavior.
onMouseDown={draftPending ? (event) => { event.preventDefault() } : undefined}
onClick={(event) => {
// The suppression above exists to protect the INPUT's focus;
// 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)
}}
onClick={() => { setShowHidden(prev => !prev) }}
>
{t('browser.showHidden')}
{showHidden && <IconCheckOutline16 size={14} className={css.toggleCheck} />}
{/* Trailing check (Menu's selected vocabulary): the label never
* shifts when the pressed state toggles. */}
{showHidden && <IconCheckOutline16 size={14} />}
</button>
<span className={css.footerGap} />
<Button variant="outline" className={clsx(css.footerAction)} disabled={parentInert} onClick={onClose}>{t('browser.cancel')}</Button>
@@ -893,7 +702,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
{/* Nested create dialog (figma 813:23278): names one folder inside the target. */}
<Modal
open={folderDraft !== null}
onClose={() => { if (!creatingFolder) closeCreateDialog() }}
onClose={() => { if (!creatingFolder) setFolderDraft(null) }}
title={t('browser.newFolder')}
className={clsx(css.createDialog)}
headless
@@ -917,13 +726,13 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
}
if (event.key === 'Escape') {
event.stopPropagation()
if (!creatingFolder) closeCreateDialog()
if (!creatingFolder) setFolderDraft(null)
}
}}
/>
{createError !== null && <div className={css.error} role="alert">{createError}</div>}
<div className={css.createActions}>
<Button variant="outline" disabled={creatingFolder} onClick={() => { closeCreateDialog() }}>{t('browser.cancel')}</Button>
<Button variant="outline" disabled={creatingFolder} onClick={() => { setFolderDraft(null) }}>{t('browser.cancel')}</Button>
<Button
variant="primary"
disabled={creatingFolder || folderDraft === null || folderDraft.trim() === ''}

View File

@@ -15,7 +15,7 @@ import { DirectoryBrowser } from './DirectoryBrowser.tsx'
export interface BrowseFlowInjected {
/** List one directory level (absent path = the Host home directory); the signal aborts a superseded scan. */
listDirectory: (path?: string, signal?: AbortSignal) => Promise<DirectoryListing>
/** Create one child directory under an existing parent; returns the created path in the shape `IWorkspaces.createDirectory` contracts. */
/** Create one child directory under an existing parent. */
createDirectory: (path: string, name: string) => Promise<string>
/** Localized dialog copy (this package's namespace). */
t: Translate

View File

@@ -215,22 +215,14 @@ export default class BrowseDirectoryPicker extends DirectoryPicker {
}
private async list(path?: string, signal?: AbortSignal): Promise<DirectoryListing> {
// Resolved like every other path in the listing: the environment may
// decorate HOME (trailing or repeated separators, dot segments, win32
// forward slashes) and homedir() ships it verbatim, while the wire
// contract promises one canonical shape for every listing path. A
// relative or drive-less HOME rebases under the process cwd / current
// drive here — the behavior the fullyQualified fence refuses for wire
// values — accepted for the host's own environment, since the listed
// target derives from home and stays consistent with it.
const home = resolve(homedir())
const home = homedir()
// The seam contract takes fully qualified paths only; resolve() would
// silently rebase a relative or empty wire value under the host process
// cwd (or, for rooted drive-less Windows forms, its current drive).
if (path !== undefined && !fullyQualified(path)) {
throw new DirectoryPickerError('directory-unreadable', path, `cannot list "${path}": not a fully qualified path`)
}
const target = path === undefined ? home : resolve(path)
const target = resolve(path ?? home)
// Stream the level (opendir, one dirent at a time) into a name-sorted
// window of maxEntries + 1 candidates: memory stays bounded no matter how
// many children the directory holds, the window keeps the name-sorted