@@ -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 land s w ith: 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 l and selection-anchored: a crumb jump or a
* submitted path commits the target immediately, then re-select s 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, rea d from the host-resolved root crumb
* (`/`, `C:\`, `\\server\share\`) — exac t f or eve ry 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, infer red from the home path the host
* stamped — never from typed tex t or ent ry 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 t he 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. T he 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 norma liz e = 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 . s lic e( 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 }
// F ocus 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 f ocus 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 cas e
* drift misses the match and keeps the single-pane landing — paren t
* 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 th e
* target, leaves the committed single-pane landing — the upgrade mus t
* 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
// pl atf orms compare exactly — see foldPathFor).
const fold = foldPathFor ( separatorOf ( parentLevel ) )
// parent level's actual entry so selection comparisons hold.
const sep = separ atorOf ( 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 ex it — 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 ed it 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 = { ( ev ent ) = > {
// 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 = { ( ) = > { setShowHidd en( 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 ( ) === '' }