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

@@ -15,19 +15,11 @@ export interface DirectoryEntry {
hidden: boolean
}
/**
* host.listDirectory response value: one directory level plus its ancestry.
* Every path in one listing — `path`, `crumbs[].path`, `entries[].path`,
* and `home` — is host-resolved canonical form: no `.`/`..` segments, no
* repeated or trailing separators (bare roots `/`, `C:\`, `\\server\share\`
* excepted), one platform separator. Resolution is lexical (`resolve()`),
* never realpath: a symlinked ancestry keeps the logical path the operator
* navigated. Clients compare paths on this promise without re-normalizing.
*/
/** host.listDirectory response value: one directory level plus its ancestry. */
export interface DirectoryListing {
/** Absolute path of the listed directory. */
path: string
/** The host account's home directory (breadcrumb "Home" rooting), in the interface's canonical shape like every other path here. */
/** The host account's home directory (breadcrumb "Home" rooting). */
home: string
/**
* Ancestor chain from the filesystem root to the listed directory
@@ -83,9 +75,7 @@ export interface HostApi {
* Create one child directory under an existing parent (the browser's
* "New folder"). Only served under the `browse` capability; an existing
* child fails with `directory-exists`, every other filesystem failure with
* `directory-create-failed`. The returned path is in the listing
* contract's canonical shape — verbatim equal to the child's
* `entries[].path` in the parent's next listing.
* `directory-create-failed`.
*/
createDirectory(
request: RpcRequest<{ path: string; name: string }>,

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/directory-picker-browse/README.md
README.md: d6ed7181ffbec85d11e0abf2aa8d0053173ba4e9
README.zh.md: 67f5f2bc40297d96bc3fcd3cfba0a3fd26855adc
README.md: 23153881b84dcb71dfb05d4f297a5818c410ca77
README.zh.md: d7010e2941a801ba6358082824330eaae46e42b7

View File

@@ -19,6 +19,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Windows hidden attribute is not read** — Node dirents do not expose `FILE_ATTRIBUTE_HIDDEN`, so `hidden` means dot-prefixed on every platform until a native probe is worth its cost.
- **Name-normalizing volumes void the create-path equality** — `createDirectory` promises its return verbatim-equal to the child's next `entries[].path`; Node's namespaced Win32 paths store even trailing-dot/space segments literally, but a volume that rewrites names on storage (NFD normalization on HFS+-style volumes) breaks the match, and the create landing degrades to a two-pane view whose left pane lacks the aria-current row while focus falls back to the crumb edit zone.
- **No drive-root enumeration** — on Windows the ancestry stops at the drive root; crossing drives waits for the browser UI's path-entry affordance rather than an enumeration primitive here.
- **Whole-filesystem scope** — no per-deployment browse-root restriction; `workspace.create` accepts arbitrary paths today, so a root here would be UX scoping, not a boundary — deferred until a deployment needs it.

View File

@@ -19,6 +19,5 @@
## 已知限制与延期工作
- **不读取 Windows 隐藏属性**——Node 的 dirent 不暴露 `FILE_ATTRIBUTE_HIDDEN`,因此在所有平台上 `hidden` 都意味着点前缀,直到原生探测值回其成本为止。
- **名称规范化的卷会使创建路径等式失效**——`createDirectory` 承诺其返回值与该子项下一次的 `entries[].path` 逐字相等Node 带命名空间的 Win32 路径连末尾点空格段都按字面存储但在存储时改写名称的卷HFS+ 风格卷上的 NFD 规范化)会破坏这一匹配,创建落地随之退化为左栏缺少 aria-current 行的双栏视图,同时焦点回落至 crumb 编辑区。
- **不枚举盘符根**——Windows 上祖先链止于盘符根;跨盘依赖浏览器 UI 的路径输入入口,而不是这里的枚举原语。
- **全盘可浏览**——没有按部署限定的浏览根;`workspace.create` 今天就接受任意路径,这里的根只会是 UX 范围而非边界——等到有部署需要时再做。

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

View File

@@ -100,22 +100,6 @@ function mount(overrides: Partial<Parameters<typeof DirectoryBrowser>[0]> = {})
return { view, props, listDirectory, createDirectory, onOpen, onClose }
}
/**
* A listDirectory fake whose explicit HOME listings hang for manual
* settlement — the initial open lists home through the absent-path form,
* so only relists and parent legs are held.
*/
function hangingHomeLister() {
const settlers: { resolve: (value: DirectoryListing) => void; reject: (reason: unknown) => void }[] = []
const listDirectory = vi.fn(async (path?: string) => {
if (path === HOME) {
return new Promise<DirectoryListing>((resolve, reject) => { settlers.push({ resolve, reject }) })
}
return listingFor(path)
})
return { listDirectory, settlers }
}
/** The rendered level columns, left-to-right. */
function columns(): HTMLElement[] {
return screen.getAllByRole('list')
@@ -240,94 +224,6 @@ describe('DirectoryBrowser', () => {
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
})
it('a landing whose new level dropped the focused row parks on the edit zone', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(rowButton(screen.getByRole('listitem')))
await waitFor(() => { expect(columns()).toHaveLength(2) })
// Keyboard is on the right-pane harness row; the home landing has no
// such key, so the focused node unmounts and focus re-parks.
rowButton(within(columns()[1]!).getByRole('listitem')).focus()
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
await waitFor(() => { expect(columns()).toHaveLength(1) })
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' }))
})
it('closing the create dialog parks focus on the edit zone', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
const nameInput = screen.getByLabelText('browser.folderName')
fireEvent.keyDown(nameInput, { key: 'Escape' })
// The nested dialog unmounted with focus inside it: re-park.
expect(screen.queryByLabelText('browser.folderName')).toBeNull()
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' }))
})
it('a pick during the post-create relist supersedes it: late settlements drop', async () => {
const { listDirectory, settlers } = hangingHomeLister()
mount({ listDirectory })
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
fireEvent.change(screen.getByLabelText('browser.folderName'), { target: { value: 'ghost' } })
fireEvent.click(screen.getByRole('button', { name: 'browser.create' }))
// The nested dialog is gone while the relist hangs; the card is
// interactive, and picking a row supersedes the relist.
await waitFor(() => { expect(settlers).toHaveLength(1) })
fireEvent.click(rowButton(screen.getByRole('listitem')))
await waitFor(() => { expect(columns()).toHaveLength(2) })
// The stale relist settles late and must not land or select ghost.
await act(async () => { settlers[0]!.resolve({ ...listingFor(HOME), entries: [] }) })
expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true')
})
it('a rejection of a superseded post-create relist is equally silent', async () => {
const { listDirectory, settlers } = hangingHomeLister()
mount({ listDirectory })
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
fireEvent.change(screen.getByLabelText('browser.folderName'), { target: { value: 'ghost' } })
fireEvent.click(screen.getByRole('button', { name: 'browser.create' }))
await waitFor(() => { expect(settlers).toHaveLength(1) })
fireEvent.click(rowButton(screen.getByRole('listitem')))
await waitFor(() => { expect(columns()).toHaveLength(2) })
await act(async () => { settlers[0]!.reject(new Error('late')) })
expect(screen.queryByRole('alert')).toBeNull()
expect(columns()).toHaveLength(2)
})
it('a failed create relist parks focus on the edit zone with the error shown', async () => {
let relists = 0
const listDirectory = vi.fn(async (path?: string) => {
if (path === HOME && ++relists > 0) {
throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'gone', details: { path } })
}
return listingFor(path)
})
mount({ listDirectory })
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
fireEvent.change(screen.getByLabelText('browser.folderName'), { target: { value: 'ghost' } })
fireEvent.click(screen.getByRole('button', { name: 'browser.create' }))
await screen.findByRole('alert')
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' }))
})
it('Escape over a dot-revealed focused row re-parks on the edit zone as the row re-hides', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: `${HOME}/.co` } })
const row = rowButton(screen.getByRole('listitem'))
expect(row.textContent).toBe('.config')
row.focus()
fireEvent.keyDown(row, { key: 'Escape' })
// The cleared draft re-hides the revealed row under the focused cursor;
// the body guard sees the fall and parks on the edit zone.
expect(screen.queryByText('.config')).toBeNull()
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' }))
})
it('a navigation to the filesystem root keeps the single wide level', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
@@ -374,81 +270,6 @@ describe('DirectoryBrowser', () => {
expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull()
})
it('re-parks focus on the re-selected row when the upgrade displaces focused rows', async () => {
const { listDirectory, settlers } = hangingHomeLister()
mount({ listDirectory })
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: DOCS } })
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
// The committed landing is interactive; Tab reaches its rows while the
// parent leg is still in flight.
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
rowButton(screen.getByRole('listitem')).focus()
await waitFor(() => { expect(settlers).toHaveLength(1) })
// The upgrade replaces every committed row node; focus re-parks on the
// re-selected row instead of falling to body.
await act(async () => { settlers[0]!.resolve(listingFor(HOME)) })
await waitFor(() => { expect(columns()).toHaveLength(2) })
expect(document.activeElement?.textContent).toBe('Documents')
expect(document.activeElement?.getAttribute('aria-current')).toBe('true')
})
it('a UNC level is home-collapsed and filters decorated UNC drafts without popping the share root', async () => {
const SHARE = '\\\\server\\share'
const listing: DirectoryListing = {
path: `${SHARE}\\x`,
home: `${SHARE}\\x`,
crumbs: [
{ name: `${SHARE}\\`, path: `${SHARE}\\`, hidden: false },
{ name: 'x', path: `${SHARE}\\x`, hidden: false },
],
entries: [{ name: 'Alpha', path: `${SHARE}\\x\\Alpha`, hidden: false }],
truncated: false,
}
const listDirectory = vi.fn(async () => listing)
mount({ listDirectory })
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
expect(columns()).toHaveLength(1)
expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy()
// A decorated UNC draft (doubled separator, share-root-crossing dot-dot)
// still normalizes to the listed level: the filter matches what Enter
// would navigate to, and \\server\share stays unpoppable.
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
fireEvent.change(input, { target: { value: '\\\\server\\\\share\\..\\x\\a' } })
expect(screen.getByText('Alpha')).toBeTruthy()
fireEvent.change(input, { target: { value: `${SHARE}\\x\\z` } })
expect(screen.queryByRole('listitem')).toBeNull()
})
it('collapses a typed-case Windows home to the display root (single pane, Home crumb)', async () => {
const CANON = 'C:\\Users\\Alice'
const TYPED = 'c:\\users\\alice'
const typedHome: DirectoryListing = {
path: TYPED,
home: CANON,
crumbs: [
{ name: 'C:\\', path: 'C:\\', hidden: false },
{ name: 'users', path: 'c:\\users', hidden: false },
{ name: 'alice', path: TYPED, hidden: false },
],
entries: [{ name: 'Desktop', path: `${CANON}\\Desktop`, hidden: false }],
truncated: false,
}
const canonHome: DirectoryListing = { ...typedHome, path: CANON, crumbs: typedHome.crumbs }
mount({ listDirectory: vi.fn(async (path?: string) => (path === TYPED ? typedHome : canonHome)) })
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: TYPED } })
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
// Case-folded home comparison: the typed-case home is still the display
// root — single pane, collapsed Home crumb, no parent leg.
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Desktop') })
expect(columns()).toHaveLength(1)
expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy()
})
it('keeps the single-pane landing when the truncated parent level lacks the target', async () => {
const listDirectory = vi.fn(async (path?: string) => {
// The parent leg names HOME explicitly; serve it a truncated window
@@ -482,7 +303,7 @@ describe('DirectoryBrowser', () => {
path: TYPED,
home: ROOT,
crumbs: [{ name: 'C:\\', path: ROOT, hidden: false }, { name: 'users', path: TYPED, hidden: false }],
entries: [{ name: 'Alpha', path: `${TYPED}\\Alpha`, hidden: false }],
entries: [],
truncated: false,
}
mount({ listDirectory: vi.fn(async (path?: string) => (path === TYPED ? winUsers : winRoot)) })
@@ -496,23 +317,6 @@ describe('DirectoryBrowser', () => {
expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true')
})
expect(within(columns()[0]!).getByText('Users')).toBeTruthy()
// The editor seeds from the actual-cased selection while the child
// level still carries the typed case: the draft's directory part folds
// per platform, so the right pane keeps prefix-filtering.
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
expect(input.value).toBe('C:\\Users\\')
fireEvent.change(input, { target: { value: 'C:\\Users\\a' } })
expect(within(columns()[1]!).getByText('Alpha')).toBeTruthy()
fireEvent.change(input, { target: { value: 'C:\\Users\\z' } })
expect(within(columns()[1]!).queryAllByRole('listitem')).toHaveLength(0)
// Forward-slash drafts are equally legal on win32 (Enter navigates
// them); the filter folds them instead of going silent.
fireEvent.change(input, { target: { value: 'C:/Users/a' } })
expect(within(columns()[1]!).getByText('Alpha')).toBeTruthy()
// Dot segments normalize on win32 too.
fireEvent.change(input, { target: { value: 'C:\\Users\\.\\a' } })
expect(within(columns()[1]!).getByText('Alpha')).toBeTruthy()
})
it('re-parks focus on the edit zone when a failed pick unmounts a dot-revealed row', async () => {
@@ -645,14 +449,6 @@ describe('DirectoryBrowser', () => {
// A draft naming some other directory (or none) leaves the level whole.
fireEvent.change(input, { target: { value: 'no-separator' } })
expect(screen.getByRole('listitem').textContent).toBe('Documents')
// Dot segments and repeated separators are legal for Enter, so the
// filter's directory comparison normalizes them the same way.
fireEvent.change(input, { target: { value: `${HOME}/./do` } })
expect(screen.getByRole('listitem').textContent).toBe('Documents')
fireEvent.change(input, { target: { value: `${HOME}//do` } })
expect(screen.getByRole('listitem').textContent).toBe('Documents')
fireEvent.change(input, { target: { value: `${HOME}/foo/../do` } })
expect(screen.getByRole('listitem').textContent).toBe('Documents')
})
it('filters the child pane in two-pane mode and follows the draft back up a level', async () => {
@@ -770,55 +566,6 @@ describe('DirectoryBrowser', () => {
expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy()
})
it('a plain right-pane advance parks focus on the new selection (no editor involved)', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(rowButton(screen.getByRole('listitem')))
await waitFor(() => { expect(columns()).toHaveLength(2) })
// Keyboard reached the right pane; the advance replaces that whole
// column, so focus re-parks on the new left pane's selected row.
const row = rowButton(within(columns()[1]!).getByRole('listitem'))
row.focus()
fireEvent.click(row)
await waitFor(() => { expect(document.activeElement?.textContent).toBe('harness') })
expect(document.activeElement?.getAttribute('aria-current')).toBe('true')
})
it('a create landing parks focus on the created row', async () => {
const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
b.listDirectory.mockImplementation(async (path?: string) => {
// The created directory is not in listingFor's fixed tree: serve its
// level before the fixture lookup can reject the unknown path.
if (path === `${HOME}/fresh`) return { ...listingFor(HOME), path: `${HOME}/fresh`, entries: [] }
const base = listingFor(path)
if (path === HOME) {
return { ...base, entries: [...base.entries, { name: 'fresh', path: `${HOME}/fresh`, hidden: false }] }
}
return base
})
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
fireEvent.change(screen.getByLabelText('browser.folderName'), { target: { value: 'fresh' } })
fireEvent.click(screen.getByRole('button', { name: 'browser.create' }))
await waitFor(() => { expect(document.activeElement?.textContent).toBe('fresh') })
expect(document.activeElement?.getAttribute('aria-current')).toBe('true')
})
it('a create landing whose truncated relist lost the created row parks on the edit zone', async () => {
const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
// The relist window misses the created directory (truncated tail).
b.listDirectory.mockImplementation(async (path?: string) => ({ ...listingFor(path), truncated: true }))
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
fireEvent.change(screen.getByLabelText('browser.folderName'), { target: { value: 'zzz-tail' } })
fireEvent.click(screen.getByRole('button', { name: 'browser.create' }))
// No aria-current row exists for the selection: focus falls back to the
// crumb edit zone instead of staying wherever it fell.
await waitFor(() => {
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' }))
})
})
it('a right-pane pick while editing parks focus on the advanced selection', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
@@ -1198,7 +945,6 @@ describe('DirectoryBrowser', () => {
expect(cancels.map(button => button.disabled).sort()).toEqual([false, true])
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'browser.open' }).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'browser.editPath' }).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'browser.showHidden' }).disabled).toBe(true)
for (const row of screen.getAllByRole('listitem')) {
expect(rowButton(row).disabled).toBe(true)
}
@@ -1408,59 +1154,6 @@ describe('DirectoryBrowser', () => {
expect(screen.getByText('browser.createIn:/srv/data')).toBeTruthy()
})
it('a crumb-less Windows level still seeds the editor with a backslash', async () => {
// The empty chain degrades separatorOf to the home-text read; the
// backslash side of that fallback is the Windows shape.
const bare: DirectoryListing = { path: 'C:\\srv', home: 'C:\\Users\\u', crumbs: [], entries: [], truncated: false }
mount({ listDirectory: vi.fn(async () => bare) })
await waitFor(() => {
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'browser.editPath' }).disabled).toBe(false)
})
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
expect(screen.getByLabelText<HTMLInputElement>('browser.editPath').value).toBe('C:\\srv\\')
})
it('a POSIX home whose name contains a backslash still reads as the display root', async () => {
const WEIRD = '/home/we\\ird'
const listing: DirectoryListing = {
path: WEIRD,
home: WEIRD,
crumbs: [
{ name: '/', path: '/', hidden: false },
{ name: 'home', path: '/home', hidden: false },
{ name: 'we\\ird', path: WEIRD, hidden: false },
],
entries: [{ name: 'notes', path: `${WEIRD}/notes`, hidden: false }],
truncated: false,
}
const listDirectory = vi.fn(async () => listing)
mount({ listDirectory })
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
// The root crumb ('/') decides the platform: the backslash in the name
// neither flips the fold nor breaks the Home collapse.
expect(columns()).toHaveLength(1)
expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy()
expect(listDirectory).toHaveBeenCalledTimes(1)
})
it('pointer-toggling hidden off keeps focus on the toggle as the focused row re-hides', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
const toggle = screen.getByRole('button', { name: 'browser.showHidden' })
fireEvent.click(toggle)
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
// Tab parked focus on the revealed hidden row; the pointer click below
// would unmount it (toggle off + empty seeded prefix hides it again).
const hiddenRow = within(columns()[0]!).getByText('.config').closest('button')!
hiddenRow.focus()
fireEvent.mouseDown(toggle)
fireEvent.click(toggle)
expect(screen.queryByText('.config')).toBeNull()
expect(document.activeElement).toBe(toggle)
// The editor survives the whole exchange.
expect(screen.getByLabelText('browser.editPath', { selector: 'input' })).toBeTruthy()
})
it('refuses to close the nested dialog while the creation is in flight', async () => {
const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })

