Merge latest master into worktree-config-settings-seam

This commit is contained in:
Tianyi Cui
2026-07-30 18:55:15 +08:00
12 changed files with 943 additions and 180 deletions

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 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md
2026-07-28-directory-picker-capability-seam.md: 7c8f8cb67690cb4c5858cefb52b8cd79e649ec38
2026-07-28-directory-picker-capability-seam.zh.md: 05545fc3cd758523814b31afa705249972d86464
2026-07-28-directory-picker-capability-seam.md: ad2aa904beddb2fe941883c3c1827702dbec9964
2026-07-28-directory-picker-capability-seam.zh.md: 30e719ad9b4e8374496106b447e961a042c7d8b6

View File

@@ -18,7 +18,9 @@ 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 planned show-hidden toggle becomes a client-only change. Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself.
- **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 inferred from `listing.home`; 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.
- **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.
- **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.
- **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it.
@@ -30,6 +32,9 @@ Placement and policy rulings folded into this decision:
- **One uniform seam method set (`pick(): path`).** Rejected: an in-app browser cannot be served behind a single host-side call — the browsing loop lives in the client and needs primitives on the wire; the native chooser cannot implement primitives. The interaction difference is irreducible, hence the discriminant.
- **Direct stdlib calls inside apiproxy (no seam).** Rejected: keeps the gateway the only swap point (source edits), loses fixture/test backends, and contradicts the plugin doctrine that motivated the work.
- **Adopting a file-manager/drive-enumeration dependency.** Rejected per the survey above; recorded here as the dependency policy requires.
- **A flip-label show-hidden toggle ("Hide hidden files").** Rejected: a flipping action label is ambiguous between state and action and doubles the negative; the fixed label with a pressed presentation states both at once.
- **Pure relatedTarget blur cancellation (no mousedown suppression).** Rejected: Safari does not focus buttons on pointer down, so a click's focusout carries a null `relatedTarget` and would cancel the editor before the click lands; editing-scoped mousedown suppression plus the card-anchored relatedTarget guard covers pointer and keyboard paths together.
- **A wire `separator` field on `DirectoryListing` (host stamps `path.sep`).** Deferred, not rejected: it is the authoritative form — a POSIX home directory containing a backslash defeats the `listing.home` heuristic — but it touches the seam type and every backend; the browse client's `separatorOf` carries a TODO pointing at this alternative until a wire change is next scheduled.
## Consequences

View File

@@ -18,7 +18,9 @@ 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 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,计划中的"显示隐藏"开关变成纯客户端改动。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。
- **隐藏条目:返回并打标。** 宿主标注 `hidden`POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。
- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded``aria-controls`active-descendant、结果播报同样被延期——目前二者在辅助技术看来是彼此独立的控件。
- **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地重新选中目标在父层级中的实际条目Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。
- **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。
- **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await打开、每次读取、每次符号链接探测都与信号赛跑中止路径放弃而非等待 closeNode 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。
- **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。
@@ -30,6 +32,9 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick
- **统一方法集的 seam`pick(): path`)。** 否决:应用内浏览器无法藏在一次宿主侧调用后面——浏览循环在客户端,需要协议上的原语;而对话框实现不了原语。交互差异不可约,故用判别标签。
- **apiproxy 里直接调标准库(不建 seam。** 否决:换装点仍是改网关源码,失去 fixture测试后端与促成这项工作的插件教义相悖。
- **引入文件管理器/盘符枚举依赖。** 按上文调研否决;依赖政策要求记录于此。
- **动作标签随状态翻转的"显示隐藏"开关("隐藏隐藏文件")。** 否决:会翻转的动作标签在状态与动作之间有歧义,还把否定叠了两层;固定标签加按下态呈现一次说清两者。
- **纯 relatedTarget 失焦取消(不做 mousedown 抑制)。** 否决Safari 在指针按下时不给按钮聚焦,点击触发的 focusout 因而携带空 `relatedTarget`,会在点击落地前就取消编辑器;编辑期作用的 mousedown 抑制加上锚定卡片的 relatedTarget 守卫才能同时覆盖指针与键盘路径。
- **在 `DirectoryListing` 上增设线上 `separator` 字段(宿主标注 `path.sep`)。** 延期而非否决:它才是权威形态——含反斜杠的 POSIX 家目录会击穿 `listing.home` 启发式——但它触及 seam 类型与每个后端browse 客户端的 `separatorOf` 挂着指向本方案的 TODO直到下次安排线上变更。
## 后果

View File

@@ -5,6 +5,37 @@
- img
- button "browse-golden"
- button "Edit path"
- list:
- listitem:
- button "adopted":
- img
- text: adopted
- img
- listitem:
- button "alpha-ws":
- img
- text: alpha-ws
- img
- listitem:
- button "beta-ws":
- img
- text: beta-ws
- img
- listitem:
- button "browse-golden":
- img
- text: browse-golden
- img
- listitem:
- button "same-name":
- img
- text: same-name
- img
- listitem:
- button "workspace":
- img
- text: workspace
- img
- list:
- listitem:
- button "alpha":
@@ -19,5 +50,6 @@
- button "New folder":
- img
- text: New folder
- button "Show hidden files"
- button "Cancel"
- button "Open"

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: 318380405214d5f25ad77e348c4e134a8981ffb3
README.zh.md: 2f88f64cc2974b8535e34eb9798f512ea109b754
README.md: 23153881b84dcb71dfb05d4f297a5818c410ca77
README.zh.md: d7010e2941a801ba6358082824330eaae46e42b7

View File

@@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick
Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (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, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md).
**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view, breadcrumb with a click-to-edit path zone, nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored: a crumb jump or a submitted path commits the target immediately, then re-selects its actual entry in its parent level once that level arrives — two panes, so stepping back never collapses (a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing (case-insensitively, over the listed — possibly truncated — rows only; Enter still navigates by the exact text), and cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft); a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from both filters; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
## Model Experience

View File

@@ -6,7 +6,7 @@
行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/``C:\``list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo``/foo`)与不完整的 UNC 前缀(`\\``\\server`)——报 `directory-unreadable``directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。
**双面包**browser half`./client`)以应用内 **选择工作区目录** 对话框figma `Harness` 813-23126 家族——Miller 双列视图、带点击即编辑路径区的面包屑、嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory``host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`zh 默认en。因此一行 cordis.yml 同时组合浏览交互的两侧client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind
**双面包**browser half`./client`)以应用内 **选择工作区目录** 对话框figma `Harness` 813-23126 家族——Miller 双列视图其导航以选中项为锚落地crumb 跳转或提交的路径会立即提交目标待父层级到达后再在其中重新选中目标的实际条目——双栏因此后退绝不塌缩父层级这一程失败或被截断时保持单栏落地展示根保持单个宽层级带点击即编辑路径区的面包屑其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤不区分大小写且仅作用于已列出、可能被截断的行Enter 仍按确切文本导航)、按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿);基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受这两种过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory``host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`zh 默认en。因此一行 cordis.yml 同时组合浏览交互的两侧client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind
## 模型体验

