feat(host): navigations land selection-anchored — crumb jumps step back a pane instead of collapsing

This commit is contained in:
creatixchu
2026-07-29 21:12:04 +08:00
parent 23b74c5d7d
commit a1d752f799
9 changed files with 160 additions and 20 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md
2026-07-28-directory-picker-capability-seam.md: 550d51cf2e4e5b70b0844401d42bfea2342612e7
2026-07-28-directory-picker-capability-seam.zh.md: afaf277e866349b20d370fe1fd638b0c71e1a3e7
2026-07-28-directory-picker-capability-seam.md: 51d71d15cb5144d555a5b156d9b108d7a2ad41b8
2026-07-28-directory-picker-capability-seam.zh.md: 60bc14c13d1e4655faefbd5eaa63469119bf0ca3

View File

@@ -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.
- **Navigation lands selection-anchored.** Away from the display root, the browse client's navigate (a crumb jump or a submitted path) lists the target's parent level with the target selected and its children on the right — two panes throughout, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The display root (home, or a rootward chain with no parent crumb) keeps the single wide level; the parent leg runs under the same supersession scope as the landing, and its failure falls back to the single-pane landing quietly — the target listed fine, and nobody asked to see the parent.
- **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption.
- **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories.
- **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it.

View File

@@ -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、结果播报同样被延期——目前二者在辅助技术看来是彼此独立的控件。
- **导航以选中项为锚落地。** 在展示根之外browse 客户端的导航crumb 跳转或提交的路径)列出目标的父层级,选中目标并在右侧展示其子项——全程双栏,因此 crumb 跳转读作后退一栏而不是塌缩成单列。展示根home或没有父 crumb 的抵根链)保持单个宽层级;父层级这一程与落地共用同一 supersession 范围,其失败会静默回退到单栏落地——目标本身列举无误,本也没有人要求查看父层级。
- **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。
- **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await打开、每次读取、每次符号链接探测都与信号赛跑中止路径放弃而非等待 closeNode 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。
- **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。

View File