View File

@@ -1,43 +0,0 @@
/**
* The wire contract's home shape: a decorated HOME (trailing/repeated
* separators, dot segments — homedir() ships it verbatim) still leaves the
* listing carrying the resolved form, matching `path` and `crumbs[].path`.
* The mock points homedir at a scratch tree so the probe never scans the
* running machine's real home (same hermetic reasoning as service.spec's
* temporary tree); the mock spreads the actual module, so tmpdir stays real.
*/
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterAll, beforeAll, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
let scratch: string
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>()
return { ...actual, homedir: () => `${scratch}/.//.` }
})
beforeAll(async () => {
scratch = await mkdtemp(join(tmpdir(), 'dsh-home-shape-'))
})
afterAll(async () => {
await rm(scratch, { recursive: true, force: true })
})
it('resolves a decorated homedir before stamping listing.home', async () => {
const { default: BrowseDirectoryPicker } = await import('../src/index.ts')
const ctx = new Context()
const fiber = ctx.plugin(BrowseDirectoryPicker)
await fiber.await()
const picked = ctx.get('directoryPicker')!.capability()
if (picked.kind !== 'browse') throw new Error('browse backend must advertise the browse capability')
const listing = await picked.list()
expect(listing.home).toBe(resolve(scratch))
expect(listing.path).toBe(listing.home)
expect(listing.crumbs.at(-1)!.path).toBe(listing.home)
await fiber.dispose()
})