View File

@@ -1,6 +1,7 @@
/* Directory-browser dialog (figma 813-23126 family). The shared Modal renders
* headless here — mask, card, Escape only — and this module owns the figma
* frame: 600×420 card (viewport-clamped), header (title + crumbs, l3 separator),
* frame: 680×500 card (viewport-clamped; upsized from the figma 600×420),
* header (title + crumbs, l3 separator),
* the one-or-two-column Miller content, and the bordered footer. */
/* Doubled class beats Modal's own .dialog regardless of stylesheet order. */
@@ -8,19 +9,26 @@
* columns scroll, so shrinking the height keeps Open/Cancel reachable
* instead of clipping them below a fixed overlay. */
.dialog.dialog {
width: min(600px, 100%);
height: min(420px, calc(100dvh - 32px));
width: min(680px, 100%);
height: min(500px, calc(100dvh - 32px));
padding: 0;
gap: 0;
}
/* Header block: pl24 pr14 pt22 pb12, 8px between title row and crumb row. */
/* Card-scope wrapper hosting the path editor's Escape and focus-leave
* observers; display:contents keeps header/content/footer as direct flex
* children of the Modal card. */
.editorScope {
display: contents;
}
/* Header block: pl24 pr14 pt16 pb8, 8px between title row and crumb row. */
.header {
display: flex;
flex-direction: column;
gap: 8px;
flex: none;
padding: 22px 14px 12px 24px;
padding: 16px 14px 8px 24px;
border-bottom: 1px solid var(--dsw-alias-border-l3);
}
@@ -54,8 +62,12 @@
align-items: stretch;
flex: 1 1 0;
min-height: 0;
gap: 20px;
/* 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 {
@@ -126,28 +138,34 @@
color: var(--dsw-alias-label-primary);
}
/* Miller content: pt16 px24; columns are 256 wide (or full width solo) with
* the hairline divider centered between them; each column scrolls alone. */
/* Miller content: symmetric 16px vertical padding so the divider clears the
* header and footer rules evenly; each column scrolls alone (column widths
* live at .column). */
.content {
display: flex;
flex-direction: column;
flex: 1 1 0;
min-height: 0;
padding: 16px 24px 0;
/* 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;
}
/* 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
* the panes. */
.column {
display: flex;
flex-direction: column;
gap: 2px;
width: 256px;
flex: none;
overflow-y: auto;
}
.columnWide {
width: 100%;
flex: 1 1 0;
min-width: 256px;
overflow-y: auto;
/* The themed scrollbar occupies the column's edge (styled scrollbars are
* classic, gutter-taking ones); the extra clearance keeps the row pills
* clear of the thumb. */
padding-right: 8px;
}
.divider {
@@ -228,8 +246,8 @@
color: var(--dsw-alias-state-error-primary);
}
/* Footer: l3 separator on top, pt12 px24, New-folder pinned left; the fixed
* card leaves the figma 28px below the 36px buttons. */
/* Footer: l3 separator on top, symmetric padding so the row sits vertically
* centered in the bar; New-folder and the show-hidden toggle pin left. */
.footerBar {
display: flex;
align-items: center;
@@ -238,10 +256,42 @@
flex-wrap: wrap;
gap: 8px;
flex: none;
padding: 12px 24px 28px;
padding: 16px 24px;
border-top: 1px solid var(--dsw-alias-border-l3);
}
/* Show-hidden toggle: a subtle fixed-label text button left of the gap;
* the pressed state seats a check glyph after the label (Menu's selected
* vocabulary; trailing so the label never shifts) instead of flipping the
* wording. */
.showHiddenToggle {
display: inline-flex;
align-items: center;
gap: 4px;
border: none;
background: transparent;
padding: 0;
font-size: 13px;
line-height: 20px;
font-weight: 500;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
white-space: nowrap;
}
.showHiddenToggle:hover {
color: var(--dsw-alias-label-primary);
}
.showHiddenToggle:disabled {
color: var(--dsw-alias-label-caption);
cursor: default;
}
.showHiddenToggleActive {
color: var(--dsw-alias-label-primary);
}
.footerGap {
flex: 1 1 0;
}

View File

@@ -1,22 +1,30 @@
/**
* The in-app workspace-directory browser (figma Harness 813-23126 family): a
* 600×420 dialog (clamped to short/narrow viewports — the Miller row scrolls
* 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 — one
* full-width level until a row is selected, then two 256px columns (level |
* selected folder's children) around a hairline divider. Selecting in the
* 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. 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 filtered here (a
* show-hidden toggle is deferred work, client-side only).
* error surface. Hidden entries are host-flagged and hidden by default; the
* footer's fixed-label "Show hidden files" toggle (aria-pressed, check when
* on) reveals them (client-side only). The path editor opens seeded with a
* trailing separator, and while the draft's directory part names a listed
* level, its final segment prefix-filters that level's rows (a dot-led
* prefix also reveals the hidden entries it names).
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import {
Button, IconChevronRightOutline14, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, Modal,
Button, IconCheckOutline16, IconChevronRightOutline14, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, Modal,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client'
@@ -59,17 +67,59 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE
return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail]
}
/**
* 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): '\\' | '/' {
return listing.home.includes('\\') ? '\\' : '/'
}
/**
* 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 cut = draft.lastIndexOf(sep)
if (cut === -1) return 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). */
function LevelColumn({ entries, selectedPath, busy, onPick, wide }: {
function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPrefix, pathEditing }: {
entries: readonly DirectoryEntry[]
selectedPath: string | null
busy: boolean
onPick: (entry: DirectoryEntry) => void
wide: boolean
showHidden: boolean
filterPrefix: string | null
pathEditing: boolean
}) {
const visible = entries.filter((entry) => {
// The selection is exempt from both filters: it anchors the two-pane
// view (crumbs and the child pane point at it), so neither the hidden
// filter after a dot-reveal pick nor a prefix miss may orphan it.
if (entry.path === selectedPath) return true
if (filterPrefix !== null && !entry.name.toLowerCase().startsWith(filterPrefix.toLowerCase())) return false
// A dot-led prefix names hidden entries explicitly, so matching ones
// surface even while the toggle keeps the rest hidden.
return showHidden || !entry.hidden || filterPrefix?.startsWith('.') === true
})
return (
<div className={clsx(css.column, wide && css.columnWide)} role="list">
{entries.filter(entry => !entry.hidden).map((entry) => {
<div className={css.column} role="list">
{visible.map((entry) => {
const selected = entry.path === selectedPath
return (
// The wrapper carries the list semantics; the row keeps its NATIVE
@@ -80,6 +130,15 @@ function LevelColumn({ entries, selectedPath, busy, onPick, wide }: {
aria-current={selected || undefined}
className={clsx(css.row, selected && css.rowSelected)}
disabled={busy}
// While the path editor is open, keep focus in it: a focus
// steal on mousedown would blur the editor and (in engines
// where the blur lands before our guards) drop this click.
// Outside editing, rows keep native focus behavior.
onMouseDown={pathEditing ? (event) => { event.preventDefault() } : undefined}
// 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
@@ -110,6 +169,8 @@ 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 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)
const [creatingFolder, setCreatingFolder] = useState(false)
@@ -156,28 +217,86 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
return { seq, scan: listDirectory(path, controller.signal) }
}, [supersede, listDirectory])
/** Replace the whole view with one freshly listed level (no selection). */
/**
* 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> => {
const controller = new AbortController()
scanController.current = controller
return listDirectory(path, controller.signal)
}, [listDirectory])
/**
* Replace the whole view with a freshly navigated level. The target level
* commits the moment it arrives (single wide level: the editor closes and
* loading ends on this first settlement, so an Enter-submitted navigation
* is never withdrawn waiting on anything further). Away from the display
* root — the same collapse the crumb header renders, so crumbs and pane
* 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. 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)
setLoading(true)
setError(null)
scan.then((next) => {
scan.then((target) => {
if (seq !== requestSeq.current) return
setParent(next)
setParent(target)
setSelected(null)
setChild(null)
setLoading(false)
setPathDraft(null)
// Arity is label-independent: only the collapsed chain's depth decides.
if (displayCrumbs(target, '').length < 2) return
const parentCrumb = target.crumbs.at(-2)
/* v8 ignore next -- narrowing: a two-deep display chain implies a parent crumb (root-to-target inclusive). */
if (parentCrumb === undefined) return
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.
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
setParent(parentLevel)
setSelected(match)
setChild(target)
}, () => {
// Swallows the parent-leg failure (its abort included): the
// committed single-pane landing stands, and nobody asked to see
// the parent level.
})
}, (reason: unknown) => {
if (seq !== requestSeq.current) return
setLoading(false)
setError(failureText(reason))
})
}, [launchListing])
}, [launchListing, continueScan])
// 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. 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)
setLoading(true)
@@ -193,8 +312,31 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
// An unreadable selection cannot be the committing target while the
// breadcrumb still names the level: fall back to the single pane.
setSelected(null)
// Clearing the selection can unmount the very row the pick parked
// focus on (a dot-revealed hidden row re-hides); the refocus effect
// 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(() => {
// Cancel also withdraws a navigation the editor already launched: its
// late success must not jump to the cancelled path, so the pending
// request is superseded and the view leaves the loading state.
supersede()
setLoading(false)
setPathDraft(null)
setError(null)
// Editing may have superseded the selection's preview request; a
// selection with no preview would render a half-empty two-pane view, so
// cancel falls back to the single-pane level.
if (child === null) setSelected(null)
// With no level listed yet (the editor superseded the initial home
// listing), plain cancellation would leave a permanently blank picker:
// restart the home listing.
if (parent === null) navigate()
}, [supersede, child, parent, navigate])
/** A right-column pick advances the view one level: child becomes the level. */
const advance = useCallback((entry: DirectoryEntry) => {
@@ -213,6 +355,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
setSelected(null)
setChild(null)
setCreatingFolder(false)
setShowHidden(false)
navigate()
return
}
@@ -221,6 +364,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
setPathDraft(null)
setFolderDraft(null)
setCreateError(null)
// A close mid-flight (failed Enter, then Cancel) may leave refocus
// flags armed; retire them so a later render cannot consume them.
refocusPick.current = false
refocusEditZone.current = false
}, [open, navigate, supersede])
/** The folder a create or Open acts on: the selection, else the listed level. */
@@ -285,6 +432,37 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
const row = millerRowRef.current
if (row !== null && childPath !== undefined) row.scrollLeft = row.scrollWidth
}, [childPath])
// 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) {
refocusPick.current = false
refocusEditZone.current = false
const rowHost = millerRowRef.current
/* 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"]')
/* 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
// Re-park only when the close actually dropped focus to body; focus
// the user parked elsewhere (a surviving row) stays theirs.
if (document.activeElement !== document.body) return
const zone = editZoneRef.current
/* v8 ignore next -- narrowing guard: crumb mode renders the edit zone whenever the editor just closed. */
if (zone === null) return
zone.focus()
}
})
if (!open) return null
const twoPane = selected !== null
@@ -310,149 +488,216 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
className={clsx(css.dialog)}
headless
>
<div className={css.header}>
<h2 className={css.title}>{t('browser.title')}</h2>
<div className={css.crumbBar}>
{pathDraft === null
? (
<>
<span className={css.crumbTrail} role="navigation" ref={crumbTrailRef}>
{crumbs.map((crumb, index) => (
<span key={crumb.path} className={css.crumbSeat}>
{index > 0 && <IconChevronRightOutline14 size={12} className={css.crumbChevron} />}
<button
type="button"
className={css.crumb}
disabled={parentInert}
onClick={() => { navigate(crumb.path) }}
>
{crumb.name}
</button>
</span>
))}
</span>
{/* The empty zone right of the crumbs is the path-edit affordance. */}
<button
type="button"
className={css.crumbEditZone}
aria-label={t('browser.editPath')}
// Stays available with no listed level: when the home
// listing itself fails, typing an absolute path is the one
// remaining way forward.
disabled={parentInert}
onClick={() => {
{/* Path-edit cancellation is observed at the card scope, not the
* input: once Tab parks focus on a filtered row the input is off the
* event path, yet Escape must still collapse the editor (not the
* dialog) and a further focus move out of the card must still
* cancel. display:contents keeps header/content/footer as direct
* flex children of the Modal card. */}
<div
className={css.editorScope}
onKeyDown={(event) => {
if (event.key !== 'Escape' || pathDraft === null) return
// stopPropagation keeps the card-scope Escape from the Modal's
// document listener — the same containment the input previously
// provided for itself.
event.stopPropagation()
// 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 = document.activeElement === pathInputRef.current
cancelPathEdit()
}}
// Focus leaving THIS dialog card while editing cancels like Escape.
// Guarded non-cancel paths: window/tab focus loss (document no
// longer focused); a focus move that stays inside the card (Tab
// onto the filtered rows or the footer toggle); and pointer paths,
// where rows and the toggle suppress focus steal on mousedown while
// editing so their click lands first. Enter keeps focus in the
// input while its navigation is in flight, so a submitted path is
// never withdrawn here. Anchored to this card via closest, not any
// [role="dialog"], so focus escaping into a sibling overlay cancels.
onBlur={(event) => {
if (pathDraft === null) return
if (!document.hasFocus()) return
const card = event.currentTarget.closest('[role="dialog"]')
/* v8 ignore next -- narrowing guard: this scope always renders inside the Modal card. */
if (card === null) return
if (event.relatedTarget instanceof Node && card.contains(event.relatedTarget)) return
// The user moved focus out of the card themselves: cancel without
// re-parking (a lingering Enter-failure flag must not yank focus
// back either).
refocusEditZone.current = false
cancelPathEdit()
}}
>
<div className={css.header}>
<h2 className={css.title}>{t('browser.title')}</h2>
<div className={css.crumbBar}>
{pathDraft === null
? (
<>
<span className={css.crumbTrail} role="navigation" ref={crumbTrailRef}>
{crumbs.map((crumb, index) => (
<span key={crumb.path} className={css.crumbSeat}>
{index > 0 && <IconChevronRightOutline14 size={12} className={css.crumbChevron} />}
<button
type="button"
className={css.crumb}
disabled={parentInert}
onClick={() => { navigate(crumb.path) }}
>
{crumb.name}
</button>
</span>
))}
</span>
{/* The empty zone right of the crumbs is the path-edit affordance. */}
<button
type="button"
className={css.crumbEditZone}
aria-label={t('browser.editPath')}
// Stays available with no listed level: when the home
// listing itself fails, typing an absolute path is the one
// remaining way forward.
disabled={parentInert}
ref={editZoneRef}
onClick={() => {
// Opening the editor supersedes any pending listing: a
// settlement landing before the first keystroke would
// otherwise close the editor via navigate's draft reset.
supersede()
setLoading(false)
setPathDraft(selected?.path ?? parent?.path ?? '')
}}
/>
</>
)
: (
<input
className={css.pathInput}
value={pathDraft}
aria-label={t('browser.editPath')}
autoFocus
disabled={parentInert}
onChange={(event) => {
supersede()
setLoading(false)
// Seed with a trailing separator so typing immediately
// continues into child names (and prefix-filters below).
// No listed level means nothing to seed from (the editor
// is the recovery path for a failed home listing).
if (parent === null) {
setPathDraft('')
return
}
const base = selected?.path ?? parent.path
const sep = separatorOf(parent)
setPathDraft(base.endsWith(sep) ? base : `${base}${sep}`)
}}
/>
</>
)
: (
<input
className={css.pathInput}
value={pathDraft}
aria-label={t('browser.editPath')}
autoFocus
ref={pathInputRef}
disabled={parentInert}
onChange={(event) => {
// Editing the draft supersedes any in-flight navigation:
// its completion must neither clear the newer text nor
// repopulate the view with the older path.
supersede()
setLoading(false)
setPathDraft(event.target.value)
}}
{...compositionGuard}
onKeyDown={(event) => {
if (event.key === 'Enter' && !composingRef.current) {
event.preventDefault()
// Trim only detects a blank draft; the Host gets the
// original text — a real directory name may end in
// whitespace, and trimming would list its sibling.
if (pathDraft.trim() !== '') navigate(pathDraft)
}
if (event.key === 'Escape') {
event.stopPropagation()
// Cancel also withdraws a navigation the editor already
// launched: its late success must not jump to the
// cancelled path, so the pending request is superseded
// and the view leaves the loading state.
supersede()
setLoading(false)
setPathDraft(null)
setError(null)
// Editing may have superseded the selection's preview
// request; a selection with no preview would render a
// half-empty two-pane view, so cancel falls back to the
// single-pane level.
if (child === null) setSelected(null)
// With no level listed yet (the editor superseded the
// initial home listing), plain cancellation would leave a
// permanently blank picker: restart the home listing.
if (parent === null) navigate()
}
}}
setPathDraft(event.target.value)
}}
{...compositionGuard}
// Escape and focus-leave cancellation live on the card-scope
// wrapper above (they must work after focus Tabs onto the
// rows); this handler owns only submission.
onKeyDown={(event) => {
if (event.key === 'Enter' && !composingRef.current) {
event.preventDefault()
// Trim only detects a blank draft; the Host gets the
// original text — a real directory name may end in
// whitespace, and trimming would list its sibling.
if (pathDraft.trim() !== '') {
// Success will unmount the still-focused input; park
// focus on the returning crumb edit zone (a failure
// keeps the editor, so the flag waits until close).
refocusEditZone.current = true
navigate(pathDraft)
}
}
}}
/>
)}
</div>
</div>
<div className={css.content}>
<div className={css.millerRow} ref={millerRowRef}>
{parent !== null && (
<LevelColumn
entries={parent.entries}
selectedPath={selected?.path ?? null}
busy={parentInert}
onPick={select}
showHidden={showHidden}
filterPrefix={draftPrefixFor(parent, pathDraft)}
pathEditing={draftPending}
/>
)}
</div>
</div>
<div className={css.content}>
<div className={css.millerRow} ref={millerRowRef}>
{parent !== null && (
<LevelColumn
entries={parent.entries}
selectedPath={selected?.path ?? null}
busy={parentInert}
onPick={select}
wide={!twoPane}
/>
)}
{twoPane && <span className={css.divider} />}
{twoPane && child !== null && (
<LevelColumn
entries={child.entries}
selectedPath={null}
busy={parentInert}
onPick={advance}
wide={false}
/>
)}
</div>
{loading && <div className={css.status} role="status">{t('browser.loading')}</div>}
{/* The backend bounds a level at its complete-result limit; say so
{twoPane && <span className={css.divider} />}
{twoPane && child !== null && (
<LevelColumn
entries={child.entries}
selectedPath={null}
busy={parentInert}
onPick={advance}
showHidden={showHidden}
filterPrefix={draftPrefixFor(child, pathDraft)}
pathEditing={draftPending}
/>
)}
</div>
{loading && <div className={css.status} role="status">{t('browser.loading')}</div>}
{/* The backend bounds a level at its complete-result limit; say so
* whenever a visible pane was cut instead of letting the tail of a
* huge directory go silently missing. */}
{(parent?.truncated === true || child?.truncated === true) && !loading
{(parent?.truncated === true || child?.truncated === true) && !loading
&& <div className={css.status} role="status">{t('browser.truncated')}</div>}
{error !== null && <div className={css.error} role="alert">{error}</div>}
</div>
<div className={css.footerBar}>
<Button
variant="outline"
icon={<IconPlusOutline16 size={14} />}
disabled={parent === null || loading || parentInert || draftPending}
onClick={() => {
setFolderDraft('')
setCreateError(null)
}}
>
{t('browser.newFolder')}
</Button>
<span className={css.footerGap} />
<Button variant="outline" className={clsx(css.footerAction)} disabled={parentInert} onClick={onClose}>{t('browser.cancel')}</Button>
<Button
variant="primary"
className={clsx(css.footerAction)}
disabled={targetPath === null || loading || parentInert || draftPending}
/* v8 ignore next -- narrowing guard: Open disables while no target exists. */
onClick={() => { if (targetPath !== null) onOpen(targetPath) }}
>
{t('browser.open')}
</Button>
{error !== null && <div className={css.error} role="alert">{error}</div>}
</div>
<div className={css.footerBar}>
<Button
variant="outline"
icon={<IconPlusOutline16 size={14} />}
disabled={parent === null || loading || parentInert || draftPending}
onClick={() => {
setFolderDraft('')
setCreateError(null)
}}
>
{t('browser.newFolder')}
</Button>
<button
type="button"
className={clsx(css.showHiddenToggle, showHidden && css.showHiddenToggleActive)}
aria-pressed={showHidden}
disabled={parentInert}
// The toggle composes with the path editor (dot-led prefixes and
// this filter interleave): while editing, don't steal focus, so
// toggling never blur-cancels a draft mid-thought. Outside editing
// it keeps native focus behavior.
onMouseDown={draftPending ? (event) => { event.preventDefault() } : undefined}
onClick={() => { setShowHidden(prev => !prev) }}
>
{t('browser.showHidden')}
{/* 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>
<Button
variant="primary"
className={clsx(css.footerAction)}
disabled={targetPath === null || loading || parentInert || draftPending}
/* v8 ignore next -- narrowing guard: Open disables while no target exists. */
onClick={() => { if (targetPath !== null) onOpen(targetPath) }}
>
{t('browser.open')}
</Button>
</div>
</div>
{/* Nested create dialog (figma 813:23278): names one folder inside the target. */}
<Modal

View File

@@ -46,6 +46,7 @@ export function apply(ctx: ClientContext): void {
'browser.editPath': '编辑路径',
'browser.loading': '加载中…',
'browser.truncated': '文件夹过多,仅显示开头部分。',
'browser.showHidden': '显示隐藏文件',
}],
['en', {
'browser.title': 'Select Workspace Directory',
@@ -60,6 +61,7 @@ export function apply(ctx: ClientContext): void {
'browser.editPath': 'Edit path',
'browser.loading': 'Loading…',
'browser.truncated': 'Too many folders to list; only the beginning is shown.',
'browser.showHidden': 'Show hidden files',
}],
]
try {

View File

@@ -162,6 +162,7 @@ describe('directory-picker-browse client half', () => {
// zh is the shipped default locale.
expect(injected.t('browser.title')).toBe('选择工作区目录')
expect(injected.t('browser.newFolder')).toBe('新建文件夹')
expect(injected.t('browser.showHidden')).toBe('显示隐藏文件')
})
it('drives the injected browse calls through the hole entry', async () => {

View File

@@ -29,6 +29,25 @@ function listingFor(path?: string): DirectoryListing {
],
truncated: false,
},
'/': {
path: '/',
home: HOME,
crumbs: [{ name: '/', path: '/', hidden: false }],
entries: [{ name: 'home', path: '/home', hidden: false }],
truncated: false,
},
[`${HOME}/.config`]: {
path: `${HOME}/.config`,
home: HOME,
crumbs: [
{ name: '/', path: '/', hidden: false },
{ name: 'home', path: '/home', hidden: false },
{ name: 'u', path: HOME, hidden: false },
{ name: '.config', path: `${HOME}/.config`, hidden: true },
],
entries: [],
truncated: false,
},
[DOCS]: {
path: DOCS,
home: HOME,
@@ -103,6 +122,28 @@ describe('DirectoryBrowser', () => {
expect(screen.queryByRole('button', { name: '/' })).toBeNull()
})
it('shows hidden entries when the toggle is on and hides them again on close', async () => {
const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
expect(screen.queryByText('.config')).toBeNull()
// The fixed-label toggle reports its state through aria-pressed. Its
// mousedown never steals focus (so it composes with the path editor).
const toggle = screen.getByRole('button', { name: 'browser.showHidden' })
expect(toggle.getAttribute('aria-pressed')).toBe('false')
fireEvent.mouseDown(toggle)
fireEvent.click(toggle)
expect(toggle.getAttribute('aria-pressed')).toBe('true')
expect(screen.getByText('.config')).toBeTruthy()
fireEvent.click(toggle)
expect(toggle.getAttribute('aria-pressed')).toBe('false')
expect(screen.queryByText('.config')).toBeNull()
// Close resets the toggle.
b.view.rerender(<DirectoryBrowser {...b.props} open={false} />)
b.view.rerender(<DirectoryBrowser {...b.props} open />)
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
expect(screen.queryByText('.config')).toBeNull()
})
it('selects a row into the two-pane view: children preview right, crumbs follow the selection', async () => {
const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
@@ -153,7 +194,7 @@ describe('DirectoryBrowser', () => {
expect(signals[2]?.aborted).toBe(true)
})
it('jumps back through a crumb into a fresh single-column level', async () => {
it('a crumb jump to the display root (home) lands the single wide level', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(rowButton(screen.getByRole('listitem')))
@@ -164,6 +205,183 @@ describe('DirectoryBrowser', () => {
expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull()
})
it('a crumb jump away from the root lands two-pane with the target selected', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(rowButton(screen.getByRole('listitem')))
await waitFor(() => { expect(columns()).toHaveLength(2) })
fireEvent.click(rowButton(within(columns()[1]!).getByRole('listitem')))
await waitFor(() => { expect(screen.getByRole('button', { name: 'harness' })).toBeTruthy() })
// Jumping to the Documents crumb is a step BACK one pane, not a
// collapse: Documents stays selected in the home level, its children
// stay on the right.
fireEvent.click(screen.getByRole('button', { name: 'Documents' }))
await waitFor(() => {
expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true')
})
expect(columns()).toHaveLength(2)
expect(within(columns()[0]!).getByText('Documents')).toBeTruthy()
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
})
it('a navigation to the filesystem root keeps the single wide level', 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: '/' } })
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
// A one-crumb chain has no parent level to show on the left.
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('home') })
expect(columns()).toHaveLength(1)
})
it('commits the target immediately, aborts a superseded parent leg on the wire, and drops its late resolution', async () => {
const signals: (AbortSignal | undefined)[] = []
const settlers: ((value: DirectoryListing) => void)[] = []
// Only the FIRST explicit HOME request (the parent leg) hangs; the later
// home crumb jump lists normally.
let homeCalls = 0
const listDirectory = vi.fn(async (path?: string, signal?: AbortSignal) => {
signals.push(signal)
if (path === HOME && ++homeCalls === 1) {
return new Promise<DirectoryListing>((resolve) => { settlers.push(resolve) })
}
return listingFor(path)
})
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 target leg commits at once: editor closed, single-pane DOCS level,
// while the parent leg (upgrade) is still in flight.
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
expect(columns()).toHaveLength(1)
await waitFor(() => { expect(settlers).toHaveLength(1) })
// A newer jump aborts the pending parent leg ON THE WIRE, not merely
// dropping its settlement.
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
expect(signals[2]?.aborted).toBe(true)
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') })
// Its late resolution changes nothing either.
await act(async () => { settlers[0]!(listingFor(HOME)) })
expect(columns()).toHaveLength(1)
expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull()
})
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
// that misses Documents (the initial open uses the absent-path form).
if (path === HOME) return { ...listingFor(HOME), entries: [], truncated: true }
return listingFor(path)
})
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' })
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
// The upgrade would orphan the selection (no source row): it stays off.
await act(async () => {})
expect(columns()).toHaveLength(1)
expect(screen.queryByText('browser.truncated')).toBeNull()
})
it('anchors the upgrade on the parent level actual entry under Windows case folding', async () => {
const ROOT = 'C:\\'
const TYPED = 'c:\\users'
const winRoot: DirectoryListing = {
path: ROOT,
home: ROOT,
crumbs: [{ name: 'C:\\', path: ROOT, hidden: false }],
entries: [{ name: 'Users', path: 'C:\\Users', hidden: false }],
truncated: false,
}
const winUsers: DirectoryListing = {
path: TYPED,
home: ROOT,
crumbs: [{ name: 'C:\\', path: ROOT, hidden: false }, { name: 'users', path: TYPED, hidden: false }],
entries: [],
truncated: false,
}
mount({ listDirectory: vi.fn(async (path?: string) => (path === TYPED ? winUsers : winRoot)) })
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' })
// The typed case differs from the real entry; the upgrade selects the
// parent level's ACTUAL entry so aria-current and exemptions hold.
await waitFor(() => {
expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true')
})
expect(within(columns()[0]!).getByText('Users')).toBeTruthy()
})
it('re-parks focus on the edit zone when a failed pick unmounts a dot-revealed row', async () => {
const listDirectory = vi.fn(async (path?: string) => {
if (path === `${HOME}/.config`) {
throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path } })
}
return listingFor(path)
})
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: `${HOME}/.co` } })
const row = rowButton(screen.getByRole('listitem'))
fireEvent.mouseDown(row)
fireEvent.click(row)
// The failed selection re-hides the picked row; focus fell to body and
// re-parks on the crumb edit zone.
await screen.findByRole('alert')
expect(screen.queryByText('.config')).toBeNull()
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' }))
})
it('leaves focus on a surviving row when its pick fails', async () => {
const listDirectory = vi.fn(async (path?: string) => {
if (path === DOCS) {
throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path } })
}
return listingFor(path)
})
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: `${HOME}/do` } })
const row = rowButton(screen.getByRole('listitem'))
row.focus()
fireEvent.mouseDown(row)
fireEvent.click(row)
// Documents survives the cleared selection (it is not hidden): the
// user's focus on it is not yanked to the edit zone.
await screen.findByRole('alert')
expect(document.activeElement).toBe(row)
})
it('falls back to the single-pane landing when the parent leg of a navigation fails', async () => {
const listDirectory = vi.fn(async (path?: string) => {
// The initial open lists home through the absent-path form; only the
// parent leg names HOME explicitly.
if (path === HOME) {
throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'parent gone', details: { path } })
}
return listingFor(path)
})
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 target listed fine; the failed parent leg neither blocks the
// landing nor surfaces an error for a level nobody asked to see.
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
expect(columns()).toHaveLength(1)
expect(screen.queryByRole('alert')).toBeNull()
})
it('opens the selection, else the listed level; Cancel closes; busy freezes Open', async () => {
const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
@@ -186,18 +404,221 @@ describe('DirectoryBrowser', () => {
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
expect(input.value).toBe(HOME)
// The editor seeds with a trailing separator so typing continues into
// child names.
expect(input.value).toBe(`${HOME}/`)
fireEvent.change(input, { target: { value: DOCS } })
fireEvent.keyDown(input, { key: 'Enter' })
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
expect(columns()).toHaveLength(1)
// Away from the root a navigation lands two-pane: the target selected
// in its parent level, its own children on the right.
await waitFor(() => { expect(columns()).toHaveLength(2) })
expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true')
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
// The submitted navigation unmounted the focused input; focus parks on
// the crumb edit zone that replaced it.
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' }))
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const again = screen.getByLabelText<HTMLInputElement>('browser.editPath')
fireEvent.change(again, { target: { value: ' ' } })
fireEvent.keyDown(again, { key: 'Enter' })
expect(b.listDirectory).toHaveBeenCalledTimes(2)
// Initial home + the DOCS target leg + its parent leg; the blank draft
// added none.
expect(b.listDirectory).toHaveBeenCalledTimes(3)
fireEvent.keyDown(again, { key: 'Escape' })
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
// Escape with focus in the input parks focus on the returning edit zone.
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' }))
})
it('prefix-filters the listed level from the draft tail, dot revealing hidden matches', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
// The seeded empty segment leaves the level as-is: hidden stays hidden.
expect(screen.getByRole('listitem').textContent).toBe('Documents')
// Case-insensitive prefix narrows the rows.
fireEvent.change(input, { target: { value: `${HOME}/do` } })
expect(screen.getByRole('listitem').textContent).toBe('Documents')
// A dot-led prefix names hidden entries, so it reveals the match.
fireEvent.change(input, { target: { value: `${HOME}/.co` } })
expect(screen.getByRole('listitem').textContent).toBe('.config')
// A prefix matching nothing empties the level (no stale rows linger).
fireEvent.change(input, { target: { value: `${HOME}/zzz` } })
expect(screen.queryByRole('listitem')).toBeNull()
// 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')
})
it('filters the child pane in two-pane mode and follows the draft back up a level', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(rowButton(screen.getByRole('listitem')))
await waitFor(() => { expect(columns()).toHaveLength(2) })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
// The seed comes from the selection, so the draft tail addresses the
// RIGHT pane (the selection's children).
expect(input.value).toBe(`${DOCS}/`)
fireEvent.change(input, { target: { value: `${DOCS}/h` } })
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
fireEvent.change(input, { target: { value: `${DOCS}/zzz` } })
expect(within(columns()[1]!).queryAllByRole('listitem')).toHaveLength(0)
expect(within(columns()[0]!).getByText('Documents')).toBeTruthy()
// Erasing back into the parent's own path moves the filter to the LEFT
// pane and releases the right one. The selected row is exempt (it
// anchors the two-pane view), so it alone survives the miss.
fireEvent.change(input, { target: { value: `${HOME}/zz` } })
expect(within(columns()[0]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents'])
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
})
it('keeps the draft and filter through window focus loss and in-dialog focus moves', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
fireEvent.change(input, { target: { value: `${HOME}/do` } })
// A blur while the document itself lost focus (window switch, dev-tools
// focus) must not discard the draft: value and filter both survive.
const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(false)
fireEvent.focusOut(input)
hasFocus.mockRestore()
expect(screen.getByLabelText<HTMLInputElement>('browser.editPath', { selector: 'input' }).value).toBe(`${HOME}/do`)
expect(screen.getByRole('listitem').textContent).toBe('Documents')
// A keyboard focus move that stays inside the dialog (Tab onto the
// filtered row) keeps the draft too — the results stay reachable.
fireEvent.focusOut(input, { relatedTarget: rowButton(screen.getByRole('listitem')) })
expect(screen.getByLabelText<HTMLInputElement>('browser.editPath', { selector: 'input' }).value).toBe(`${HOME}/do`)
// Toggling show-hidden mid-edit suppresses focus steal: the draft and
// its filter survive the toggle in both directions.
const toggle = screen.getByRole('button', { name: 'browser.showHidden' })
fireEvent.mouseDown(toggle)
fireEvent.click(toggle)
expect(toggle.getAttribute('aria-pressed')).toBe('true')
expect(screen.getByLabelText<HTMLInputElement>('browser.editPath', { selector: 'input' }).value).toBe(`${HOME}/do`)
expect(screen.getByRole('listitem').textContent).toBe('Documents')
// Focus landing outside the dialog cancels like Escape — even when the
// departure happens from a row the user had Tabbed onto, not the input
// (the observer lives on the card scope, not the input).
fireEvent.focusOut(rowButton(screen.getByRole('listitem')), { relatedTarget: document.body })
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
// Outside editing the card-scope observer is inert.
fireEvent.focusOut(screen.getByRole('button', { name: 'browser.showHidden' }))
expect(screen.getByRole('button', { name: 'browser.editPath' })).toBeTruthy()
})
it('Escape with focus on a filtered row collapses the editor, not the dialog', async () => {
const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
fireEvent.change(input, { target: { value: `${HOME}/do` } })
// Tab parked focus on the result row; Escape must still mean "leave
// path editing", not "close the whole dialog".
const row = rowButton(screen.getByRole('listitem'))
row.focus()
fireEvent.keyDown(row, { key: 'Escape' })
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
expect(b.onClose).not.toHaveBeenCalled()
// Focus was already on a surviving row, so nothing re-parks it.
expect(document.activeElement).toBe(row)
// With no draft left, Escape falls through to the Modal and closes.
fireEvent.keyDown(row, { key: 'Escape' })
expect(b.onClose).toHaveBeenCalledTimes(1)
})
it('a picked dot-revealed hidden row stays visible as the selection', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
fireEvent.change(input, { target: { value: `${HOME}/.co` } })
const row = rowButton(screen.getByRole('listitem'))
expect(row.textContent).toBe('.config')
fireEvent.mouseDown(row)
fireEvent.click(row)
// The pick cleared the draft (and with it the dot-reveal), but the
// selection is exempt from the hidden filter: the anchor row survives.
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
await waitFor(() => { expect(columns()).toHaveLength(2) })
expect(within(columns()[0]!).getByText('.config')).toBeTruthy()
})
it('picking a filtered row adopts it and closes the path editor', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
fireEvent.change(input, { target: { value: `${HOME}/do` } })
// The row suppresses focus steal on mousedown (no blur-cancel unmounts
// the filtered rows mid-gesture), then the click both selects the row
// and closes the editor.
const row = rowButton(screen.getByRole('listitem'))
fireEvent.mouseDown(row)
fireEvent.click(row)
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
// Focus parks on the picked row (the editor's input just unmounted and
// the Modal has no focus trap to catch a fall to body).
expect(document.activeElement).toBe(row)
await waitFor(() => { expect(columns()).toHaveLength(2) })
expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy()
})
it('a right-pane pick while editing parks focus on the advanced selection', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(rowButton(screen.getByRole('listitem')))
await waitFor(() => { expect(columns()).toHaveLength(2) })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
fireEvent.change(input, { target: { value: `${DOCS}/h` } })
// The advance replaces BOTH panes (the picked button's own column
// unmounts), so focus is re-parked on the selection's aria-current row
// in the freshly rendered left pane rather than the clicked node.
const row = rowButton(within(columns()[1]!).getByRole('listitem'))
fireEvent.mouseDown(row)
fireEvent.click(row)
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
await waitFor(() => { expect(document.activeElement?.textContent).toBe('harness') })
expect(document.activeElement?.getAttribute('aria-current')).toBe('true')
})
it('seeds and filters with backslashes on a Windows-rooted listing', async () => {
const ROOT = 'C:\\'
const windowsListing: DirectoryListing = {
path: ROOT,
home: ROOT,
crumbs: [{ name: 'C:\\', path: ROOT, hidden: false }],
entries: [
{ name: 'Program Files', path: `${ROOT}Program Files`, hidden: false },
{ name: 'Users', path: `${ROOT}Users`, hidden: false },
],
truncated: false,
}
mount({ listDirectory: vi.fn(async () => windowsListing) })
await waitFor(() => { expect(screen.getAllByRole('listitem')).toHaveLength(2) })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
// The root already ends in its separator: no doubled backslash.
expect(input.value).toBe(ROOT)
fireEvent.change(input, { target: { value: `${ROOT}u` } })
expect(screen.getByRole('listitem').textContent).toBe('Users')
})
it('clicking away from the path editor cancels it back to the crumb view', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
fireEvent.change(input, { target: { value: '/somewhere/else' } })
// Focus moving anywhere outside the editor abandons the draft like Escape.
fireEvent.focusOut(input)
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
// The crumb view is back and the abandoned draft was never navigated to.
expect(screen.getByRole('button', { name: 'browser.editPath' })).toBeTruthy()
expect(screen.getByRole('listitem').textContent).toBe('Documents')
})
it('restarts the home listing when Escape cancels an edit opened before any level listed', async () => {
@@ -713,11 +1134,13 @@ describe('DirectoryBrowser', () => {
b.listDirectory.mockReturnValueOnce(slow)
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
fireEvent.click(within(screen.getByRole('navigation')).getByRole('button', { name: 'Documents' }))
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
// The newer jump lands two-pane: Documents selected at home, children right.
await waitFor(() => { expect(within(columns()[1]!).getByText('harness')).toBeTruthy() })
resolveSlow(listingFor(undefined))
await new Promise(settle => setTimeout(settle, 0))
// The stale home listing did not replace the newer Documents level.
expect(screen.getByRole('listitem').textContent).toBe('harness')
// The stale home listing did not replace the newer Documents landing.
expect(columns()).toHaveLength(2)
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
})
it('names the create target by its path when the level reports no crumbs', async () => {