From 12d302ea591bc4439fc9c6102f495033b3b4cb18 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 01:10:37 +0800 Subject: [PATCH] =?UTF-8?q?fix(host):=20review=20round=2017=20=E2=80=94=20?= =?UTF-8?q?separator-fold=20drafts;=20UNC=20noise=20scrub;=20honest=20empt?= =?UTF-8?q?y-chain=20fallback;=20toggle=20focus=20keep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 4 +- ...-28-directory-picker-capability-seam.zh.md | 4 +- .../src/client/DirectoryBrowser.tsx | 47 ++++++++++---- .../tests/directory-browser.spec.tsx | 62 ++++++++++++++++++- 5 files changed, 102 insertions(+), 19 deletions(-) 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 0b17e08ce7..81c065227f 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: 75fda86788ec2ab1966f7919cdca7d503238da91 -2026-07-28-directory-picker-capability-seam.zh.md: 1e2c7819133366d5b9a0b26db195b46ac5ea62f4 +2026-07-28-directory-picker-capability-seam.md: 9008ba928c16a19fae601db0b8a83d44eaeeaae0 +2026-07-28-directory-picker-capability-seam.zh.md: 95ba66062aafb4ebe1ed37219efd5ad94f13f842 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 75fda86788..9008ba928c 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -19,7 +19,7 @@ Placement and policy rulings folded into this decision: - **Not the `ctx.fs` seam.** `packages/fs/` is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swapping `fs-sandbox` for the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free; `packages/host/` is its consumer-domain home. - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. -- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are 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. 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; keyboard traversal never falls out of the card (the Modal has no focus trap), except during the owner's adopt window, where `busy` inerts every control and the dialog is closing either way. +- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are read from the host-resolved root crumb (exact for every root form this backend emits: `/`, `C:\`, `\\server\share\`); the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. Focus parking is a card-wide invariant, not an editor-only one: every pick — editing or not, including right-pane advances and create landings whose columns are replaced — re-parks focus on the selection's row after commit, while every other displacing exit (Enter, Escape, a navigation landing whose new level dropped the focused row, a failed pick or create relist, and the nested create dialog closing) falls back to the crumb edit zone whenever focus actually fell to body; keyboard traversal never falls out of the card (the Modal has no focus trap), except during the owner's adopt window, where `busy` inerts every control and the dialog is closing either way. - **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. Known boundaries of the progressive shape: a pointer press landing exactly inside the one-RTT upgrade window can lose its click when the pressed row node is replaced (keyboard focus is re-parked on the re-selected row; the pointer window is accepted); on slash platforms (macOS) only a final-segment case drift misses the parent-entry match and keeps the single-pane landing, while ancestor-segment drift still matches — parent entry paths inherit the typed prefix — and lands two panes at the cost of the Home collapse; and navigate always relists both legs even when the target is the currently shown level — a crumb tap doubles as the refresh gesture, so freshness wins over reusing possibly-stale in-hand listings at the cost of up to two host scans. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. @@ -34,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 — 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. +- **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. ## 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 1e2c781913..95ba66062a 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -19,7 +19,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **不用 `ctx.fs` seam。** `packages/fs/` 是面向模型/会话的存储栈(policy 事件、sandbox 可换后端)。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 `fs-sandbox` 绝不能改变 GUI 行为——而 OS 事实(home 锚定、隐藏约定)也不是存储原语。picker seam 保持无展示、无模型;`packages/host/` 是它消费方域的家。 - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 -- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。焦点停靠是卡片全域的不变量,而非编辑器独有:每次选取——无论是否处于编辑态,包括右栏推进与各列被替换的创建落地——提交后都把焦点重新停靠到选中项所在的行上,而其余所有会顶离焦点的退出(Enter、Escape、新层级已不含焦点所在行的导航落地、选取或创建失败后的重新列举,以及嵌套创建对话框的关闭)只要焦点确实落到了 body 上,就回落到 crumb 编辑区;键盘遍历绝不会落出卡片之外(Modal 没有焦点陷阱),仅 owner 的接纳窗口期间例外——此时 `busy` 把每个控件置为惰性,且对话框反正正在关闭。 +- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从宿主解析的根 crumb 读取(对该后端发出的每种根形态都精确:`/`、`C:\`、`\\server\share\`);下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。焦点停靠是卡片全域的不变量,而非编辑器独有:每次选取——无论是否处于编辑态,包括右栏推进与各列被替换的创建落地——提交后都把焦点重新停靠到选中项所在的行上,而其余所有会顶离焦点的退出(Enter、Escape、新层级已不含焦点所在行的导航落地、选取或创建失败后的重新列举,以及嵌套创建对话框的关闭)只要焦点确实落到了 body 上,就回落到 crumb 编辑区;键盘遍历绝不会落出卡片之外(Modal 没有焦点陷阱),仅 owner 的接纳窗口期间例外——此时 `busy` 把每个控件置为惰性,且对话框反正正在关闭。 - **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。渐进形态的已知边界:指针按压若恰好落在单个 RTT 的升级窗口内,可能因所按行节点被替换而丢失点击(键盘焦点会重新停靠到重新选中的行上;指针的这段窗口则被接受);斜杠平台(macOS)上仅末段的大小写偏差会错过父层级条目匹配,保留单栏落地,而祖先段的偏差仍能匹配——父层级条目路径继承键入的前缀——并落地双栏,代价是 Home 塌缩;且导航总是重新列举两程,哪怕目标就是当前展示的层级——crumb 点按兼作刷新手势,因此宁要新鲜度也不复用手头可能已陈旧的列举,代价是至多两次宿主扫描。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 @@ -34,7 +34,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **引入文件管理器/盘符枚举依赖。** 按上文调研否决;依赖政策要求记录于此。 - **动作标签随状态翻转的"显示隐藏"开关("隐藏隐藏文件")。** 否决:会翻转的动作标签在状态与动作之间有歧义,还把否定叠了两层;固定标签加按下态呈现一次说清两者。 - **纯 relatedTarget 失焦取消(不做 mousedown 抑制)。** 否决:Safari 在指针按下时不给按钮聚焦,点击触发的 focusout 因而携带空 `relatedTarget`,会在点击落地前就取消编辑器;编辑期作用的 mousedown 抑制加上锚定卡片的 relatedTarget 守卫才能同时覆盖指针与键盘路径。 -- **在 `DirectoryListing` 上增设线上 `separator` 字段(宿主标注 `path.sep`)。** 延期而非否决:它才是权威形态——含反斜杠的 POSIX 家目录会击穿 `listing.home` 启发式——但它触及 seam 类型与每个后端;browse 客户端的 `separatorOf` 挂着指向本方案的 TODO,直到下次安排线上变更。 +- **在 `DirectoryListing` 上增设线上 `separator` 字段(宿主标注 `path.sep`)。** 延期而非否决:它才是权威形态。客户端今天所用的根 crumb 读取对该后端发出的每种根形态都精确,但它仍是从路径文本推断平台事实,还把"链从根开始"从后端行为提升为客户端所依赖的不变量(`crumbs` 的 JSDoc 确实承诺了这一点),并在链为空时退化回旧的 home 文本启发式;线上字段则会原样随线传输,经得住空链与未来的后端。它触及 seam 类型与每个后端,因此 browse 客户端的 `separatorOf` 挂着指向本方案的 TODO,直到下次安排线上变更。 ## 后果 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index d82b4f0abd..2e5e732548 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -82,13 +82,26 @@ function foldPathFor(sep: '\\' | '/'): (value: string) => string { * backend's own paths arrive already resolved. A lexical mirror only: * symlinks are the backend's business. */ +/** + * 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) +} + function normalizePathFor(sep: '\\' | '/'): (value: string) => string { + const foldSeparators = foldSeparatorsFor(sep) return (raw) => { - // win32 treats a forward slash as a separator too (resolve() folds - // them); POSIX must not — a backslash there is a name character. - const value = sep === '\\' ? raw.replaceAll('/', sep) : raw + const value = foldSeparators(raw) const unc = sep === '\\' && value.startsWith(`${sep}${sep}`) - const segments = (unc ? value.slice(2) : value).split(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 @@ -136,8 +149,10 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE */ function separatorOf(listing: DirectoryListing): '\\' | '/' { const rootCrumb = listing.crumbs.at(0) - // Home is the honest fallback for an impossible empty chain. - /* v8 ignore next -- narrowing guard: the wire chain is root-to-target inclusive. */ + // 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('\\') ? '\\' : '/' } @@ -149,17 +164,19 @@ function separatorOf(listing: DirectoryListing): '\\' | '/' { * leaves the level unfiltered. The directory part compares under the * platform fold (exact on slash platforms; Windows folds case, since an * upgraded selection may carry the actual entry's case while the level - * below still carries the typed one); the name filter downstream is - * case-insensitive everywhere. + * below still carries the typed one — and folds forward slashes, which + * win32 and the backend both accept in typed paths); the name filter + * downstream is case-insensitive everywhere. */ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null { if (draft === null) return null const sep = separatorOf(listing) - const cut = draft.lastIndexOf(sep) + const folded = foldSeparatorsFor(sep)(draft) + const cut = folded.lastIndexOf(sep) if (cut === -1) return null const fold = foldPathFor(sep) const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}` - return fold(draft.slice(0, cut + 1)) === fold(level) ? draft.slice(cut + 1) : null + return fold(folded.slice(0, cut + 1)) === fold(level) ? folded.slice(cut + 1) : null } /** One column of folder rows (the Miller view renders one or two of these). */ @@ -824,7 +841,15 @@ 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={() => { setShowHidden(prev => !prev) }} + onClick={(event) => { + // The suppression above exists to protect the INPUT's focus; + // when focus is instead on a row that this very toggle may + // re-hide, restore the native outcome — the clicked toggle + // keeps focus in the card (the editing-time refocus effect + // deliberately stays out of the way). + if (focusInMillerRows()) event.currentTarget.focus() + setShowHidden(prev => !prev) + }} > {t('browser.showHidden')} {showHidden && } diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index fcc2b21f92..4434e31eaa 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -412,8 +412,9 @@ describe('DirectoryBrowser', () => { const listing: DirectoryListing = { path: `${SHARE}\\x`, // USERPROFILE ships verbatim; win32.resolve keeps \\server\share as - // the unpoppable root, so this normalizes to \\server\share\x. - home: `${SHARE}\\..\\x`, + // the unpoppable root and folds the doubled separator, so this + // normalizes to \\server\share\x. + home: '\\\\server\\\\share\\..\\x', crumbs: [ { name: `${SHARE}\\`, path: `${SHARE}\\`, hidden: false }, { name: 'x', path: `${SHARE}\\x`, hidden: false }, @@ -536,6 +537,10 @@ describe('DirectoryBrowser', () => { expect(within(columns()[1]!).getByText('Alpha')).toBeTruthy() fireEvent.change(input, { target: { value: 'C:\\Users\\z' } }) expect(within(columns()[1]!).queryAllByRole('listitem')).toHaveLength(0) + // Forward-slash drafts are equally legal on win32 (Enter navigates + // them); the filter folds them instead of going silent. + fireEvent.change(input, { target: { value: 'C:/Users/a' } }) + expect(within(columns()[1]!).getByText('Alpha')).toBeTruthy() }) it('re-parks focus on the edit zone when a failed pick unmounts a dot-revealed row', async () => { @@ -1423,6 +1428,59 @@ describe('DirectoryBrowser', () => { expect(screen.getByText('browser.createIn:/srv/data')).toBeTruthy() }) + it('a crumb-less Windows level still seeds the editor with a backslash', async () => { + // The empty chain degrades separatorOf to the home-text read; the + // backslash side of that fallback is the Windows shape. + const bare: DirectoryListing = { path: 'C:\\srv', home: 'C:\\Users\\u', crumbs: [], entries: [], truncated: false } + mount({ listDirectory: vi.fn(async () => bare) }) + await waitFor(() => { + expect(screen.getByRole('button', { name: 'browser.editPath' }).disabled).toBe(false) + }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + expect(screen.getByLabelText('browser.editPath').value).toBe('C:\\srv\\') + }) + + it('a POSIX home whose name contains a backslash still reads as the display root', async () => { + const WEIRD = '/home/we\\ird' + const listing: DirectoryListing = { + path: WEIRD, + home: WEIRD, + crumbs: [ + { name: '/', path: '/', hidden: false }, + { name: 'home', path: '/home', hidden: false }, + { name: 'we\\ird', path: WEIRD, hidden: false }, + ], + entries: [{ name: 'notes', path: `${WEIRD}/notes`, hidden: false }], + truncated: false, + } + const listDirectory = vi.fn(async () => listing) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + // The root crumb ('/') decides the platform: the backslash in the name + // neither flips the fold nor breaks the Home collapse. + expect(columns()).toHaveLength(1) + expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() + expect(listDirectory).toHaveBeenCalledTimes(1) + }) + + it('pointer-toggling hidden off keeps focus on the toggle as the focused row re-hides', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + const toggle = screen.getByRole('button', { name: 'browser.showHidden' }) + fireEvent.click(toggle) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + // Tab parked focus on the revealed hidden row; the pointer click below + // would unmount it (toggle off + empty seeded prefix hides it again). + const hiddenRow = within(columns()[0]!).getByText('.config').closest('button')! + hiddenRow.focus() + fireEvent.mouseDown(toggle) + fireEvent.click(toggle) + expect(screen.queryByText('.config')).toBeNull() + expect(document.activeElement).toBe(toggle) + // The editor survives the whole exchange. + expect(screen.getByLabelText('browser.editPath', { selector: 'input' })).toBeTruthy() + }) + it('refuses to close the nested dialog while the creation is in flight', async () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })