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 bc62880e58..900e1d01b6 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: 6768ed8f898765396d6a4549a661674b5336c1f3 -2026-07-28-directory-picker-capability-seam.zh.md: f20ea0af278151cb6f9ebd963185701cc3f3f2fd +2026-07-28-directory-picker-capability-seam.md: ad2aa904beddb2fe941883c3c1827702dbec9964 +2026-07-28-directory-picker-capability-seam.zh.md: 30e719ad9b4e8374496106b447e961a042c7d8b6 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 6768ed8f89..ad2aa904be 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -19,10 +19,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 show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. -- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are read from the host-resolved root crumb (exact for every root form this backend emits: `/`, `C:\`, `\\server\share\`); the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. Focus parking is a card-wide invariant, not an editor-only one: every pick — editing or not, including right-pane advances and create landings whose columns are replaced — re-parks focus on the selection's row after commit, while every other displacing exit (Enter, Escape, a navigation landing whose new level dropped the focused row, a failed pick or create relist, and the nested create dialog closing) falls back to the crumb edit zone whenever focus actually fell to body, and a show-hidden toggle click that finds focus among the rows parks synchronously on the toggle itself. The guarantee is scoped to the dialog's own node replacements — the Modal has no focus trap, so tabbing past the card's edge legitimately leaves, and the owner's adopt window (where `busy` inerts every control and the dialog is closing either way) is likewise outside it. -- **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. Known boundaries of the progressive shape: a pointer press landing exactly inside the one-RTT upgrade window can lose its click when the pressed row node is replaced (keyboard focus is re-parked on the re-selected row; the pointer window is accepted); on slash platforms (macOS) only a final-segment case drift misses the parent-entry match and keeps the single-pane landing, while ancestor-segment drift still matches — parent entry paths inherit the typed prefix — and lands two panes at the cost of the Home collapse; and navigate always relists both legs even when the target is the currently shown level — a crumb tap doubles as the refresh gesture, so freshness wins over reusing possibly-stale in-hand listings at the cost of up to two host scans. +- **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. -- **One canonical path shape on the wire.** A listing's `path`, `crumbs[].path`, `entries[].path`, and `home` — and `createDirectory`'s returned path, which clients compare verbatim against the child's next `entries[].path` to anchor the create landing — all ship host-resolved: lexical `resolve()`, never realpath (the symlink ruling above keeps ancestries logical), with homedir() output included since the environment may decorate HOME and the backend resolves it before stamping. Clients compare listing paths verbatim on that promise; the only lexical mirror left in the browse client serves the draft side, the one path a user types and hence naturally non-canonical. Normalizing at the source replaces a client-side mirror of resolve() that had to anticipate every decoration (trailing and repeated separators, dot segments, UNC roots, forward slashes), and the promise binds every browse backend. - **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. - **The native backend stays.** Plugin-form was the point: multiple providers can serve the seam (an Electron shell would provide the `native` interaction through its own dialog API). Kind naming: `dialog` was the first pick and was dropped — the browse interaction also presents a dialog (the in-app modal), so the word failed to discriminate; `native` names where the chooser runs. @@ -35,7 +34,7 @@ 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 wire `separator` field on `DirectoryListing` (host stamps `path.sep`).** Deferred, not rejected: it is the authoritative form. The root-crumb read the client uses today is exact for every root shape this backend emits, but it still infers a platform fact from path text and promotes "the chain starts at the root" from backend behavior into a client-relied invariant (the `crumbs` JSDoc does promise it), and it degrades to the old home-text heuristic on an empty chain; a wire field would travel verbatim and survive empty chains and future backends. It touches the seam type and every backend, so the browse client's `separatorOf` carries a TODO pointing at this alternative until a wire change is next scheduled. +- **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 f20ea0af27..30e719ad9b 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -19,10 +19,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 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 -- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从宿主解析的根 crumb 读取(对该后端发出的每种根形态都精确:`/`、`C:\`、`\\server\share\`);下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。焦点停靠是卡片全域的不变量,而非编辑器独有:每次选取——无论是否处于编辑态,包括右栏推进与各列被替换的创建落地——提交后都把焦点重新停靠到选中项所在的行上,而其余所有会顶离焦点的退出(Enter、Escape、新层级已不含焦点所在行的导航落地、选取或创建失败后的重新列举,以及嵌套创建对话框的关闭)只要焦点确实落到了 body 上,就回落到 crumb 编辑区,而点击"显示隐藏"开关时若发现焦点落在行间,则把焦点同步停靠到开关自身。该保证的范围仅限对话框自身的节点替换——Modal 没有焦点陷阱,所以 Tab 越过卡片边缘属于正当离开,而 owner 的接纳窗口(其间 `busy` 把每个控件置为惰性,且对话框反正正在关闭)同样在此范围之外。 -- **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。渐进形态的已知边界:指针按压若恰好落在单个 RTT 的升级窗口内,可能因所按行节点被替换而丢失点击(键盘焦点会重新停靠到重新选中的行上;指针的这段窗口则被接受);斜杠平台(macOS)上仅末段的大小写偏差会错过父层级条目匹配,保留单栏落地,而祖先段的偏差仍能匹配——父层级条目路径继承键入的前缀——并落地双栏,代价是 Home 塌缩;且导航总是重新列举两程,哪怕目标就是当前展示的层级——crumb 点按兼作刷新手势,因此宁要新鲜度也不复用手头可能已陈旧的列举,代价是至多两次宿主扫描。 +- **路径编辑器的取消范围:对话框卡片。** 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 规范化。 -- **线上只有一种规范路径形态。** 列举的 `path`、`crumbs[].path`、`entries[].path` 与 `home`——连同 `createDirectory` 返回的路径,客户端拿它与该子项下一次的 `entries[].path` 逐字比较以锚定创建落地——一律以宿主解析后的形态发出:词法 `resolve()`,绝不做 realpath(上文的符号链接裁决保持祖先链为逻辑路径);homedir() 的输出也不例外,因为环境可能修饰 HOME,后端在标注前先行解析。客户端凭这一承诺逐字比较列举路径;browse 客户端仅剩的词法镜像服务于草稿一侧——用户键入的那一条路径,因而天然非规范。在源头做规范化,取代了客户端侧那份必须预判每种修饰(末尾与重复的分隔符、点段、UNC 根、正斜杠)的 resolve() 镜像,且这一承诺约束每一个 browse 后端。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 - **native 后端保留。** 插件化正是目的:多方都能提供该 seam(Electron 壳可以经自己的对话框 API 提供 `native` 交互)。kind 命名:最初选了 `dialog` 后被放弃——browse 交互同样以对话框呈现(应用内弹窗),这个词起不到判别作用;`native` 命名的是选择器运行的位置。 @@ -35,7 +34,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **引入文件管理器/盘符枚举依赖。** 按上文调研否决;依赖政策要求记录于此。 - **动作标签随状态翻转的"显示隐藏"开关("隐藏隐藏文件")。** 否决:会翻转的动作标签在状态与动作之间有歧义,还把否定叠了两层;固定标签加按下态呈现一次说清两者。 - **纯 relatedTarget 失焦取消(不做 mousedown 抑制)。** 否决:Safari 在指针按下时不给按钮聚焦,点击触发的 focusout 因而携带空 `relatedTarget`,会在点击落地前就取消编辑器;编辑期作用的 mousedown 抑制加上锚定卡片的 relatedTarget 守卫才能同时覆盖指针与键盘路径。 -- **在 `DirectoryListing` 上增设线上 `separator` 字段(宿主标注 `path.sep`)。** 延期而非否决:它才是权威形态。客户端今天所用的根 crumb 读取对该后端发出的每种根形态都精确,但它仍是从路径文本推断平台事实,还把"链从根开始"从后端行为提升为客户端所依赖的不变量(`crumbs` 的 JSDoc 确实承诺了这一点),并在链为空时退化回旧的 home 文本启发式;线上字段则会原样随线传输,经得住空链与未来的后端。它触及 seam 类型与每个后端,因此 browse 客户端的 `separatorOf` 挂着指向本方案的 TODO,直到下次安排线上变更。 +- **在 `DirectoryListing` 上增设线上 `separator` 字段(宿主标注 `path.sep`)。** 延期而非否决:它才是权威形态——含反斜杠的 POSIX 家目录会击穿 `listing.home` 启发式——但它触及 seam 类型与每个后端;browse 客户端的 `separatorOf` 挂着指向本方案的 TODO,直到下次安排线上变更。 ## 后果 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index ea109431ea..e0dd17fa02 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -500,7 +500,7 @@ Abstract directory-picking service. Subclass, implement `capability()`, and load abstract capability(): DirectoryPickerCapability ``` -Source: [`packages/host/directory-picker/src/index.ts:144`](../../packages/host/directory-picker/src/index.ts) +Source: [`packages/host/directory-picker/src/index.ts:131`](../../packages/host/directory-picker/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 22113e7fdb..be9ba79347 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1016,10 +1016,6 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { // same tree the browse primitives serve). pickDirectory: request => ok(request, { path: `${FIXTURE_HOME}/Documents/project` }), listDirectory: (request) => { - // The fixture accepts CANONICAL paths only: a decorated input - // (./, //, ..) misses the tree map and reads as unreadable, where - // the real backend resolve()s it first. The keyless lanes drive - // canonical paths, so the divergence stays out of transcripts. const target = request.payload.path ?? FIXTURE_HOME const children = childrenOf(target) if (children === undefined) { diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index 2b6f5f4d34..9238ea5fd0 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -48,10 +48,7 @@ export interface IWorkspaces { * Create one child directory through the Host's `browse` capability. * @param path - absolute existing parent directory. * @param name - single non-blank path segment. - * @returns the created directory's absolute path, in the shape the wire - * `HostApi.createDirectory` contracts: verbatim equal to the child's - * `entries[].path` in the parent's next listing (the browser anchors a - * create landing's selection on that equality). + * @returns the created directory's absolute path. */ createDirectory(path: string, name: string): Promise /** diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index df7f69a19b..1dd3319e79 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -208,8 +208,7 @@ export class WorkspacesService implements IWorkspaces { * Create one child directory through the Host's `browse` capability. * @param path - absolute existing parent directory. * @param name - single non-blank path segment. - * @returns the created directory's absolute path, in the shape - * `IWorkspaces.createDirectory` contracts. + * @returns the created directory's absolute path. */ async createDirectory(path: string, name: string): Promise { const response = await this.api.host.createDirectory({ path, name }) diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index 75779f266b..6c1a9d0aad 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -142,18 +142,13 @@ export class TestWorkspaces implements IWorkspaces { * Browse child creation (recorded). The default joins parent and name. * @param path - absolute existing parent directory. * @param name - single path segment. - * @returns the created directory's absolute path, in the shape - * `IWorkspaces.createDirectory` contracts. + * @returns the created directory's absolute path. */ async createDirectory(path: string, name: string): Promise { this.calls.push({ method: 'createDirectory', args: [path, name] }) const stub = this.stubs.get('createDirectory') if (stub !== undefined) return await (stub(path, name) as Promise) - // Join in the parent's own separator flavor (a canonical parent ends - // with one only when it is a bare root), so the contract's verbatim - // equality holds for POSIX and Windows fixture trees alike. - const sep = path.includes('\\') ? '\\' : '/' - return path.endsWith(sep) ? `${path}${name}` : `${path}${sep}${name}` + return `${path}/${name}` } /** diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index dcd4bfaadf..b170f69ba2 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -329,21 +329,12 @@ describe('workspaces', () => { await expect(runtime.workspaces.listDirectory()).resolves.toMatchObject({ path: '/home/test', entries: [] }) await expect(runtime.workspaces.listDirectory('/home/test')).resolves.toMatchObject({ path: '/home/test' }) await expect(runtime.workspaces.createDirectory('/home/test', 'fresh')).resolves.toBe('/home/test/fresh') - // Canonical join in the parent's own separator flavor: bare roots do - // not double the separator, Windows parents keep backslashes (the - // IWorkspaces contract's verbatim entries[].path equality). - await expect(runtime.workspaces.createDirectory('/', 'top')).resolves.toBe('/top') - await expect(runtime.workspaces.createDirectory('C:\\', 'top')).resolves.toBe('C:\\top') - await expect(runtime.workspaces.createDirectory('C:\\Users', 'Alice')).resolves.toBe('C:\\Users\\Alice') // The recorded signal seat mirrors the production face (undefined here; // cancellation tests pass and observe a real one). expect(runtime.workspaces.calls).toEqual([ { method: 'listDirectory', args: [undefined, undefined] }, { method: 'listDirectory', args: ['/home/test', undefined] }, { method: 'createDirectory', args: ['/home/test', 'fresh'] }, - { method: 'createDirectory', args: ['/', 'top'] }, - { method: 'createDirectory', args: ['C:\\', 'top'] }, - { method: 'createDirectory', args: ['C:\\Users', 'Alice'] }, ]) // Stubs replace the defaults like every sibling method. const listing = { path: '/x', home: '/x', crumbs: [], entries: [] } diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index 19e16bf628..3d0713e523 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -15,19 +15,11 @@ export interface DirectoryEntry { hidden: boolean } -/** - * host.listDirectory response value: one directory level plus its ancestry. - * Every path in one listing — `path`, `crumbs[].path`, `entries[].path`, - * and `home` — is host-resolved canonical form: no `.`/`..` segments, no - * repeated or trailing separators (bare roots `/`, `C:\`, `\\server\share\` - * excepted), one platform separator. Resolution is lexical (`resolve()`), - * never realpath: a symlinked ancestry keeps the logical path the operator - * navigated. Clients compare paths on this promise without re-normalizing. - */ +/** host.listDirectory response value: one directory level plus its ancestry. */ export interface DirectoryListing { /** Absolute path of the listed directory. */ path: string - /** The host account's home directory (breadcrumb "Home" rooting), in the interface's canonical shape like every other path here. */ + /** The host account's home directory (breadcrumb "Home" rooting). */ home: string /** * Ancestor chain from the filesystem root to the listed directory @@ -83,9 +75,7 @@ export interface HostApi { * Create one child directory under an existing parent (the browser's * "New folder"). Only served under the `browse` capability; an existing * child fails with `directory-exists`, every other filesystem failure with - * `directory-create-failed`. The returned path is in the listing - * contract's canonical shape — verbatim equal to the child's - * `entries[].path` in the parent's next listing. + * `directory-create-failed`. */ createDirectory( request: RpcRequest<{ path: string; name: string }>, diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 2ab8b5a667..d807bd737a 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: d6ed7181ffbec85d11e0abf2aa8d0053173ba4e9 -README.zh.md: 67f5f2bc40297d96bc3fcd3cfba0a3fd26855adc +README.md: 23153881b84dcb71dfb05d4f297a5818c410ca77 +README.zh.md: d7010e2941a801ba6358082824330eaae46e42b7 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index d6ed7181ff..23153881b8 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -19,6 +19,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Windows hidden attribute is not read** — Node dirents do not expose `FILE_ATTRIBUTE_HIDDEN`, so `hidden` means dot-prefixed on every platform until a native probe is worth its cost. -- **Name-normalizing volumes void the create-path equality** — `createDirectory` promises its return verbatim-equal to the child's next `entries[].path`; Node's namespaced Win32 paths store even trailing-dot/space segments literally, but a volume that rewrites names on storage (NFD normalization on HFS+-style volumes) breaks the match, and the create landing degrades to a two-pane view whose left pane lacks the aria-current row while focus falls back to the crumb edit zone. - **No drive-root enumeration** — on Windows the ancestry stops at the drive root; crossing drives waits for the browser UI's path-entry affordance rather than an enumeration primitive here. - **Whole-filesystem scope** — no per-deployment browse-root restriction; `workspace.create` accepts arbitrary paths today, so a root here would be UX scoping, not a boundary — deferred until a deployment needs it. diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 67f5f2bc40..d7010e2941 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -19,6 +19,5 @@ ## 已知限制与延期工作 - **不读取 Windows 隐藏属性**——Node 的 dirent 不暴露 `FILE_ATTRIBUTE_HIDDEN`,因此在所有平台上 `hidden` 都意味着点前缀,直到原生探测值回其成本为止。 -- **名称规范化的卷会使创建路径等式失效**——`createDirectory` 承诺其返回值与该子项下一次的 `entries[].path` 逐字相等;Node 带命名空间的 Win32 路径连末尾点/空格段都按字面存储,但在存储时改写名称的卷(HFS+ 风格卷上的 NFD 规范化)会破坏这一匹配,创建落地随之退化为左栏缺少 aria-current 行的双栏视图,同时焦点回落至 crumb 编辑区。 - **不枚举盘符根**——Windows 上祖先链止于盘符根;跨盘依赖浏览器 UI 的路径输入入口,而不是这里的枚举原语。 - **全盘可浏览**——没有按部署限定的浏览根;`workspace.create` 今天就接受任意路径,这里的根只会是 UX 范围而非边界——等到有部署需要时再做。 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 8bd044f610..85349962e5 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -52,6 +52,24 @@ /* Deep chains scroll inside the trail (the effect pins the tail into view) * so the edit zone to the right never leaves the bar. */ +/* The Miller columns keep their own row so a status/error line below never + * competes with the fixed column widths for horizontal space. */ +/* A narrow viewport shrinks the dialog below two fixed panes; the row + * scrolls horizontally (the effect pins the child pane into view) so + * descent never hides behind the Modal's clipping. */ +.millerRow { + display: flex; + align-items: stretch; + flex: 1 1 0; + min-height: 0; + /* 12px of row gap on each side of the divider; the left side reads wider + * by the column's trailing 8px scrollbar clearance, which is deliberate — + * the thumb needs that room, the right pane's rows do not. */ + gap: 12px; + overflow-x: auto; + scrollbar-width: none; +} + .crumbTrail { display: flex; align-items: center; @@ -62,12 +80,6 @@ scrollbar-width: none; } -/* Pseudo-element-path engines (see .millerRow's twin rule): the 20px crumb - * bar has no room for a bar at all. */ -.crumbTrail::-webkit-scrollbar { - display: none; -} - .crumbSeat { display: inline-flex; align-items: center; @@ -134,36 +146,11 @@ flex-direction: column; flex: 1 1 0; min-height: 0; - /* Right inset is slimmer than the left: the trailing column's own - * scrollbar clearance (see .column) makes up the optical difference. */ + /* Right inset is slimmer than the left: the trailing column's own 8px + * scrollbar clearance makes up the optical difference. */ padding: 16px 16px 16px 24px; } -/* The Miller columns keep their own row so a status/error line below never - * competes with the fixed column widths for horizontal space. */ -/* A narrow viewport shrinks the dialog below two fixed panes; the row - * scrolls horizontally (the effect pins the child pane into view) so - * descent never hides behind the Modal's clipping. */ -.millerRow { - display: flex; - align-items: stretch; - flex: 1 1 0; - min-height: 0; - /* 12px of row gap on each side of the divider; the left side reads wider - * by the column's trailing scrollbar clearance (see .column) — the thumb - * needs that room, the right pane's rows do not. */ - gap: 12px; - overflow-x: auto; - scrollbar-width: none; -} - -/* Engines that predate scrollbar-width take the pseudo-element path (the - * two are mutually exclusive by construction — see ui-theme's scrollbar - * contract); hide the row's horizontal bar there too. */ -.millerRow::-webkit-scrollbar { - display: none; -} - /* Columns split the row evenly around the divider (a solo column takes the * whole row); 256px is the floor below which the row scrolls (scrollbar * hidden, the effect pins the child pane into view) instead of squeezing @@ -305,12 +292,6 @@ color: var(--dsw-alias-label-primary); } -/* Flex-none: the nowrap label refuses to shrink, which would leave the - * glyph as the only compressible item under wrap or narrow-viewport clamp. */ -.toggleCheck { - flex: none; -} - .footerGap { flex: 1 1 0; } diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index f0f153ecc6..859667d115 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -2,20 +2,16 @@ * The in-app workspace-directory browser (figma Harness 813-23126 family): a * 680×500 dialog (clamped to short/narrow viewports — the Miller row scrolls * sideways, the columns scroll down) whose header carries the title, the selection-path - * breadcrumb, and a click-to-edit path zone; below it a Miller view of one - * or two columns splitting the row evenly (256px floor; level | selected - * folder's children) around a hairline divider — the display root and - * degraded landings keep the single wide level, while any selection opens - * the second pane, including the one a navigation lands with: a crumb jump - * or a submitted path commits the target immediately, then re-selects it - * in its parent level once that level arrives, so stepping back keeps two - * panes away from the display root. Selecting in the + * breadcrumb, and a click-to-edit path zone; below it a Miller view — one + * full-width level until a row is selected, then two columns splitting the + * row evenly (256px floor; level | selected folder's children) around a + * hairline divider. Navigations land selection-anchored: a crumb jump or a + * submitted path commits the target immediately, then re-selects it in its + * parent level once that level arrives, so stepping back keeps two panes + * away from the display root. Selecting in the * right column shifts the view one level deeper. "New folder" opens a nested * create dialog targeting the selected folder (or the level itself) and - * selects the created folder — unless a newer pick or crumb jump supersedes - * the post-create relist, in which case neither the level nor the selection - * refreshes (see closeCreateDialog's two-stage parking for the matching - * focus story). Open adopts the selected folder, falling back + * selects the created folder. Open adopts the selected folder, falling back * to the listed level. Pure consumer of the injected browse calls — the * owning flow decides what "Open" means and owns the workspace-creation * error surface. Hidden entries are host-flagged and hidden by default; the @@ -37,16 +33,11 @@ import css from './DirectoryBrowser.module.css' /** Owner-supplied browser props: browse calls, pick semantics, and copy. */ export interface DirectoryBrowserProps { - /** Dialog visibility (owner-local; closing resets the per-open state, so a reopen starts clean on its first frame). */ + /** Dialog visibility (owner-local; closed unmounts nothing but resets on reopen). */ open: boolean /** List one directory level (absent path = the Host home directory); the signal aborts a superseded scan on the wire. */ listDirectory: (path?: string, signal?: AbortSignal) => Promise - /** - * Create one child directory under an existing parent; the returned path - * is verbatim the child's `entries[].path` in the parent's next listing - * (`IWorkspaces.createDirectory`'s contract) — the create landing anchors - * its selection and focus on that equality. - */ + /** Create one child directory under an existing parent. */ createDirectory: (path: string, name: string) => Promise /** The operator confirmed a directory (the selection, else the listed level). */ onOpen: (path: string) => void @@ -64,126 +55,46 @@ function failureText(error: unknown): string { return error instanceof Error ? error.message : String(error) } -/** - * Case-folds a path for comparisons under the given separator's platform: - * backslash (Windows) paths compare case-insensitively — a typed path - * legally differs in case from the host's stamped one — while slash - * platforms compare exactly (the filesystem may be case-sensitive; only a - * FINAL-segment macOS case drift misses parent-entry matching and keeps - * the single-pane landing, since parent entry paths inherit the typed - * prefix). - */ -function foldPathFor(sep: '\\' | '/'): (value: string) => string { - return value => (sep === '\\' ? value.toLowerCase() : value) -} - -/** - * Folds separators to the platform's canonical one: win32 treats a forward - * slash as a separator too (resolve() folds them the same way), while - * POSIX must not — a backslash there is a name character. - */ -function foldSeparatorsFor(sep: '\\' | '/'): (value: string) => string { - return value => (sep === '\\' ? value.replaceAll('/', sep) : value) -} - -/** - * Lexically normalizes a typed absolute path for comparisons against the - * backend's resolved ones (every listing path arrives in the - * DirectoryListing contract's canonical shape; only the DRAFT side, the - * one path a user types, needs this): collapses repeated and trailing - * separators, drops `.` segments, and applies `..` without ever crossing - * the root — POSIX's `/`, a drive's `C:`, or UNC's `\\server\share` pair — - * mirroring resolve()'s lexical behavior. Expects separators already - * folded to `sep` (foldSeparatorsFor); a lexical mirror only, symlinks are - * the backend's business. - */ -function normalizePathFor(sep: '\\' | '/'): (value: string) => string { - return (value) => { - const unc = sep === '\\' && value.startsWith(`${sep}${sep}`) - const rawSegments = (unc ? value.slice(2) : value).split(sep) - // Empty segments are separator noise everywhere except POSIX's leading - // root marker, which must survive as the first segment; scrubbing them - // up front keeps a doubled separator from being locked into the UNC - // server + share root below. - const segments = unc ? rawSegments.filter(segment => segment !== '') : rawSegments - // The unpoppable root: POSIX's leading empty segment / the drive - // segment, or UNC's server + share pair. - const rootLength = unc ? 2 : 1 - const out = segments.slice(0, rootLength) - for (const segment of segments.slice(rootLength)) { - if (segment === '' || segment === '.') continue - if (segment === '..') { - if (out.length > rootLength) out.pop() - continue - } - out.push(segment) - } - // A bare root keeps (or regains) the trailing separator resolve() - // emits for `/`, `C:\`, and `\\server\share\`. - return `${unc ? sep + sep : ''}${out.join(sep)}${out.length === rootLength ? sep : ''}` - } -} - /** * Breadcrumb rows for display: inside the home subtree the chain starts at a * localized Home crumb; outside it the full ancestry shows, the root labeled - * by its own path. `home` and every crumb path arrive in the same resolved - * shape (the wire contract), so only the platform case fold remains — a - * typed-case Windows chain still collapses to the Home crumb. + * by its own path. */ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryEntry[] { - const fold = foldPathFor(separatorOf(listing)) - const home = fold(listing.home) - const homeIndex = listing.crumbs.findIndex(crumb => fold(crumb.path) === home) + const homeIndex = listing.crumbs.findIndex(crumb => crumb.path === listing.home) if (homeIndex === -1) return listing.crumbs const tail = listing.crumbs.slice(homeIndex + 1) return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail] } /** - * The listing's platform separator, read from the host-resolved root crumb - * (`/`, `C:\`, `\\server\share\`) — exact for every root form the backend - * emits, and immune to backslashes inside POSIX names (which the home text - * may legally carry; the wire contract already excludes non-canonical - * shapes elsewhere). + * The listing's platform separator, inferred from the home path the host + * stamped — never from typed text or entry paths, where a backslash is a + * legal POSIX name character. Still a heuristic at the last step: a POSIX + * home directory whose own name contains a backslash would misread. * TODO: replace with a host-stamped `separator` field on the wire * DirectoryListing so the platform fact travels verbatim (the trade-off is * recorded in the directory-picker capability seam Agent Note). */ function separatorOf(listing: DirectoryListing): '\\' | '/' { - const rootCrumb = listing.crumbs.at(0) - // The seam type allows an empty chain (this backend never emits one, but - // create-target naming supports it, see targetName): degrade to a - // best-effort read of the home text — the pre-root-crumb heuristic, with - // its backslash-in-a-POSIX-name blind spot. - if (rootCrumb === undefined) return listing.home.includes('\\') ? '\\' : '/' - return rootCrumb.path.includes('\\') ? '\\' : '/' + return listing.home.includes('\\') ? '\\' : '/' } /** - * The path draft's final segment, when its directory part names the level - * `listing` lists — the segment the level prefix-filters on while the user - * types. Any other draft (no separator yet, or naming some other directory) - * leaves the level unfiltered. Only the directory part is lexically - * normalized (dot segments, repeated separators, and win32 forward slashes - * all match what Enter would navigate to) and platform-case-folded (exact - * on slash platforms; Windows folds, since an upgraded selection may carry - * the actual entry's case while the level below still carries the typed - * one); the FINAL segment stays a literal name prefix — a lone `.` reads - * as the dot-reveal, `..` matches no entry (Enter still navigates it) — - * and the name filter downstream is case-insensitive everywhere. + * The path draft's final segment, when its directory part is exactly the + * level `listing` lists — the segment the level prefix-filters on while the + * user types. Any other draft (no separator yet, or naming some other + * directory) leaves the level unfiltered. The directory part compares + * exactly (it is the host's own path text, reached by seeding or erasing); + * only the name filter downstream is case-insensitive. */ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null { if (draft === null) return null const sep = separatorOf(listing) - const folded = foldSeparatorsFor(sep)(draft) - const cut = folded.lastIndexOf(sep) + const cut = draft.lastIndexOf(sep) if (cut === -1) return null - const fold = foldPathFor(sep) - const normalize = normalizePathFor(sep) - return fold(normalize(folded.slice(0, cut + 1))) === fold(listing.path) - ? folded.slice(cut + 1) - : null + const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}` + return draft.slice(0, cut + 1) === level ? draft.slice(cut + 1) : null } /** One column of folder rows (the Miller view renders one or two of these). */ @@ -224,10 +135,10 @@ function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPr // where the blur lands before our guards) drop this click. // Outside editing, rows keep native focus behavior. onMouseDown={pathEditing ? (event) => { event.preventDefault() } : undefined} - // Focus parking happens after commit (the DirectoryBrowser - // refocus effect): a right-pane pick replaces this very - // column, so focusing the clicked node here would still fall - // to body. + // Editing-time focus parking happens after commit (the + // DirectoryBrowser refocus effect): a right-pane pick replaces + // this very column, so focusing the clicked node here would + // still fall to body. onClick={() => { onPick(entry) }} > {selected @@ -258,7 +169,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const [error, setError] = useState(null) // Path-edit state: null = breadcrumb mode; a string = the draft being typed. const [pathDraft, setPathDraft] = useState(null) - // Show-hidden toggle state (pure client-side filter, reset on close). + // Show-hidden toggle state (pure client-side filter, reset on each open). const [showHidden, setShowHidden] = useState(false) // Create-folder state: null = closed; a string = the nested dialog's draft. const [folderDraft, setFolderDraft] = useState(null) @@ -267,13 +178,8 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const requestSeq = useRef(0) // The in-flight listing's controller: superseding intent aborts the wire // request too — the Host stops scanning — instead of only discarding the - // eventual result while the scan keeps consuming host resources. Always - // holds a controller so no consumer needs a null guard: initially a - // placeholder that the first supersede aborts unused (minted lazily — - // useRef evaluates its argument every render), afterwards the latest - // scan's, settled or aborted between scans. - const [initialScanController] = useState(() => new AbortController()) - const scanController = useRef(initialScanController) + // eventual result while the scan keeps consuming host resources. + const scanController = useRef(null) // Bumped on every open/close edge: settlements from a previous open (a // pending creation included) must never mutate a reopened dialog. const openGeneration = useRef(0) @@ -289,7 +195,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, useEffect(() => () => { requestSeq.current += 1 openGeneration.current += 1 - scanController.current.abort() + scanController.current?.abort() }, []) const compositionGuard = { onCompositionStart: () => { composingRef.current = true }, @@ -298,7 +204,8 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, /** Newer intent wins: invalidate the pending listing's settlement AND abort its wire request. */ const supersede = useCallback((): number => { - scanController.current.abort() + scanController.current?.abort() + scanController.current = null return ++requestSeq.current }, []) @@ -310,54 +217,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, return { seq, scan: listDirectory(path, controller.signal) } }, [supersede, listDirectory]) - // The miller row's scroll host, shared by the pin and refocus effects - // below and read by navigate's upgrade leg (declared ahead of both). - const millerRowRef = useRef(null) - // Focus parking (consumed by the refocus effect below): a pick — and a - // parent-leg upgrade that displaces focused rows — parks on the - // selection's row; every other displacing exit (Enter, Escape, a landing - // whose new level dropped the focused row, a failed pick, and every - // create-dialog exit, whose close-time parking is also what a failed - // relist inherits) parks on the crumb edit zone, each only when focus - // actually fell to body. One parking bypasses both flags: the - // show-hidden toggle's click reclaims focus onto itself, synchronously, - // when the click finds focus among the rows. Pointer-out cancels never - // set (or clear) these — yanking focus back from wherever the user - // clicked would be worse than the fall. - const refocusPick = useRef(false) - const refocusEditZone = useRef(false) - const editZoneRef = useRef(null) - - /** - * Whether the focused element sits among the miller rows — probed before - * a landing replaces the row nodes to decide focus parking, and by the - * show-hidden toggle's click to decide whether to reclaim the native - * focus outcome. A probe only: it never gates its caller — a torn-down - * ref in a landing's close race merely skips the parking, and - * committing the landing into a closing dialog is safe: the close edge's - * supersede() fences every later settlement, and the same close effect - * zeroes parent/selected/child for the one frame that can slip between - * the close render and its effect. - * @returns true when `document.activeElement` is inside the miller row. - */ - const focusInMillerRows = useCallback((): boolean => { - const rowHost = millerRowRef.current - // Only the landing callers can race a close (commit precedes the reset - // effect); the toggle's click caller always finds the host mounted. - /* v8 ignore next -- close-race guard: not deterministically reproducible. */ - if (rowHost === null) return false - return rowHost.contains(document.activeElement) - }, []) - /** * Launch a follow-up listing under the CURRENT supersession seq: a newer * intent aborts it like the leg it continues, and it supersedes nothing. */ const continueScan = useCallback((path: string): Promise => { - // Abort whatever the slot last tracked before overwriting it (the - // caller's settled leg: a no-op) — the slot must never silently strand - // a live scan, the exact waste supersede() exists to prevent. - scanController.current.abort() const controller = new AbortController() scanController.current = controller return listDirectory(path, controller.signal) @@ -372,13 +236,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, * shape never disagree — a parent leg then upgrades the landing in place: * the target's ACTUAL parent-level entry re-selected (left pane = parent, * right pane = the target), so a crumb jump reads as stepping back one - * pane (Windows folds case; on slash platforms only a FINAL-segment case - * drift misses the match and keeps the single-pane landing — parent - * entries inherit the typed prefix, so ancestor-segment drift still - * matches, at the cost of the Home collapse). A failed parent leg, or a - * truncated parent window that lacks the target, leaves the committed - * single-pane landing — the upgrade must never orphan the selection it - * exists to anchor. + * pane. A failed parent leg, or a truncated parent window that lacks the + * target, leaves the committed single-pane landing — the upgrade must + * never orphan the selection it exists to anchor. */ const navigate = useCallback((path?: string) => { const { seq, scan } = launchListing(path) @@ -386,13 +246,6 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setError(null) scan.then((target) => { if (seq !== requestSeq.current) return - // The landing replaces every row key; a slow jump leaves the OLD - // rows tabbable meanwhile (parentInert excludes loading), so focus - // may live among them. With no selection yet the edit zone is the - // park target (body-guarded, like every other exit). The probe never - // gates the commit below — stranding the dialog in loading over a - // focus check would be far worse than a skipped parking. - if (focusInMillerRows()) refocusEditZone.current = true setParent(target) setSelected(null) setChild(null) @@ -406,15 +259,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, continueScan(parentCrumb.path).then((parentLevel) => { if (seq !== requestSeq.current) return // Windows resolves a typed path preserving its case; anchor on the - // parent level's actual entry so selection comparisons hold (slash - // platforms compare exactly — see foldPathFor). - const fold = foldPathFor(separatorOf(parentLevel)) + // parent level's actual entry so selection comparisons hold. + const sep = separatorOf(parentLevel) + const fold = (value: string): string => (sep === '\\' ? value.toLowerCase() : value) const match = parentLevel.entries.find(entry => fold(entry.path) === fold(target.path)) if (match === undefined) return - // The upgrade replaces every committed row node; if focus lives - // among them (Tab reached the rows during the parent leg), arm the - // refocus effect so it re-parks on the re-selected row. - if (focusInMillerRows()) refocusPick.current = true setParent(parentLevel) setSelected(match) setChild(target) @@ -428,33 +277,25 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setLoading(false) setError(failureText(reason)) }) - }, [launchListing, continueScan, focusInMillerRows]) + }, [launchListing, continueScan]) - /** - * Close the nested create dialog. Its unmount drops focus to body (the - * Modal has no focus trap), so every exit — Escape, mask, Cancel, and a - * successful create — arms the body-guarded edit-zone parking. A - * successful create therefore parks in TWO stages: the edit zone on this - * close, then the relist's select() re-parks on the created row one RTT - * later — deliberately re-parking even focus the user moved during the - * relist window, and doubling as the parking a failed relist inherits. - */ - const closeCreateDialog = useCallback(() => { - setFolderDraft(null) - refocusEditZone.current = true - }, []) + // Editor-close focus parking (consumed by the refocus effect below the + // miller-row ref): a pick parks on the selection's row, Enter and an + // input-focused Escape park on the crumb edit zone that replaces the + // input. Pointer-out cancels never set (or clear) these — yanking focus + // back from wherever the user clicked would be worse than the fall. + const refocusPick = useRef(false) + const refocusEditZone = useRef(false) + const pathInputRef = useRef(null) + const editZoneRef = useRef(null) /** Select a row of the listed level and preview its children on the right. */ const select = useCallback((entry: DirectoryEntry) => { const { seq, scan } = launchListing(entry.path) // A pick while the path editor is open adopts the (filtered) row and - // closes the editor — the draft served its purpose. EVERY pick re-parks - // focus on the selection after commit (see the refocus effect below): - // a left-pane pick lands on the very row that was clicked (a near - // no-op), while a right-pane advance and a create landing replace the - // picked button's column entirely and would otherwise drop focus to - // body. - refocusPick.current = true + // closes the editor — the draft served its purpose. Focus re-parks on + // the selection after commit (see the refocus effect below). + if (pathDraft !== null) refocusPick.current = true setPathDraft(null) setSelected(entry) setChild(null) @@ -476,7 +317,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // re-parks on the edit zone only if focus actually fell to body. refocusEditZone.current = true }) - }, [launchListing]) + }, [launchListing, pathDraft]) /** Abandon path editing (Escape or clicking away) and restore the crumb view. */ const cancelPathEdit = useCallback(() => { @@ -506,26 +347,19 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }, [child, select]) // Every open starts fresh at the Host home directory; closing invalidates - // any in-flight response so a late arrival cannot repopulate a closed - // dialog. The per-open state resets live on the CLOSE edge: resetting on - // open would let the reopen's first commit paint one frame of the stale - // view (revealed hidden rows, a pressed toggle) before this passive - // effect runs. No automated gate observes that ordering (act() hides the - // frame in tests) — this comment is the guard; read it before moving - // these back. + // any in-flight response so a late arrival cannot repopulate a closed dialog. useEffect(() => { openGeneration.current += 1 if (open) { + setParent(null) + setSelected(null) + setChild(null) + setCreatingFolder(false) + setShowHidden(false) navigate() return } supersede() - setParent(null) - setSelected(null) - setChild(null) - setCreatingFolder(false) - setShowHidden(false) - setLoading(false) setError(null) setPathDraft(null) setFolderDraft(null) @@ -557,20 +391,19 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // the fresh dialog or issue a relist against the stale target. if (generation !== openGeneration.current) return setCreatingFolder(false) - closeCreateDialog() + setFolderDraft(null) // Land like a right-column pick (figma 802:57446 → 813:23278 flow): the // create target becomes the listed level and the new folder its selection. const { seq, scan } = launchListing(targetPath) setLoading(true) scan.then((level) => { - // The nested dialog closed before this relist launched, so the card - // is interactive meanwhile: a pick or crumb jump supersedes it. + /* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */ if (seq !== requestSeq.current) return setParent(level) setLoading(false) select({ name, path: createdPath, hidden: false }) }, (reason: unknown) => { - // Same interactive-window fence as the success branch above. + /* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */ if (seq !== requestSeq.current) return setLoading(false) setError(failureText(reason)) @@ -592,29 +425,19 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }, [crumbTail]) // On viewports too narrow for both fixed panes the Miller row scrolls; // whenever a child preview lands, pin it into view the way the crumb tail - // pins — otherwise descent is unreachable on a phone-width window. The - // refocus effect's row.focus() and this pin can fight on such viewports, - // and whichever commit runs later wins by design: on a parent-leg - // upgrade (one commit) focus placement runs after the pin and keeps the - // selected LEFT row in view; on a plain advance or create landing the - // child arrives in a later commit, so the pin runs after the focus and - // descent reachability wins. + // pins — otherwise descent is unreachable on a phone-width window. + const millerRowRef = useRef(null) const childPath = child?.path useEffect(() => { const row = millerRowRef.current if (row !== null && childPath !== undefined) row.scrollLeft = row.scrollWidth }, [childPath]) - // Every pick and editor exit that would drop focus to body re-parks it - // after commit, so THIS DIALOG'S OWN node replacements never leak focus - // out of the card: a pick lands on the selection's row — aria-current in - // the freshly rendered left pane, which survives even a right-pane - // advance or a create landing replacing the picked button's column — - // while the edit-zone exits enumerated at the flag declarations fall - // back to the crumb edit zone. Outside the guarantee: the Modal has no - // focus trap, so tabbing past the card's edge legitimately leaves, and - // the owner's adopt window (busy inerts every control; browsers blur - // disabled elements to body) gets no parking — the owner closes the - // dialog either way. + // Every editor exit that would drop focus to body re-parks it after + // commit, so keyboard traversal stays inside the dialog (the Modal has no + // focus trap): a pick lands on the selection's row — aria-current in the + // freshly rendered left pane, which survives even a right-pane advance + // replacing the picked button's column — while Enter and an input-focused + // Escape land on the crumb edit zone that replaces the input. useEffect(() => { if (pathDraft !== null) return if (refocusPick.current) { @@ -624,14 +447,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, /* v8 ignore next -- narrowing guard: the miller row is mounted whenever a pick just committed. */ if (rowHost === null) return const row = rowHost.querySelector('button[aria-current="true"]') - if (row !== null) { - row.focus() - return - } - // The pick lost its row (a truncated relist after Create can drop - // the created directory outside the window): fall through to the - // edit-zone parking below instead of leaving focus where it fell. - refocusEditZone.current = true + /* v8 ignore next -- narrowing guard: the pick that set the flag just rendered its aria-current row. */ + if (row === null) return + row.focus() + return } if (refocusEditZone.current) { refocusEditZone.current = false @@ -639,9 +458,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // the user parked elsewhere (a surviving row) stays theirs. if (document.activeElement !== document.body) return const zone = editZoneRef.current - // The effect already returned while a draft is open, and the close - // reset cleared both flags — so crumb mode's zone is always mounted. - /* v8 ignore next -- narrowing guard: crumb mode always renders the edit zone. */ + /* v8 ignore next -- narrowing guard: crumb mode renders the edit zone whenever the editor just closed. */ if (zone === null) return zone.focus() } @@ -685,13 +502,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // document listener — the same containment the input previously // provided for itself. event.stopPropagation() - // The cancel may unmount whatever holds focus — the input, or a - // dot-revealed row the cleared draft re-hides. Arm the parking - // unconditionally: the refocus effect's body guard already - // distinguishes a surviving focused row (left alone) from focus - // that actually fell. Assignment (not a conditional set) also + // Escape while the input holds focus is about to unmount it; with + // focus already parked on a row, that row survives the cancel and + // keeps focus naturally. Assignment (not a conditional set) also // retires a stale flag a failed or still-upgrading Enter left. - refocusEditZone.current = true + refocusEditZone.current = document.activeElement === pathInputRef.current cancelPathEdit() }} // Focus leaving THIS dialog card while editing cancels like Escape. @@ -775,6 +590,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, value={pathDraft} aria-label={t('browser.editPath')} autoFocus + ref={pathInputRef} disabled={parentInert} onChange={(event) => { // Editing the draft supersedes any in-flight navigation: @@ -863,19 +679,12 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // toggling never blur-cancels a draft mid-thought. Outside editing // it keeps native focus behavior. onMouseDown={draftPending ? (event) => { event.preventDefault() } : undefined} - onClick={(event) => { - // The suppression above exists to protect the INPUT's focus; - // with focus among the rows instead, hand back the native - // click outcome wholesale — the clicked toggle takes focus - // and stays in the card. Accepted cost: this also moves - // focus off a row the toggle would NOT have hidden; tracking - // which rows a direction change unmounts is not worth it. - if (focusInMillerRows()) event.currentTarget.focus() - setShowHidden(prev => !prev) - }} + onClick={() => { setShowHidden(prev => !prev) }} > {t('browser.showHidden')} - {showHidden && } + {/* Trailing check (Menu's selected vocabulary): the label never + * shifts when the pressed state toggles. */} + {showHidden && } @@ -893,7 +702,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, {/* Nested create dialog (figma 813:23278): names one folder inside the target. */} { if (!creatingFolder) closeCreateDialog() }} + onClose={() => { if (!creatingFolder) setFolderDraft(null) }} title={t('browser.newFolder')} className={clsx(css.createDialog)} headless @@ -917,13 +726,13 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, } if (event.key === 'Escape') { event.stopPropagation() - if (!creatingFolder) closeCreateDialog() + if (!creatingFolder) setFolderDraft(null) } }} /> {createError !== null &&
{createError}
}
- +