@@ -202,6 +202,12 @@ it('adopts a directory through the composed in-app browse flow and lands in its
// row's name span is stable (clicks bubble to the row button).
fireEvent.click(await within(dialog).findByText('Documents', {}, { timeout: 10_000 }))
fireEvent.click(await within(dialog).findByText('project', {}, { timeout: 10_000 }))
// A crumb jump away from the root steps BACK one pane instead of
// collapsing: Documents lands selected in the home level (Downloads is
// the home-level marker) with its children still on the right.
fireEvent.click(within(dialog).getByRole('button', { name: 'Documents' }))
await within(dialog).findByText('Downloads', {}, { timeout: 10_000 })
fireEvent.click(await within(dialog).findByText('project', {}, { timeout: 10_000 }))
// Open disables while the selection's child listing is in flight; wait for
// the enabled state or the click lands on a dead button on slow runners.
await waitFor(() => {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/directory-picker-browse/README.md
README.md: 7813e0e9c589d1f37471b08ebafcf08878cbf768
README.zh.md: 46538ed6f1cf23357cc4f8e289a8117d0e5e017d
README.md: 51e27115b5179796810c19b618b08a3523de8d10
README.zh.md: 4caeff1c2ebfea2c12e6bb598dae2c34f2e34d84

View File

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

View File

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

View File

@@ -214,24 +214,60 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
return { seq, scan: listDirectory(path, controller.signal) }
}, [supersede, listDirectory])
/** Replace the whole view with one freshly listed level (no selection). */
/**
* Replace the whole view with a freshly navigated level. Away from the
* display root the landing keeps the navigated directory SELECTED inside
* its parent level (left pane = parent, right pane = its children), so a
* crumb jump or a submitted path reads as stepping back one pane instead
* of collapsing to a single column; the display root (the home level, or
* a chain with no parent) keeps the single wide level.
*/
const navigate = useCallback((path?: string) => {
const { seq, scan } = launchListing(path)
setLoading(true)
setError(null)
scan.then((next) => {
scan.then((target) => {
if (seq !== requestSeq.current) return
setParent(next)
setSelected(null)
setChild(null)
setLoading(false)
setPathDraft(null)
const parentCrumb = target.crumbs.at(-2)
const anchor = target.crumbs.at(-1)
if (target.path === target.home || parentCrumb === undefined
/* v8 ignore next -- narrowing: the anchor crumb exists whenever a parent crumb does (root-to-target inclusive chain). */
|| anchor === undefined) {
setParent(target)
setSelected(null)
setChild(null)
setLoading(false)
setPathDraft(null)
return
}
// Two-pane landing: the parent leg runs under the same supersession
// scope (a newer intent aborts it like the first leg).
const controller = new AbortController()
scanController.current = controller
listDirectory(parentCrumb.path, controller.signal).then((parentLevel) => {
if (seq !== requestSeq.current) return
setParent(parentLevel)
setSelected(anchor)
setChild(target)
setLoading(false)
setPathDraft(null)
}, () => {
if (seq !== requestSeq.current) return
// The target listed fine and is what the user asked for; a parent
// leg failure quietly falls back to the single-pane landing rather
// than surfacing an error for a level nobody requested.
setParent(target)
setSelected(null)
setChild(null)
setLoading(false)
setPathDraft(null)
})
}, (reason: unknown) => {
if (seq !== requestSeq.current) return
setLoading(false)
setError(failureText(reason))
})
}, [launchListing])
}, [launchListing, listDirectory])
// 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

View File

@@ -29,6 +29,13 @@ function listingFor(path?: string): DirectoryListing {
],
truncated: false,
},
'/': {
path: '/',
home: HOME,
crumbs: [{ name: '/', path: '/', hidden: false }],
entries: [{ name: 'home', path: '/home', hidden: false }],
truncated: false,
},
[`${HOME}/.config`]: {
path: `${HOME}/.config`,
home: HOME,
@@ -198,6 +205,88 @@ describe('DirectoryBrowser', () => {
expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull()
})
it('a crumb jump away from the root lands two-pane with the target selected', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(rowButton(screen.getByRole('listitem')))
await waitFor(() => { expect(columns()).toHaveLength(2) })
fireEvent.click(rowButton(within(columns()[1]!).getByRole('listitem')))
await waitFor(() => { expect(screen.getByRole('button', { name: 'harness' })).toBeTruthy() })
// Jumping to the Documents crumb is a step BACK one pane, not a
// collapse: Documents stays selected in the home level, its children
// stay on the right.
fireEvent.click(screen.getByRole('button', { name: 'Documents' }))
await waitFor(() => {
expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true')
})
expect(columns()).toHaveLength(2)
expect(within(columns()[0]!).getByText('Documents')).toBeTruthy()
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
})
it('a navigation to the filesystem root keeps the single wide level', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: '/' } })
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
// A one-crumb chain has no parent level to show on the left.
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('home') })
expect(columns()).toHaveLength(1)
})
it('drops a parent leg that settles after a newer intent, resolving or rejecting', async () => {
const settlers: { resolve: (value: DirectoryListing) => void; reject: (reason: unknown) => void }[] = []
const listDirectory = vi.fn(async (path?: string) => {
if (path === HOME) {
return new Promise<DirectoryListing>((resolve, reject) => { settlers.push({ resolve, reject }) })
}
return listingFor(path)
})
mount({ listDirectory })
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
// Enter lands the target leg; the parent leg hangs. Escape supersedes
// the landing, and the late parent RESOLUTION must change nothing.
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: DOCS } })
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
await waitFor(() => { expect(settlers).toHaveLength(1) })
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Escape' })
await act(async () => { settlers[0]!.resolve(listingFor(HOME)) })
expect(columns()).toHaveLength(1)
expect(screen.getByRole('listitem').textContent).toBe('Documents')
// Same shape, late parent REJECTION: equally silent.
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: DOCS } })
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
await waitFor(() => { expect(settlers).toHaveLength(2) })
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Escape' })
await act(async () => { settlers[1]!.reject(new Error('late')) })
expect(columns()).toHaveLength(1)
expect(screen.queryByRole('alert')).toBeNull()
})
it('falls back to the single-pane landing when the parent leg of a navigation fails', async () => {
const listDirectory = vi.fn(async (path?: string) => {
// The initial open lists home through the absent-path form; only the
// parent leg names HOME explicitly.
if (path === HOME) {
throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'parent gone', details: { path } })
}
return listingFor(path)
})
mount({ listDirectory })
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: DOCS } })
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
// The target listed fine; the failed parent leg neither blocks the
// landing nor surfaces an error for a level nobody asked to see.
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
expect(columns()).toHaveLength(1)
expect(screen.queryByRole('alert')).toBeNull()
})
it('opens the selection, else the listed level; Cancel closes; busy freezes Open', async () => {
const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
@@ -225,8 +314,11 @@ describe('DirectoryBrowser', () => {
expect(input.value).toBe(`${HOME}/`)
fireEvent.change(input, { target: { value: DOCS } })
fireEvent.keyDown(input, { key: 'Enter' })
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
expect(columns()).toHaveLength(1)
// Away from the root a navigation lands two-pane: the target selected
// in its parent level, its own children on the right.
await waitFor(() => { expect(columns()).toHaveLength(2) })
expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true')
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
// The submitted navigation unmounted the focused input; focus parks on
// the crumb edit zone that replaced it.
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' }))
@@ -234,7 +326,9 @@ describe('DirectoryBrowser', () => {
const again = screen.getByLabelText<HTMLInputElement>('browser.editPath')
fireEvent.change(again, { target: { value: ' ' } })
fireEvent.keyDown(again, { key: 'Enter' })
expect(b.listDirectory).toHaveBeenCalledTimes(2)
// Initial home + the DOCS target leg + its parent leg; the blank draft
// added none.
expect(b.listDirectory).toHaveBeenCalledTimes(3)
fireEvent.keyDown(again, { key: 'Escape' })
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
// Escape with focus in the input parks focus on the returning edit zone.
@@ -945,11 +1039,13 @@ describe('DirectoryBrowser', () => {
b.listDirectory.mockReturnValueOnce(slow)
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
fireEvent.click(within(screen.getByRole('navigation')).getByRole('button', { name: 'Documents' }))
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
// The newer jump lands two-pane: Documents selected at home, children right.
await waitFor(() => { expect(within(columns()[1]!).getByText('harness')).toBeTruthy() })
resolveSlow(listingFor(undefined))
await new Promise(settle => setTimeout(settle, 0))
// The stale home listing did not replace the newer Documents level.
expect(screen.getByRole('listitem').textContent).toBe('harness')
// The stale home listing did not replace the newer Documents landing.
expect(columns()).toHaveLength(2)
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
})
it('names the create target by its path when the level reports no crumbs', async () => {