diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index f5b5312f73..0502f5cd0d 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: a22b313649a9efb1784f88a30fff3f82b9828347 -2026-07-28-directory-picker-capability-seam.zh.md: fb800fe75bda07198f9b3dc67ac03fb385df919a +2026-07-28-directory-picker-capability-seam.md: 6cba49f9a5817d05e4933e397918a0b9a17ce845 +2026-07-28-directory-picker-capability-seam.zh.md: bfa9f80db32b1e73a198442826c7aea9972c7412 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index a22b313649..6cba49f9a5 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -19,7 +19,7 @@ Placement and policy rulings folded into this decision: - **Not the `ctx.fs` seam.** `packages/fs/` is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swapping `fs-sandbox` for the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free; `packages/host/` is its consumer-domain home. - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. -- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are read from the host-resolved root crumb (exact for every root form this backend emits: `/`, `C:\`, `\\server\share\`); the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. Focus parking is a card-wide invariant, not an editor-only one: every pick — editing or not, including right-pane advances and create landings whose columns are replaced — re-parks focus on the selection's row after commit, while every other displacing exit (Enter, Escape, a navigation landing whose new level dropped the focused row, a failed pick or create relist, and the nested create dialog closing) falls back to the crumb edit zone whenever focus actually fell to body, and a show-hidden toggle click that finds focus among the rows parks synchronously on the toggle itself; keyboard traversal never falls out of the card (the Modal has no focus trap), except during the owner's adopt window, where `busy` inerts every control and the dialog is closing either way. +- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are read from the host-resolved root crumb (exact for every root form this backend emits: `/`, `C:\`, `\\server\share\`); the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. Focus parking is a card-wide invariant, not an editor-only one: every pick — editing or not, including right-pane advances and create landings whose columns are replaced — re-parks focus on the selection's row after commit, while every other displacing exit (Enter, Escape, a navigation landing whose new level dropped the focused row, a failed pick or create relist, and the nested create dialog closing) falls back to the crumb edit zone whenever focus actually fell to body, and a show-hidden toggle click that finds focus among the rows parks synchronously on the toggle itself. The guarantee is scoped to the dialog's own node replacements — the Modal has no focus trap, so tabbing past the card's edge legitimately leaves, and the owner's adopt window (where `busy` inerts every control and the dialog is closing either way) is likewise outside it. - **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. Known boundaries of the progressive shape: a pointer press landing exactly inside the one-RTT upgrade window can lose its click when the pressed row node is replaced (keyboard focus is re-parked on the re-selected row; the pointer window is accepted); on slash platforms (macOS) only a final-segment case drift misses the parent-entry match and keeps the single-pane landing, while ancestor-segment drift still matches — parent entry paths inherit the typed prefix — and lands two panes at the cost of the Home collapse; and navigate always relists both legs even when the target is the currently shown level — a crumb tap doubles as the refresh gesture, so freshness wins over reusing possibly-stale in-hand listings at the cost of up to two host scans. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index fb800fe75b..bfa9f80db3 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -19,7 +19,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **不用 `ctx.fs` seam。** `packages/fs/` 是面向模型/会话的存储栈(policy 事件、sandbox 可换后端)。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 `fs-sandbox` 绝不能改变 GUI 行为——而 OS 事实(home 锚定、隐藏约定)也不是存储原语。picker seam 保持无展示、无模型;`packages/host/` 是它消费方域的家。 - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 -- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从宿主解析的根 crumb 读取(对该后端发出的每种根形态都精确:`/`、`C:\`、`\\server\share\`);下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。焦点停靠是卡片全域的不变量,而非编辑器独有:每次选取——无论是否处于编辑态,包括右栏推进与各列被替换的创建落地——提交后都把焦点重新停靠到选中项所在的行上,而其余所有会顶离焦点的退出(Enter、Escape、新层级已不含焦点所在行的导航落地、选取或创建失败后的重新列举,以及嵌套创建对话框的关闭)只要焦点确实落到了 body 上,就回落到 crumb 编辑区,而点击"显示隐藏"开关时若发现焦点落在行间,则把焦点同步停靠到开关自身;键盘遍历绝不会落出卡片之外(Modal 没有焦点陷阱),仅 owner 的接纳窗口期间例外——此时 `busy` 把每个控件置为惰性,且对话框反正正在关闭。 +- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从宿主解析的根 crumb 读取(对该后端发出的每种根形态都精确:`/`、`C:\`、`\\server\share\`);下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。焦点停靠是卡片全域的不变量,而非编辑器独有:每次选取——无论是否处于编辑态,包括右栏推进与各列被替换的创建落地——提交后都把焦点重新停靠到选中项所在的行上,而其余所有会顶离焦点的退出(Enter、Escape、新层级已不含焦点所在行的导航落地、选取或创建失败后的重新列举,以及嵌套创建对话框的关闭)只要焦点确实落到了 body 上,就回落到 crumb 编辑区,而点击"显示隐藏"开关时若发现焦点落在行间,则把焦点同步停靠到开关自身。该保证的范围仅限对话框自身的节点替换——Modal 没有焦点陷阱,所以 Tab 越过卡片边缘属于正当离开,而 owner 的接纳窗口(其间 `busy` 把每个控件置为惰性,且对话框反正正在关闭)同样在此范围之外。 - **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。渐进形态的已知边界:指针按压若恰好落在单个 RTT 的升级窗口内,可能因所按行节点被替换而丢失点击(键盘焦点会重新停靠到重新选中的行上;指针的这段窗口则被接受);斜杠平台(macOS)上仅末段的大小写偏差会错过父层级条目匹配,保留单栏落地,而祖先段的偏差仍能匹配——父层级条目路径继承键入的前缀——并落地双栏,代价是 Home 塌缩;且导航总是重新列举两程,哪怕目标就是当前展示的层级——crumb 点按兼作刷新手势,因此宁要新鲜度也不复用手头可能已陈旧的列举,代价是至多两次宿主扫描。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index 3d0713e523..25a0698234 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -19,7 +19,7 @@ export interface DirectoryEntry { export interface DirectoryListing { /** Absolute path of the listed directory. */ path: string - /** The host account's home directory (breadcrumb "Home" rooting). */ + /** The host account's home directory (breadcrumb "Home" rooting), in the same resolved shape as `path` and `crumbs[].path`. */ home: string /** * Ancestor chain from the filesystem root to the listed directory diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index bdab232457..0ed8cf3eaa 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -82,19 +82,17 @@ function foldSeparatorsFor(sep: '\\' | '/'): (value: string) => string { } /** - * Lexically normalizes an absolute host path for comparisons: folds - * forward slashes on Windows (win32 accepts either), 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 the backend's resolve() for the - * shapes an environment-supplied HOME legally carries verbatim while the - * backend's own paths arrive already resolved. A lexical mirror only: - * symlinks are the backend's business. + * Lexically normalizes a typed absolute path for comparisons against the + * backend's resolved ones (the wire contract keeps `path`, `crumbs[].path`, + * and `home` in resolved shape; only the DRAFT side 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 { - const foldSeparators = foldSeparatorsFor(sep) - return (raw) => { - const value = foldSeparators(raw) + 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 @@ -123,16 +121,14 @@ function normalizePathFor(sep: '\\' | '/'): (value: string) => string { /** * 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. The home comparison folds per platform and lexically - * normalizes both sides, so a typed-case Windows path or a `HOME=/home/u/.` - * shape still collapses to the Home crumb. + * 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. */ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryEntry[] { - const sep = separatorOf(listing) - const fold = foldPathFor(sep) - const normalize = normalizePathFor(sep) - const home = fold(normalize(listing.home)) - const homeIndex = listing.crumbs.findIndex(crumb => fold(normalize(crumb.path)) === home) + const fold = foldPathFor(separatorOf(listing)) + const home = fold(listing.home) + const homeIndex = listing.crumbs.findIndex(crumb => fold(crumb.path) === home) if (homeIndex === -1) return listing.crumbs const tail = listing.crumbs.slice(homeIndex + 1) return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail] @@ -161,13 +157,14 @@ function separatorOf(listing: DirectoryListing): '\\' | '/' { * 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. The directory part compares lexically - * normalized (dot segments, repeated separators, and win32 forward - * slashes all match what Enter would navigate to) and under the platform - * case fold (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 name filter downstream is case-insensitive - * everywhere. + * 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. */ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null { if (draft === null) return null @@ -177,7 +174,7 @@ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string if (cut === -1) return null const fold = foldPathFor(sep) const normalize = normalizePathFor(sep) - return fold(normalize(folded.slice(0, cut + 1))) === fold(normalize(listing.path)) + return fold(normalize(folded.slice(0, cut + 1))) === fold(listing.path) ? folded.slice(cut + 1) : null } @@ -593,15 +590,16 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, 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 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 + // 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. The one window outside this invariant is - // the owner's adopt: busy inerts every control in the card (browsers - // blur disabled elements to body) and no parking applies — the owner - // closes the dialog either way. + // 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. useEffect(() => { if (pathDraft !== null) return if (refocusPick.current) { diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts index 6fa157ea87..3a2b3d4cdf 100644 --- a/packages/host/directory-picker-browse/src/index.ts +++ b/packages/host/directory-picker-browse/src/index.ts @@ -215,7 +215,12 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { } private async list(path?: string, signal?: AbortSignal): Promise { - const home = homedir() + // 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 clients + // compare home against the resolved `path`/`crumbs` — the wire contract + // promises one canonical shape for all three. + const home = resolve(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). diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index af40e08fe7..38fe2a5cba 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -240,19 +240,6 @@ describe('DirectoryBrowser', () => { expect(within(columns()[1]!).getByText('harness')).toBeTruthy() }) - // HOME ships verbatim from the environment while listing paths arrive - // resolved; each decoration (trailing, repeated, dot, dot-dot segments) - // must still normalize to the display root — single pane, Home crumb, - // and no parent leg launched. - it.each(['/', '//', '/foo/../.'])('a home decorated with "%s" is still the display root', async (decoration) => { - const listDirectory = vi.fn(async (path?: string) => ({ ...listingFor(path), home: `${HOME}${decoration}` })) - mount({ listDirectory }) - await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) - expect(columns()).toHaveLength(1) - expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() - expect(listDirectory).toHaveBeenCalledTimes(1) - }) - it('a landing whose new level dropped the focused row parks on the edit zone', async () => { mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) @@ -407,42 +394,16 @@ describe('DirectoryBrowser', () => { expect(document.activeElement?.getAttribute('aria-current')).toBe('true') }) - it('a UNC home with dot-dot never pops the share root and still collapses to Home', async () => { + 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`, - // USERPROFILE ships verbatim; win32.resolve keeps \\server\share as - // the unpoppable root and folds the doubled separator, so this - // normalizes to \\server\share\x. - home: '\\\\server\\\\share\\..\\x', + home: `${SHARE}\\x`, crumbs: [ { name: `${SHARE}\\`, path: `${SHARE}\\`, hidden: false }, { name: 'x', path: `${SHARE}\\x`, hidden: false }, ], - entries: [], - truncated: false, - } - const listDirectory = vi.fn(async () => listing) - mount({ listDirectory }) - await waitFor(() => { expect(listDirectory).toHaveBeenCalled() }) - await waitFor(() => { expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() }) - expect(columns()).toHaveLength(1) - expect(listDirectory).toHaveBeenCalledTimes(1) - }) - - it('a forward-slash Windows home still reads as the display root', async () => { - const listing: DirectoryListing = { - path: 'C:\\Users\\Alice', - // USERPROFILE may legally use forward slashes; the root crumb (not - // the home text) carries the platform, and normalization folds the - // slashes before comparing. - home: 'C:/Users/Alice', - crumbs: [ - { name: 'C:\\', path: 'C:\\', hidden: false }, - { name: 'Users', path: 'C:\\Users', hidden: false }, - { name: 'Alice', path: 'C:\\Users\\Alice', hidden: false }, - ], - entries: [{ name: 'Desktop', path: 'C:\\Users\\Alice\\Desktop', hidden: false }], + entries: [{ name: 'Alpha', path: `${SHARE}\\x\\Alpha`, hidden: false }], truncated: false, } const listDirectory = vi.fn(async () => listing) @@ -450,7 +411,15 @@ describe('DirectoryBrowser', () => { await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) expect(columns()).toHaveLength(1) expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() - expect(listDirectory).toHaveBeenCalledTimes(1) + // 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('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 () => { @@ -682,6 +651,8 @@ describe('DirectoryBrowser', () => { 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 () => { diff --git a/packages/host/directory-picker-browse/tests/home-shape.spec.ts b/packages/host/directory-picker-browse/tests/home-shape.spec.ts new file mode 100644 index 0000000000..153551f7f9 --- /dev/null +++ b/packages/host/directory-picker-browse/tests/home-shape.spec.ts @@ -0,0 +1,28 @@ +/** + * 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`. + */ + +import { resolve } from 'node:path' +import { expect, it, vi } from 'vitest' +import { Context } from 'cordis' + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, homedir: () => `${actual.homedir()}/.//.` } +}) + +it('resolves a decorated homedir before stamping listing.home', async () => { + const { homedir } = await vi.importActual('node:os') + 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(homedir())) + expect(listing.path).toBe(listing.home) + await fiber.dispose() +}) diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts index 002d42e516..98adab3c5d 100644 --- a/packages/host/directory-picker-browse/tests/service.spec.ts +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' -import { basename, join } from 'node:path' +import { basename, join, resolve } 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,7 +48,9 @@ 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) - expect(listing.home).toBe(homedir()) + // Resolved like path and crumbs — the environment may decorate HOME, + // and the wire contract promises one canonical shape for all three. + expect(listing.home).toBe(resolve(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. diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts index 4dba9c9c22..e6aa5791aa 100644 --- a/packages/host/directory-picker/src/index.ts +++ b/packages/host/directory-picker/src/index.ts @@ -38,7 +38,7 @@ export interface DirectoryEntry { export interface DirectoryListing { /** Absolute path of the listed directory. */ path: string - /** The host account's home directory (breadcrumb "Home" rooting). */ + /** The host account's home directory (breadcrumb "Home" rooting), in the same resolved shape as `path` and `crumbs[].path`. */ home: string /** * Ancestor chain from the filesystem root to the listed directory