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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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() === ''}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user