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 6855a0af2b..4342f49f2b 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: 9884385cf9e0d51604bab9e4fd3c4bee77448331 -2026-07-28-directory-picker-capability-seam.zh.md: 8c229b9fb08d5052ba8a512f2153a89a9e5fd455 +2026-07-28-directory-picker-capability-seam.md: 90aa8bc7cfc0dc0fb3d057b9991682c9b531ea23 +2026-07-28-directory-picker-capability-seam.zh.md: 12917c95456bca9cdd5e20ae97847156af81277e 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 9884385cf9..90aa8bc7cf 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 @@ -20,6 +20,7 @@ Placement and policy rulings folded into this decision: - **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 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. +- **The path editor advertises itself, and the panes follow the draft.** The click-to-edit zone is not invisible: a pencil glyph sits at the bar's right edge and hover/focus lights the zone in the editor's own footprint, so the one route into typing a path is discoverable and the bar does not resize when zone and input swap. While the editor is open the panes track the draft instead of whatever level happened to be listed when it opened — the final segment prefix-filters the level its directory part names, a tail nobody matches releases the filter (a name still being spelled must not empty the pane it is being spelled into), and a directory part no pane lists is scanned after a 250ms rest and lands single-wide in place, so typing deeper descends and erasing segments steps back up without leaving the editor. That scan is speculative — half-typed directories are unreadable most of the time — so a failure keeps the last readable panes and stays silent. Enter remains the authoritative commit: it owns the view from submission until landing (a debounce timer armed by the same keystrokes is held back rather than superseding the navigation, and a rejected submission stays held until the next edit) and it alone surfaces the failure. - **Navigation lands selection-anchored, quiet, and bounded.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the landing is two-pane: 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. Target and parent legs land as **one frame** when the parent leg settles within the 200ms wait bound — the stale view keeps rendering until then, so navigation swaps the panes without an intermediate single-pane flash — and past the bound the target commits alone at once (an Enter-submitted navigation is never held hostage by a stalled parent) with the late parent leg upgrading the landing in place. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent (Escape inside the landing window therefore withdraws the whole navigation); a failed parent leg, or a truncated parent window lacking the target, leaves the single-pane landing — the upgrade must never orphan the selection it exists to anchor. The loading indicator follows the same quiet rule: it floats over the content's bottom-right corner (never a layout-shifting row; the truncated/error rows own the bottom left and keep rendering through a scan) and only once a scan outlives a 300ms silence window, so a local listing swaps with nothing shown at all. Row picks are deliberately exempt from the one-frame rule: a pick's immediate pane split is its selected-state feedback (aria-current, crumbs following), while a navigation has nothing to acknowledge the click but the swap itself. Both timing constants are calibrated for local enumeration; a remote deployment (one RPC per level, commonly 100–400ms) would sit inside the silence window with no pressed state on the crumbs — revisit the window or add pressed feedback when a remote consumer lands. - **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. @@ -34,6 +35,9 @@ Placement and policy rulings folded into this decision: - **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 permanently visible path input above the Miller view.** Rejected: the breadcrumb is already the "where am I" reading, and a second always-present field duplicates it while costing a row of a 500px card that the columns need. The glyph plus the hover-lit zone puts the affordance on the bar that already answers the question. +- **Scanning the draft on every keystroke, or only on Enter.** Per keystroke: walking one path segment issues a listing per character, most of them for directories the operator is typing through, not at. Only on Enter (what shipped first): the panes and the typed text disagreed for the whole edit — the complaint this bullet answers. The 250ms rest keeps one scan per directory the typing actually settles on. +- **Emptying a pane on a prefix miss (what shipped first).** Rejected: mid-name the miss is the normal state, so the pane blanked exactly while the operator needed it to confirm the name; releasing the filter keeps the level readable and costs only the transient wideness. - **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 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 8c229b9fb0..12917c9545 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 @@ -20,6 +20,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **依赖调研(手写 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 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。 +- **路径编辑器自我点明,各栏跟随草稿。** 点击即编辑的区域不再是隐形的:栏右端坐着一枚铅笔图标,悬停/聚焦时该区以编辑器自身的轮廓亮起,于是键入路径这唯一入口可被发现,且区域与输入框互换时栏高不变。编辑器打开期间,各栏跟随草稿,而不是停在它打开那一刻恰好列出的层级——末段对其目录部分所指的层级做前缀过滤,无一匹配的末段解除过滤(还在拼写中的名字不该把正在拼写它的那一栏清空),而任何一栏都未列出的目录部分会在停顿 250ms 后被扫描、以单宽栏就地落地,于是继续键入即下潜、删掉末段即上退,全程不必离开编辑器。该扫描是推测性的——键入到一半的目录多数时候读不出来——因此失败时保留最后一次可读的分栏并保持沉默。Enter 仍是权威提交:自提交至落地由它独占视图(同一批按键武装的防抖计时器会被扣住,而不是顶掉这次导航;提交被拒后仍扣住,直到下一次编辑),也只有它把失败呈现出来。 - **导航以选中项为锚、安静且有界地落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),落地即双栏:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在 200ms 等待上限内落定时,目标与父层级两程以**同一帧**落地——在此之前陈旧视图持续渲染,导航换栏时因此没有中间的单栏闪现——超出该上限则目标即刻单独提交(Enter 提交的导航绝不会被滞塞的父层级扣作人质),迟到的父层级这一程再就地升级这次落地。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止(因此在落地窗口内按 Escape 即撤回整次导航);父层级这一程失败,或被截断的父窗口缺少目标时,都保留单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。加载指示器遵循同一安静规则:它浮于内容右下角(绝不是会挪动布局的一行;截断/错误行占据左下角,并在扫描期间持续渲染),且仅在扫描超出 300ms 静默窗口后才出现,因此本地列举切换时什么也不显示。行选取被刻意豁免于同一帧规则:选取后立即分栏本身就是其选中态反馈(aria-current、crumb 跟随),而导航除了换栏本身没有任何东西可确认这次点击。两个时序常量都按本地列举校准;远程部署(每层级一次 RPC,通常 100–400ms)会落在静默窗口之内、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 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 @@ -34,6 +35,9 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **引入文件管理器/盘符枚举依赖。** 按上文调研否决;依赖政策要求记录于此。 - **动作标签随状态翻转的"显示隐藏"开关("隐藏隐藏文件")。** 否决:会翻转的动作标签在状态与动作之间有歧义,还把否定叠了两层;固定标签加按下态呈现一次说清两者。 - **纯 relatedTarget 失焦取消(不做 mousedown 抑制)。** 否决:Safari 在指针按下时不给按钮聚焦,点击触发的 focusout 因而携带空 `relatedTarget`,会在点击落地前就取消编辑器;编辑期作用的 mousedown 抑制加上锚定卡片的 relatedTarget 守卫才能同时覆盖指针与键盘路径。 +- **在 Miller 视图上方常驻一个路径输入框。** 否决:面包屑本就在回答"我在哪儿",再常驻一个字段是重复回答,还要从 500px 卡片里挪走一行——那是列需要的高度。图标加悬停亮起的区域,把这个入口放在了已经回答该问题的那一栏上。 +- **每敲一个键就扫描草稿,或只在 Enter 时扫描。** 每键扫描:走完一段路径就是每个字符一次列举,其中多数目录操作者只是路过而非停留。只在 Enter 时扫描(最初落地的行为):整个编辑过程中各栏与所键入文本各说各话——正是本条所回应的抱怨。250ms 的停顿把扫描收敛为"键入真正停下来的每个目录一次"。 +- **前缀无一匹配时清空该栏(最初落地的行为)。** 否决:名字敲到一半时"无匹配"才是常态,于是恰恰在操作者需要它确认名字时把栏清空了;解除过滤保住了层级的可读性,代价只是短暂的宽松。 - **在 `DirectoryListing` 上增设线上 `separator` 字段(宿主标注 `path.sep`)。** 延期而非否决:它才是权威形态——含反斜杠的 POSIX 家目录会击穿 `listing.home` 启发式——但它触及 seam 类型与每个后端;browse 客户端的 `separatorOf` 挂着指向本方案的 TODO,直到下次安排线上变更。 ## 后果 diff --git a/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md b/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md index baaaa6f3dc..47957dab82 100644 --- a/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md +++ b/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md @@ -4,7 +4,8 @@ - button "Home" - img - button "browse-golden" - - button "Edit path" + - button "Edit path": + - img - list: - listitem: - button "adopted": diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 1ffcf6f490..1a5531f59a 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -1,6 +1,7 @@ // Web e2e scenarios: workspace management — adding a workspace through the // composed directory dialog (its own New folder affordance is the product's -// one creation route), same-basename directory adoption, the rename round +// one creation route), the dialog's path editor walking the panes with the +// typed draft, same-basename directory adoption, the rename round // trip over the real wire (workspace.rename RPC + durable registry), the // duplicate-name pre-check, the // flat "In one list" view with its persisted group-by preference, the session @@ -12,7 +13,7 @@ // seeded-history seed reused verbatim — no new recording). import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' -import { join } from 'node:path' +import { join, sep } from 'node:path' import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' @@ -403,6 +404,36 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('walks the panes with the typed path: deeper past a separator, back up on erase, whole on a miss', async () => { + // The panes must track the draft without leaving the editor, so the + // typed text and what is listed under it never disagree. + const staged = join(scaffold.workspaceCwd, 'browse-golden') + await mkdir(join(staged, 'alpha', 'only-under-alpha'), { recursive: true }) + const dialog = await browseTo(staged) + await expect.poll(() => dialog.getByText('alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + await dialog.getByRole('button', { name: 'Edit path' }).click() + const path = dialog.getByLabel('Edit path') + // A directory part no pane lists: the panes follow it and keep the editor. + await path.fill(`${join(staged, 'alpha')}${sep}`) + await expect.poll(() => dialog.getByText('only-under-alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + // The editor is still up with the draft intact: the panes moved under it. + expect(await path.inputValue()).toBe(`${join(staged, 'alpha')}${sep}`) + // Erasing back past the separator steps the panes up, the tail filtering + // the level it returns to. + await path.fill(`${staged}${sep}al`) + await expect.poll(() => dialog.getByText('alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + expect(await dialog.getByText('beta', { exact: true }).count()).toBe(0) + expect(await dialog.getByText('only-under-alpha', { exact: true }).count()).toBe(0) + // A tail nobody matches is a name still being spelled: the level shows + // whole instead of emptying under it. + await path.fill(`${staged}${sep}zzz`) + await expect.poll(() => dialog.getByText('beta', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + expect(await dialog.getByText('alpha', { exact: true }).count()).toBe(1) + await dialog.getByRole('button', { name: 'Cancel' }).click() + await dialog.waitFor({ state: 'hidden', timeout: 10_000 }) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + /** * Expand Ungrouped and return its seeded session row. The only visible child * is the non-blank persisted Session; the blank Session created while diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 673d053e3a..4063c7d692 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/README.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 packages/host/directory-picker-browse/README.md -README.md: 52b5fe7e89f915be3b50324628e9d5c48f1ef94c -README.zh.md: 742da39470083887a71ddba4a7c8012f0ce0ea1f +README.md: 7cb0ec785766e954ff4bb39df6825ee7e8c9d821 +README.zh.md: ec71a90bbcd004ec9f9c0d8a9a236882a7487b73 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 52b5fe7e89..7cb0ec7857 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -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 whose navigations land selection-anchored and quiet: the previous view keeps rendering while a crumb jump or a submitted path is scanned (a "Loading…" pill floats over it only once the scan outlives a 300ms silence window, never shifting the columns), then target and parent legs land as one two-pane frame with the target re-selected as its actual parent-level entry — so stepping back never collapses and no intermediate frame flashes (a parent leg outliving its 200ms wait bound lands the target alone and upgrades in place; 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). +**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 and quiet: the previous view keeps rendering while a crumb jump or a submitted path is scanned (a "Loading…" pill floats over it only once the scan outlives a 300ms silence window, never shifting the columns), then target and parent legs land as one two-pane frame with the target re-selected as its actual parent-level entry — so stepping back never collapses and no intermediate frame flashes (a parent leg outliving its 200ms wait bound lands the target alone and upgrades in place; 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, advertised by the pencil glyph at the bar's right edge and lit on hover in the editor's own footprint, whose editor seeds a trailing separator and then keeps the panes under the draft: the final segment prefix-filters the level its directory part names (case-insensitively, over the listed — possibly truncated — rows only; a tail nobody matches releases the filter instead of emptying the pane), while a directory part no pane lists is scanned after a 250ms rest and shown in place, so typing deeper descends and erasing segments steps back up without leaving the editor — a speculative scan is silent when it fails, and Enter still navigates by the exact text, owning the view until it lands; the editor 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 every filter; 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 diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 742da39470..ec71a90bbc 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -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 双列视图,其导航以选中项为锚、安静落地:扫描 crumb 跳转或提交的路径期间,先前视图持续渲染("Loading…" 胶囊仅在扫描超出 300ms 静默窗口后才浮于其上,绝不挪动各列),随后目标与父层级两程以单个双栏帧落地,目标被重新选中为其在父层级中的实际条目——因此后退绝不塌缩,也没有中间帧闪现(父层级这一程超出其 200ms 等待上限时,目标单独落地,随后就地升级;父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;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)。 +**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚、安静落地:扫描 crumb 跳转或提交的路径期间,先前视图持续渲染("Loading…" 胶囊仅在扫描超出 300ms 静默窗口后才浮于其上,绝不挪动各列),随后目标与父层级两程以单个双栏帧落地,目标被重新选中为其在父层级中的实际条目——因此后退绝不塌缩,也没有中间帧闪现(父层级这一程超出其 200ms 等待上限时,目标单独落地,随后就地升级;父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,该区由栏右端的铅笔图标点明、悬停时以编辑器自身的轮廓亮起,其编辑器预填尾随分隔符,随后让下方各栏跟随草稿:末段对其目录部分所指的层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;无一匹配的末段会解除过滤,而不是把该栏清空),而任何一栏都未列出的目录部分会在停顿 250ms 后被扫描并就地展示,于是继续键入即下潜、删掉末段即上退,全程不必离开编辑器——推测性扫描失败时保持沉默,而 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)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index 2f207e4195..eaad4d46d8 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -53,7 +53,8 @@ display: flex; align-items: center; gap: 4px; - min-height: 20px; + /* The path editor's height: crumb mode and edit mode occupy the same bar. */ + min-height: 24px; } /* Deep chains scroll inside the trail (the effect pins the tail into view) @@ -118,17 +119,52 @@ color: var(--dsw-alias-label-tertiary); } -/* The empty remainder of the bar: invisible, but a real click target that - * flips the bar into path-edit mode. */ +/* The empty remainder of the bar: a real click target that flips the bar + * into path-edit mode. The zone itself stays flush with the crumbs; the + * pencil glyph seated at its right edge is the standing affordance, and + * hover/focus lights the zone in the editor's own rounded shape so the + * gesture reads before the click. */ .crumbEditZone { + display: flex; + align-items: center; + justify-content: flex-end; flex: 1 0 34px; min-width: 34px; - align-self: stretch; - border: none; + /* The editor's own height, so hover previews the input's exact footprint + * and the bar does not resize when the two swap. */ + height: 24px; + padding: 0 6px; + border: 1px solid transparent; + border-radius: 8px; background: transparent; cursor: text; } +.crumbEditZone:hover, +.crumbEditZone:focus-visible { + border-color: var(--dsw-alias-border-l2); + outline: none; +} + +.crumbEditGlyph { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.crumbEditZone:hover .crumbEditGlyph, +.crumbEditZone:focus-visible .crumbEditGlyph { + color: var(--dsw-alias-label-primary); +} + +.crumbEditZone:disabled { + border-color: transparent; + cursor: default; +} + +.crumbEditZone:disabled .crumbEditGlyph { + color: var(--dsw-alias-label-caption); +} + .pathInput { box-sizing: border-box; flex: 1 1 0; diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index f5510fda7c..1f9903952d 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -18,15 +18,20 @@ * owning flow decides what "Open" means and owns the workspace-creation * 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). + * on) reveals them (client-side only). The path editor announces itself with + * a pencil glyph and a hover-lit zone, opens seeded with a trailing + * separator, and keeps the panes under the draft: the final segment + * prefix-filters the level its directory part names (a dot-led prefix also + * reveals the hidden entries it names, and a prefix nobody matches releases + * the filter), while a directory part no pane lists is scanned after a short + * debounce and shown in place — so typing deeper descends and erasing + * segments steps back up without leaving the editor. */ import { useCallback, useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { - Button, IconCheckOutline16, IconChevronRightOutline14, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, Modal, + Button, IconCheckOutline16, IconChevronRightOutline14, IconEditOutline16, 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' @@ -75,6 +80,15 @@ const SLOW_SCAN_DELAY_MS = 300 */ const PARENT_LEG_WAIT_MS = 200 +/** + * How long a typed draft rests before the panes follow it to a directory no + * pane lists. The window absorbs the keystrokes that walk through + * intermediate directory parts (every character of `/usr/lo` past the + * separator would otherwise be its own scan) while staying short enough that + * a pause reads as "the list moved with me". + */ +const DRAFT_PREVIEW_DEBOUNCE_MS = 250 + /** * 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 @@ -100,21 +114,84 @@ function separatorOf(listing: DirectoryListing): '\\' | '/' { return listing.home.includes('\\') ? '\\' : '/' } +/** The listed level as a directory part: its own path, separator-terminated (the root already is). */ +function levelDirectory(listing: DirectoryListing): string { + const sep = separatorOf(listing) + return listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}` +} + +/** + * The draft's directory part — everything through its last separator — or + * null while no separator has been typed at all (nothing addresses a + * directory yet). The platform separator comes from `listing`, so the caller + * passes any listing of the host's filesystem. + */ +function draftDirectory(listing: DirectoryListing, draft: string): string | null { + const cut = draft.lastIndexOf(separatorOf(listing)) + return cut === -1 ? null : draft.slice(0, cut + 1) +} + /** * 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. + * exactly (it is the host's own path text, reached by seeding, erasing, or a + * draft-following scan); 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 + const directory = draftDirectory(listing, draft) + if (directory === null) return null + return directory === levelDirectory(listing) ? draft.slice(directory.length) : null +} + +/** + * The directory a draft addresses that no rendered pane lists — the level the + * editor must scan for the panes to keep following the typed path. Null when + * a pane already lists it (the prefix filter alone answers the draft), when + * no separator has been typed yet, and when no level is listed at all: the + * platform separator is read off a listing, so the editor's + * failed-home-listing recovery path types blind until Enter. + */ +function pendingPreviewDirectory( + parent: DirectoryListing | null, + child: DirectoryListing | null, + draft: string | null, +): string | null { + if (parent === null || draft === null) return null + const directory = draftDirectory(parent, draft) + if (directory === null || directory === levelDirectory(parent)) return null + if (child !== null && directory === levelDirectory(child)) return null + return directory +} + +/** + * The rows one column renders. The selection is exempt from every filter: 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. A prefix narrows the level only while some row matches it — a + * tail nobody matches is a name being spelled, not a demand for an empty + * pane, so the level shows whole (and its hidden rows return to obeying the + * toggle, the dot-led reveal included). + */ +function visibleEntries( + entries: readonly DirectoryEntry[], + selectedPath: string | null, + showHidden: boolean, + filterPrefix: string | null, +): readonly DirectoryEntry[] { + const needle = filterPrefix === null ? '' : filterPrefix.toLowerCase() + const matches = (entry: DirectoryEntry): boolean => entry.name.toLowerCase().startsWith(needle) + const narrowing = needle !== '' && entries.some(matches) + // A dot-led prefix names hidden entries explicitly, so matching ones + // surface even while the toggle keeps the rest hidden. + const revealHidden = narrowing && needle.startsWith('.') + return entries.filter((entry) => { + if (entry.path === selectedPath) return true + if (narrowing && !matches(entry)) return false + return showHidden || !entry.hidden || revealHidden + }) } /** One column of folder rows (the Miller view renders one or two of these). */ @@ -127,16 +204,7 @@ function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPr 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 - }) + const visible = visibleEntries(entries, selectedPath, showHidden, filterPrefix) return (