View File

@@ -2,7 +2,7 @@
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { basename, join, resolve } from 'node:path'
import { basename, join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
@@ -48,9 +48,7 @@ describe('BrowseDirectoryPicker', () => {
it('lists directories only, flags hidden rows, follows symlinks, skips broken links, sorts by name', async () => {
const listing = await capability.list(root)
expect(listing.path).toBe(root)
// The environment may decorate HOME; every listing path ships in the
// DirectoryListing contract's canonical shape, home included.
expect(listing.home).toBe(resolve(homedir()))
expect(listing.home).toBe(homedir())
expect(listing.entries.map(entry => entry.name)).toEqual(['.hidden-dir', 'linked', 'projects'])
expect(listing.entries.map(entry => entry.hidden)).toEqual([true, false, false])
// Every entry path is absolute and host-joined — clients never join segments.
@@ -163,6 +161,11 @@ describe('BrowseDirectoryPicker', () => {
expect(listing.crumbs[0]!.name).toBe(listing.crumbs[0]!.path)
})
it('lists the home directory when no path is given', async () => {
const listing = await capability.list()
expect(listing.path).toBe(homedir())
})
it('throws directory-unreadable for a missing target', async () => {
const missing = join(root, 'no-such-dir')
const failure = await capability.list(missing).catch((error: unknown) => error)
@@ -203,18 +206,10 @@ describe('BrowseDirectoryPicker', () => {
})
it('creates one child directory and surfaces it in the next listing', async () => {
// The composed-form name (U+00E9) doubles as the name-rewriting
// tripwire: a volume that stores names NFD-decomposed hands back a
// different dirent.name and the equality below goes red — the README's
// documented boundary.
const created = await capability.createDirectory(root, 'café')
expect(created).toBe(join(root, 'café'))
const created = await capability.createDirectory(root, 'fresh')
expect(created).toBe(join(root, 'fresh'))
const listing = await capability.list(root)
expect(listing.entries.map(entry => entry.name)).toContain('café')
// The contract's cross-method equality: the returned path is verbatim
// the child's entries[].path (clients anchor the create landing's
// selection and focus on it).
expect(listing.entries.find(entry => entry.name === 'café')!.path).toBe(created)
expect(listing.entries.map(entry => entry.name)).toContain('fresh')
})
it('refuses an existing child with directory-exists', async () => {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/directory-picker/README.md
README.md: 4445af3071d268c5919078278b26f369d9f3ba07
README.zh.md: 16a90eb698b48438d04154d47afda937c71a4985
README.md: 8ef8889c875f5b1d07c015ddef819591041c8d7f
README.zh.md: 8aefffa7b29a47205ea42d0d1df742d1e1b2502d

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'native', pick(signal) }` opens one native OS chooser on the host display ([`-native`](../directory-picker-native/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS chooser can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. The client side mirrors the seam without a wire advertisement: each backend package is dual-face, its browser half registering the matching picking interaction into ui-workspace's directory-flow slots — so one composition row swaps both the host capability and the client flow together.
Browse primitives fail with the typed `DirectoryPickerError` (`directory-unreadable` / `directory-exists` / `directory-create-failed`, each carrying the subject `path`), which the consuming gateway maps 1:1 onto wire error codes. `DirectoryEntry` rows carry a host-owned `hidden` flag (POSIX dot convention) so display policy stays client-side; `DirectoryListing.crumbs` is the ancestor chain from the filesystem root, every crumb a jump target. Every path in one listing — and `createDirectory`'s returned path — ships in host-resolved canonical shape (lexical `resolve()`, never realpath): clients compare listing paths verbatim, so every backend must resolve before stamping. Design rationale, the `ctx.fs` separation, and the policy decisions live in [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md).
Browse primitives fail with the typed `DirectoryPickerError` (`directory-unreadable` / `directory-exists` / `directory-create-failed`, each carrying the subject `path`), which the consuming gateway maps 1:1 onto wire error codes. `DirectoryEntry` rows carry a host-owned `hidden` flag (POSIX dot convention) so display policy stays client-side; `DirectoryListing.crumbs` is the ancestor chain from the filesystem root, every crumb a jump target. Design rationale, the `ctx.fs` separation, and the policy decisions live in [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md).
## Model Experience

View File

@@ -4,7 +4,7 @@
web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker``ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md)`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端在其中声明合并自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。client 侧以镜像方式承接该 seam 而不经 wire 广播:每个后端包都是双面包,其 browser half 把匹配的选取交互注册进 ui-workspace 的目录流 slot——因此一行组合同时切换宿主能力与 client 流程。
浏览原语以带类型的 `DirectoryPickerError` 失败(`directory-unreadable``directory-exists``directory-create-failed`,各自携带主体 `path`),消费网关将其 1:1 映射为协议错误码。`DirectoryEntry` 行携带宿主判定的 `hidden` 标志POSIX 点前缀约定),展示策略留在客户端;`DirectoryListing.crumbs` 是从文件系统根开始的祖先链,每个 crumb 都是跳转目标。单次列举中的每个路径——连同 `createDirectory` 返回的路径——都以宿主解析的规范形态交付(词法 `resolve()`,从不 realpath客户端逐字比较列举路径因此每个后端都必须先解析再标注。设计依据、与 `ctx.fs` 的切分、策略裁决见[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。
浏览原语以带类型的 `DirectoryPickerError` 失败(`directory-unreadable``directory-exists``directory-create-failed`,各自携带主体 `path`),消费网关将其 1:1 映射为协议错误码。`DirectoryEntry` 行携带宿主判定的 `hidden` 标志POSIX 点前缀约定),展示策略留在客户端;`DirectoryListing.crumbs` 是从文件系统根开始的祖先链,每个 crumb 都是跳转目标。设计依据、与 `ctx.fs` 的切分、策略裁决见[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。
## 模型体验

View File

@@ -34,21 +34,11 @@ export interface DirectoryEntry {
hidden: boolean
}
/**
* One directory level plus its ancestry, as a browse backend reports it.
* Every path in one listing — `path`, `crumbs[].path`, `entries[].path`,
* and `home` — is host-resolved canonical form: no `.`/`..` segments, no
* repeated or trailing separators (bare roots `/`, `C:\`, `\\server\share\`
* excepted), one platform separator. Resolution is lexical (`resolve()`),
* never realpath: a symlinked ancestry keeps the logical path the operator
* navigated (the seam Agent Note's symlink ruling). Clients compare paths
* on this promise without re-normalizing; every backend must resolve
* before stamping.
*/
/** One directory level plus its ancestry, as a browse backend reports it. */
export interface DirectoryListing {
/** Absolute path of the listed directory. */
path: string
/** The host account's home directory (breadcrumb "Home" rooting), in the interface's canonical shape like every other path here. */
/** The host account's home directory (breadcrumb "Home" rooting). */
home: string
/**
* Ancestor chain from the filesystem root to the listed directory
@@ -89,10 +79,7 @@ export interface DirectoryPickerBrowseCapability {
* Create one child directory under an existing parent.
* @param path - absolute existing parent directory.
* @param name - single non-blank path segment (no separators, not `.`/`..`).
* @returns the created directory's absolute path, in the listing
* contract's canonical shape — verbatim equal to the child's
* `entries[].path` in the parent's next listing (clients anchor the
* create landing's selection and focus on that equality).
* @returns the created directory's absolute path.
* @throws {DirectoryPickerError} `directory-exists` for an existing child,
* `directory-create-failed` for a parent that is not fully qualified or any other failure.
*/