Merge remote-tracking branch 'origin/master' into worktree/plan-review-layout

This commit is contained in:
creatixchu
2026-07-30 22:10:56 +08:00
338 changed files with 9551 additions and 3338 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 packages/host/README.md
README.md: d44770f70be16c12f44b78155089e092a3e9bba0
README.zh.md: 2b6878b08be6489dcd510a0a0e0f0e833c2a8014
README.md: 0417a1b8aec36d58ec0f690f397edcf9e015f982
README.zh.md: 46516d187321029ed739d8c071f246bc1755b125

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The host side of the dsh web GUI: the API gateway every client shape shares, and the plain HTTP server it rides on. The browser side lives in [`client/`](../client/README.md); the composed application is [`apps/cli`](../../apps/cli/cordis.yml) serving [`apps/web`](../../apps/web/). All **product** packages.
The host side of the dsh web GUI: the API gateway every client shape shares, and the plain HTTP server it rides on. The browser side lives in [`client/`](../client/README.md); the composed application is [`apps/cli`](../../apps/cli/config/base.cordis.yml) serving [`apps/web`](../../apps/web/). All **product** packages.
| Package | Role | ctx key |
|---|---|---|

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
dsh web GUI 的宿主侧:所有客户端形态共用的 API 网关,以及承载它的纯 HTTP 服务器。浏览器侧位于 [`client/`](../client/README.md);组合后的应用是 [`apps/cli`](../../apps/cli/cordis.yml),它负责服务 [`apps/web`](../../apps/web/)。全部为**产品**包。
dsh web GUI 的宿主侧:所有客户端形态共用的 API 网关,以及承载它的纯 HTTP 服务器。浏览器侧位于 [`client/`](../client/README.md);组合后的应用是 [`apps/cli`](../../apps/cli/config/base.cordis.yml),它负责服务 [`apps/web`](../../apps/web/)。全部为**产品**包。
| 包 | 角色 | ctx 键 |
|---|---|---|

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/apiproxy/README.md
README.md: bf5d0d762365deae83d004e00b94a0ba805d6a6f
README.zh.md: 06c2e37d2590c3c75d0a782bcdf4d98e7b398928
README.md: 8f9deb6add7d30bf1609cc7febcb1febafe392c1
README.zh.md: 399d45208b6d3f4152c27556523b6944432bec66

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`apps/cli/cordis.yml`](../../../apps/cli/cordis.yml).
The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml).
## Contract layer (`/api`)
@@ -10,6 +10,8 @@ Wire messages form a four-quadrant discriminated union — who initiates × requ
The layering/protocol decisions are recorded in the [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md); the browser-side consumption architecture in the [web client architecture RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md).
`session.history` pages on append-origin message boundaries: `maxMessages` counts `user/message`, `assistant/message`, and `steering/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only provenance on the same page as the replacement that cites it.
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface.
Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
所有客户端形态共用的 API 网关TS 契约(`src/api/`,不依赖 Node可从浏览器导入、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts``createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`。该包package在设计上与传输方式无关不注册任何路由载体目前为 HTTP未来可以是 IPC自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`apps/cli/cordis.yml`](../../../apps/cli/cordis.yml)。
所有客户端形态共用的 API 网关TS 契约(`src/api/`,不依赖 Node可从浏览器导入、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts``createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`。该包package在设计上与传输方式无关不注册任何路由载体目前为 HTTP未来可以是 IPC自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`apps/cli/config/base.cordis.yml`](../../../apps/cli/config/base.cordis.yml)。
## 契约层(`/api`
@@ -10,6 +10,8 @@
分层与协议决策记录在 [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中;浏览器侧消费架构记录在 [Web 客户端架构 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)中。
`session.history` 按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message``assistant/message``steering/message` 事件因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间从而让压缩compaction的仅日志溯源信息与引用它的替换留在同一页。
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections``@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元铸造一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema协议 schema 对 `values`/`value` 保持宽松loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。
会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`

View File

@@ -14,7 +14,7 @@ import type {
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { MessageSource } from '@deepseek-ai/dsh-llm'
import { lastActivityTime } from '@deepseek-ai/dsh-session'
import { isAppendSurfaceEvent, lastActivityTime } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
@@ -60,14 +60,18 @@ import { openNativePath } from './native-path-opener.ts'
/** Page size when history is called without maxMessages. */
const DEFAULT_MAX_MESSAGES = 50
/** Surface message event types (the pagination counting unit). */
/** Conversation message event types (the pagination counting unit). */
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message'])
/**
* Message-boundary pagination: count maxMessages surface messages backwards from
* the window tail; the cut is the starting seq of the oldest message group
* (chunks group via sourceEventSeqs — never cut mid-message). The tail page
* naturally includes the in-progress partial.
* Message-boundary pagination: count maxMessages append-origin messages
* backwards from the window tail. Replacement copies never entered the
* conversation a reader sees — they restate a shadowed range for the model
* alone — so they consume no quota; the page stays one contiguous raw range,
* which keeps a compaction's log-only provenance on the same page as its
* replacement. The cut is the starting seq of the oldest message group (chunks
* group via sourceEventSeqs — never cut mid-message). The tail page naturally
* includes the in-progress partial.
*/
function paginate(
events: readonly SessionEvent[],
@@ -79,7 +83,7 @@ function paginate(
let cut = 0
for (let i = window.length - 1; i >= 0; i--) {
const event = window[i] as SessionEvent
if (!MESSAGE_TYPES.has(event.type)) continue
if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue
count++
const sources = (event as { sourceEventSeqs?: number[] }).sourceEventSeqs
const groupStart = sources !== undefined && sources.length > 0 ? Math.min(event.seq, ...sources) : event.seq

View File

@@ -186,9 +186,11 @@ export interface SessionsApi {
Promise<RpcResponse<{ sessionId: SessionId }>>
/**
* Reads a window of history events; page boundaries align to message boundaries: one page =
* all raw events owned by a whole number of messages (including their chunk / tool events),
* never cut mid-message. The tail page (beforeSeq absent) additionally carries the in-flight
* Reads a window of history events; page boundaries align to append-origin message
* boundaries: one page = all raw events owned by a whole number of such messages (including
* their chunk / tool events), never cut mid-message. Model-only replacement copies consume no
* `maxMessages`, so a compaction's provenance stays on the page of its replacement. The tail
* page (beforeSeq absent) additionally carries the in-flight
* partial — chunk events already emitted for the last unfinalized message.
* Each entry pairs the raw SessionEvent with the host-computed view (tool events whose
* presenter produced one, evaluated against the registry at pagination time); the client

View File

@@ -14,7 +14,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { CallId, createToolResultMessage } from '@deepseek-ai/dsh-llm'
import { CallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
@@ -35,6 +35,35 @@ function tool(name: string, presenters: Pick<ToolDefinition, 'presentCall' | 'pr
})
}
/** Append a production-shaped human prompt to the session surface. */
function appendUserText(session: Session, text: string): SessionEvent {
return session.append('user/message', createUserMessage({
content: [{ type: 'text', text }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
}
/** Append a production-shaped assistant message to the session surface. */
function appendAssistantText(session: Session, text: string, step: number): SessionEvent {
return session.append('assistant/message', {
turn: 1,
step,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text }],
source: { kind: 'model', provider: 'p', model: 'm' },
}),
}, { surfaceOp: 'append' })
}
/**
* Append a plugin-owned log-only event. The host proxy is projection-only, so it
* declares no compaction vocabulary; the cast writes the real event shape without
* depending on the owning package.
*/
function appendExtension(session: Session, type: string, data: unknown): SessionEvent {
return (session.append as unknown as (type: string, data: unknown) => SessionEvent)(type, data)
}
async function harness(): Promise<{ ctx: Context }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -207,6 +236,55 @@ describe('mux live view computation', () => {
expect('view' in (byKey.get('tool/result:h-plain') ?? {})).toBe(false)
})
it('counts only append-origin messages toward maxMessages and keeps compaction provenance whole', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
const session = ctx.sessions.create()
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const first = appendUserText(session, 'first prompt')
appendAssistantText(session, 'first reply', 1)
const third = appendUserText(session, 'second prompt')
appendAssistantText(session, 'second reply', 2)
const shadowed = [...session.surface.nodes]
// A compaction transaction: log-only provenance immediately followed by the
// replacement that shadows the range.
const summary = appendExtension(session, 'compact/summary', {
summary: [{ type: 'text', text: 'summary' }],
shadowedRange: { start: shadowed[0], end: shadowed.at(-1) },
shadowedSeqs: shadowed,
shadowedTokenCount: 0,
provider: 'p',
model: 'm',
})
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: '<context_checkpoint>summary</context_checkpoint>' }],
source: { kind: 'plugin', plugin: 'compact' },
}), {
surfaceOp: { op: 'replace', start: shadowed[0] as number, end: shadowed.at(-1) as number },
sourceEventSeqs: [...shadowed, summary.seq],
})
const response = await api.sessions.history({
rpcId: RpcId('t-hist-compact'),
payload: { sessionId: session.id, maxMessages: 2 },
})
if (!response.result.ok) throw new Error('unreachable')
const page = response.result.value.events.map(entry => entry.event)
// Two append-origin messages fill the page even though a replacement copy of
// the same event type sits in the window: the copy is model-only.
const messages = page.filter(event => event.type === 'user/message' || event.type === 'assistant/message')
expect(messages.map(event => event.seq)).toEqual([third.seq, third.seq + 1, third.seq + 3])
expect(page.some(event => event.seq === first.seq)).toBe(false)
expect(response.result.value.hasMore).toBe(true)
// The range stays contiguous, so the checkpoint's provenance is readable on
// the same page as the checkpoint itself.
const summaryIndex = page.findIndex(event => event.seq === summary.seq)
expect(summaryIndex).toBeGreaterThan(-1)
expect(page[summaryIndex + 1]?.seq).toBe(summary.seq + 1)
expect(page.map(event => event.seq)).toEqual(page.map((_event, index) => third.seq + index))
})
it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/directory-picker-browse/README.md
README.md: 318380405214d5f25ad77e348c4e134a8981ffb3
README.zh.md: 2f88f64cc2974b8535e34eb9798f512ea109b754
README.md: 52b5fe7e89f915be3b50324628e9d5c48f1ef94c
README.zh.md: 742da39470083887a71ddba4a7c8012f0ce0ea1f

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
/* Directory-browser dialog (figma 813-23126 family). The shared Modal renders
* headless here — mask, card, Escape only — and this module owns the figma
* frame: 600×420 card (viewport-clamped), header (title + crumbs, l3 separator),
* frame: 680×500 card (viewport-clamped; upsized from the figma 600×420),
* header (title + crumbs, l3 separator),
* the one-or-two-column Miller content, and the bordered footer. */
/* Doubled class beats Modal's own .dialog regardless of stylesheet order. */
@@ -8,19 +9,32 @@
* columns scroll, so shrinking the height keeps Open/Cancel reachable
* instead of clipping them below a fixed overlay. */
.dialog.dialog {
width: min(600px, 100%);
height: min(420px, calc(100dvh - 32px));
width: min(680px, 100%);
height: min(500px, calc(100dvh - 32px));
padding: 0;
gap: 0;
/* The Modal card is an l2 surface and the columns below scroll on it:
* rebind the scrollbar indirection to the elevation pair here, on the
* surface, so it inherits down to whichever descendant scrolls (the
* rebinding contract in ui-theme styles/scrollbar.css). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
/* Header block: pl24 pr14 pt22 pb12, 8px between title row and crumb row. */
/* Card-scope wrapper hosting the path editor's Escape and focus-leave
* observers; display:contents keeps header/content/footer as direct flex
* children of the Modal card. */
.editorScope {
display: contents;
}
/* Header block: pl24 pr14 pt16 pb8, 8px between title row and crumb row. */
.header {
display: flex;
flex-direction: column;
gap: 8px;
flex: none;
padding: 22px 14px 12px 24px;
padding: 16px 14px 8px 24px;
border-bottom: 1px solid var(--dsw-alias-border-l3);
}
@@ -54,8 +68,12 @@
align-items: stretch;
flex: 1 1 0;
min-height: 0;
gap: 20px;
/* 12px of row gap on each side of the divider; the left side reads wider
* by the column's trailing 8px scrollbar clearance, which is deliberate —
* the thumb needs that room, the right pane's rows do not. */
gap: 12px;
overflow-x: auto;
scrollbar-width: none;
}
.crumbTrail {
@@ -126,28 +144,36 @@
color: var(--dsw-alias-label-primary);
}
/* Miller content: pt16 px24; columns are 256 wide (or full width solo) with
* the hairline divider centered between them; each column scrolls alone. */
/* Miller content: symmetric 16px vertical padding so the divider clears the
* header and footer rules evenly; each column scrolls alone (column widths
* live at .column). */
.content {
display: flex;
flex-direction: column;
flex: 1 1 0;
min-height: 0;
padding: 16px 24px 0;
/* Anchors the floating loading pill (.loadingFloat). */
position: relative;
/* Right inset is slimmer than the left: the trailing column's own 8px
* scrollbar clearance makes up the optical difference. */
padding: 16px 16px 16px 24px;
}
/* Columns split the row evenly around the divider (a solo column takes the
* whole row); 256px is the floor below which the row scrolls (scrollbar
* hidden, the effect pins the child pane into view) instead of squeezing
* the panes. */
.column {
display: flex;
flex-direction: column;
gap: 2px;
width: 256px;
flex: none;
overflow-y: auto;
}
.columnWide {
width: 100%;
flex: 1 1 0;
min-width: 256px;
overflow-y: auto;
/* The themed scrollbar occupies the column's edge (styled scrollbars are
* classic, gutter-taking ones); the extra clearance keeps the row pills
* clear of the thumb. */
padding-right: 8px;
}
.divider {
@@ -216,6 +242,10 @@
.status,
.error {
padding: 4px;
/* The loading pill occupies the opposite corner while a stale status stays
* visible. Reserve its widest localized footprint so wrapped text cannot
* run underneath it on a narrow card. */
padding-right: 120px;
font-size: 12px;
line-height: 18px;
}
@@ -228,8 +258,25 @@
color: var(--dsw-alias-state-error-primary);
}
/* Footer: l3 separator on top, pt12 px24, New-folder pinned left; the fixed
* card leaves the figma 28px below the 36px buttons. */
/* The slow-scan indicator floats over the content's bottom-RIGHT corner on
* the card background instead of occupying a row: a scan must never shift
* the columns' height, and the stale view keeps rendering beneath it (it
* only appears at all once a scan outlives SLOW_SCAN_DELAY_MS). Right,
* not left: the truncated/error status rows flow at the bottom LEFT and
* stay on screen through a scan, with their reserved right padding keeping
* both legible even on a narrow card. After .status in the cascade — the
* element carries both classes and this padding must win the
* same-specificity race. */
.loadingFloat {
position: absolute;
right: 16px;
bottom: 8px;
padding: 2px 8px;
background: var(--dsw-alias-bg-layer-2);
}
/* Footer: l3 separator on top, symmetric padding so the row sits vertically
* centered in the bar; New-folder and the show-hidden toggle pin left. */
.footerBar {
display: flex;
align-items: center;
@@ -238,10 +285,42 @@
flex-wrap: wrap;
gap: 8px;
flex: none;
padding: 12px 24px 28px;
padding: 16px 24px;
border-top: 1px solid var(--dsw-alias-border-l3);
}
/* Show-hidden toggle: a subtle fixed-label text button left of the gap;
* the pressed state seats a check glyph after the label (Menu's selected
* vocabulary; trailing so the label never shifts) instead of flipping the
* wording. */
.showHiddenToggle {
display: inline-flex;
align-items: center;
gap: 4px;
border: none;
background: transparent;
padding: 0;
font-size: 13px;
line-height: 20px;
font-weight: 500;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
white-space: nowrap;
}
.showHiddenToggle:hover {
color: var(--dsw-alias-label-primary);
}
.showHiddenToggle:disabled {
color: var(--dsw-alias-label-caption);
cursor: default;
}
.showHiddenToggleActive {
color: var(--dsw-alias-label-primary);
}
.footerGap {
flex: 1 1 0;
}

View File

@@ -1,22 +1,32 @@
/**
* The in-app workspace-directory browser (figma Harness 813-23126 family): a
* 600×420 dialog (clamped to short/narrow viewports — the Miller row scrolls
* 680×500 dialog (clamped to short/narrow viewports — the Miller row scrolls
* sideways, the columns scroll down) whose header carries the title, the selection-path
* breadcrumb, and a click-to-edit path zone; below it a Miller view — one
* full-width level until a row is selected, then two 256px columns (level |
* selected folder's children) around a hairline divider. Selecting in the
* full-width level until a row is selected, then two columns splitting the
* row evenly (256px floor; level | selected folder's children) around a
* hairline divider. Navigations land selection-anchored and quiet: the
* previous view keeps rendering while a crumb jump or a submitted path is
* scanned, then target and parent legs land as one two-pane frame (a slow
* parent leg falls back to landing the target alone and upgrading in
* place), so stepping back keeps two panes away from the display root and
* navigation never flashes an intermediate frame. Selecting in the
* right column shifts the view one level deeper. "New folder" opens a nested
* create dialog targeting the selected folder (or the level itself) and
* selects the created folder. Open adopts the selected folder, falling back
* to the listed level. Pure consumer of the injected browse calls — the
* owning flow decides what "Open" means and owns the workspace-creation
* error surface. Hidden entries are host-flagged and filtered here (a
* show-hidden toggle is deferred work, client-side only).
* error surface. Hidden entries are host-flagged and hidden by default; the
* footer's fixed-label "Show hidden files" toggle (aria-pressed, check when
* on) reveals them (client-side only). The path editor opens seeded with a
* trailing separator, and while the draft's directory part names a listed
* level, its final segment prefix-filters that level's rows (a dot-led
* prefix also reveals the hidden entries it names).
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import {
Button, IconChevronRightOutline14, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, Modal,
Button, IconCheckOutline16, IconChevronRightOutline14, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, Modal,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client'
@@ -47,6 +57,24 @@ function failureText(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/**
* How long a scan may stay visually silent before the floating "Loading…"
* pill appears. The stale view keeps rendering while a scan is in flight, so
* a listing that settles inside this window swaps the panes with no
* intermediate frame at all; only a genuinely slow host (a network mount, a
* cold disk) surfaces the indicator.
*/
const SLOW_SCAN_DELAY_MS = 300
/**
* How long a navigation landing waits for its parent leg before committing
* the target alone. Inside the window both legs land as ONE two-pane frame —
* no single-pane flash between them; past it the target commits single-pane
* at once (an Enter-submitted navigation is never held hostage by a stalled
* parent) and the late parent leg upgrades the landing in place.
*/
const PARENT_LEG_WAIT_MS = 200
/**
* 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
@@ -59,17 +87,59 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE
return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail]
}
/**
* The listing's platform separator, inferred from the home path the host
* stamped — never from typed text or entry paths, where a backslash is a
* legal POSIX name character. Still a heuristic at the last step: a POSIX
* home directory whose own name contains a backslash would misread.
* TODO: replace with a host-stamped `separator` field on the wire
* DirectoryListing so the platform fact travels verbatim (the trade-off is
* recorded in the directory-picker capability seam Agent Note).
*/
function separatorOf(listing: DirectoryListing): '\\' | '/' {
return listing.home.includes('\\') ? '\\' : '/'
}
/**
* The path draft's final segment, when its directory part is exactly the
* level `listing` lists — the segment the level prefix-filters on while the
* user types. Any other draft (no separator yet, or naming some other
* directory) leaves the level unfiltered. The directory part compares
* exactly (it is the host's own path text, reached by seeding or erasing);
* only the name filter downstream is case-insensitive.
*/
function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null {
if (draft === null) return null
const sep = separatorOf(listing)
const cut = draft.lastIndexOf(sep)
if (cut === -1) return null
const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}`
return draft.slice(0, cut + 1) === level ? draft.slice(cut + 1) : null
}
/** One column of folder rows (the Miller view renders one or two of these). */
function LevelColumn({ entries, selectedPath, busy, onPick, wide }: {
function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPrefix, pathEditing }: {
entries: readonly DirectoryEntry[]
selectedPath: string | null
busy: boolean
onPick: (entry: DirectoryEntry) => void
wide: boolean
showHidden: boolean
filterPrefix: string | null
pathEditing: boolean
}) {
const visible = entries.filter((entry) => {
// The selection is exempt from both filters: it anchors the two-pane
// view (crumbs and the child pane point at it), so neither the hidden
// filter after a dot-reveal pick nor a prefix miss may orphan it.
if (entry.path === selectedPath) return true
if (filterPrefix !== null && !entry.name.toLowerCase().startsWith(filterPrefix.toLowerCase())) return false
// A dot-led prefix names hidden entries explicitly, so matching ones
// surface even while the toggle keeps the rest hidden.
return showHidden || !entry.hidden || filterPrefix?.startsWith('.') === true
})
return (
<div className={clsx(css.column, wide && css.columnWide)} role="list">
{entries.filter(entry => !entry.hidden).map((entry) => {
<div className={css.column} role="list">
{visible.map((entry) => {
const selected = entry.path === selectedPath
return (
// The wrapper carries the list semantics; the row keeps its NATIVE
@@ -80,6 +150,15 @@ function LevelColumn({ entries, selectedPath, busy, onPick, wide }: {
aria-current={selected || undefined}
className={clsx(css.row, selected && css.rowSelected)}
disabled={busy}
// While the path editor is open, keep focus in it: a focus
// steal on mousedown would blur the editor and (in engines
// where the blur lands before our guards) drop this click.
// Outside editing, rows keep native focus behavior.
onMouseDown={pathEditing ? (event) => { event.preventDefault() } : undefined}
// Editing-time focus parking happens after commit (the
// DirectoryBrowser refocus effect): a right-pane pick replaces
// this very column, so focusing the clicked node here would
// still fall to body.
onClick={() => { onPick(entry) }}
>
{selected
@@ -107,9 +186,19 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
const [selected, setSelected] = useState<DirectoryEntry | null>(null)
const [child, setChild] = useState<DirectoryListing | null>(null)
const [loading, setLoading] = useState(false)
// Derived from `loading` and `scanWindow` by the slow-scan effect below:
// true only once the current listing call has been in flight for
// SLOW_SCAN_DELAY_MS, so fast listings never render the indicator at all.
const [slowScan, setSlowScan] = useState(false)
// Every listing call owns a fresh silence window. `loading` may stay true
// across a superseding row pick or across a navigation's target and parent
// legs, so its boolean edge cannot identify the start of each scan.
const [scanWindow, setScanWindow] = useState(0)
const [error, setError] = useState<string | null>(null)
// Path-edit state: null = breadcrumb mode; a string = the draft being typed.
const [pathDraft, setPathDraft] = useState<string | null>(null)
// Show-hidden toggle state (pure client-side filter, reset on each open).
const [showHidden, setShowHidden] = useState(false)
// Create-folder state: null = closed; a string = the nested dialog's draft.
const [folderDraft, setFolderDraft] = useState<string | null>(null)
const [creatingFolder, setCreatingFolder] = useState(false)
@@ -148,36 +237,128 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
return ++requestSeq.current
}, [])
/** Hide any prior indicator and start a fresh silence window for one listing call. */
const restartSlowScanWindow = useCallback((): void => {
setSlowScan(false)
setScanWindow(value => value + 1)
}, [])
/** Launch one listing under a fresh controller so a later supersession can abort it. */
const launchListing = useCallback((path: string | undefined): { seq: number; scan: Promise<DirectoryListing> } => {
const seq = supersede()
const controller = new AbortController()
scanController.current = controller
restartSlowScanWindow()
return { seq, scan: listDirectory(path, controller.signal) }
}, [supersede, listDirectory])
}, [supersede, restartSlowScanWindow, listDirectory])
/** Replace the whole view with one freshly listed level (no selection). */
/**
* Launch a follow-up listing under the CURRENT supersession seq: a newer
* intent aborts it like the leg it continues, and it supersedes nothing.
*/
const continueScan = useCallback((path: string): Promise<DirectoryListing> => {
const controller = new AbortController()
scanController.current = controller
restartSlowScanWindow()
return listDirectory(path, controller.signal)
}, [restartSlowScanWindow, listDirectory])
/**
* Replace the whole view with a freshly navigated level. Away from the
* display root — the same collapse the crumb header renders, so crumbs and
* pane shape never disagree — the landing is two-pane: the target's ACTUAL
* parent-level entry re-selected (left pane = parent, right pane = the
* target), so a crumb jump reads as stepping back one pane. Both legs land
* as one frame when the parent leg settles within
* {@link PARENT_LEG_WAIT_MS}; past that bound (or at the display root) the
* target commits alone — single wide level, the editor closes, loading
* ends — and a late parent leg still upgrades the landing in place. A
* failed parent leg, or a truncated parent window that lacks the target,
* leaves the single-pane landing — the upgrade must never orphan the
* selection it exists to anchor. Until whichever commit comes first, the
* previous view keeps rendering: navigation swaps the panes, it never
* blanks them.
*/
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)
// The single-pane landing; `landed` makes it first-commit-only, while
// the two-pane commit below may still upgrade an already-landed view.
let landed = false
const landSingle = (): void => {
if (landed || seq !== requestSeq.current) return
landed = true
setParent(target)
setSelected(null)
setChild(null)
setLoading(false)
setPathDraft(null)
}
// Arity is label-independent: only the collapsed chain's depth decides.
if (displayCrumbs(target, '').length < 2) { landSingle(); return }
const parentCrumb = target.crumbs.at(-2)
/* v8 ignore next -- narrowing: a two-deep display chain implies a parent crumb (root-to-target inclusive). */
if (parentCrumb === undefined) { landSingle(); return }
continueScan(parentCrumb.path).then((parentLevel) => {
if (seq !== requestSeq.current) return
// Windows resolves a typed path preserving its case; anchor on the
// parent level's actual entry so selection comparisons hold.
const sep = separatorOf(parentLevel)
const fold = (value: string): string => (sep === '\\' ? value.toLowerCase() : value)
const match = parentLevel.entries.find(entry => fold(entry.path) === fold(target.path))
if (match === undefined) { landSingle(); return }
landed = true
setParent(parentLevel)
setSelected(match)
setChild(target)
// Idempotent on a late upgrade of a timed-out landing: reopening the
// editor or starting a newer scan supersedes this seq, so reaching
// here means the draft is closed and the loading flag is this
// navigation's own.
setLoading(false)
setPathDraft(null)
}, () => {
// The parent-leg failure (its abort included) never surfaces: the
// target listed fine, and nobody asked to see the parent level.
landSingle()
})
window.setTimeout(landSingle, PARENT_LEG_WAIT_MS)
}, (reason: unknown) => {
if (seq !== requestSeq.current) return
setLoading(false)
setError(failureText(reason))
})
}, [launchListing])
}, [launchListing, continueScan])
/** Select a row of the listed level and preview its children on the right. */
// Editor-close focus parking (consumed by the refocus effect below the
// miller-row ref): a pick parks on the selection's row, Enter and an
// input-focused Escape park on the crumb edit zone that replaces the
// input. Pointer-out cancels never set (or clear) these — yanking focus
// back from wherever the user clicked would be worse than the fall.
const refocusPick = useRef(false)
const refocusEditZone = useRef(false)
const pathInputRef = useRef<HTMLInputElement | null>(null)
const editZoneRef = useRef<HTMLButtonElement | null>(null)
/**
* Select a row of the listed level and preview its children on the right.
* Deliberately NOT one-frame like navigate(): a pick's first duty is the
* immediate selected state on the clicked row, and the pane split IS that
* feedback (aria-current pill, crumbs following the selection) — holding
* it back for the child listing would make clicks feel dropped. The quiet
* rule governs whole-view replacement, where nothing acknowledges the
* click but the swap itself.
*/
const select = useCallback((entry: DirectoryEntry) => {
const { seq, scan } = launchListing(entry.path)
// A pick while the path editor is open adopts the (filtered) row and
// closes the editor — the draft served its purpose. Focus re-parks on
// the selection after commit (see the refocus effect below).
if (pathDraft !== null) refocusPick.current = true
setPathDraft(null)
setSelected(entry)
setChild(null)
setLoading(true)
@@ -193,8 +374,31 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
// An unreadable selection cannot be the committing target while the
// breadcrumb still names the level: fall back to the single pane.
setSelected(null)
// Clearing the selection can unmount the very row the pick parked
// focus on (a dot-revealed hidden row re-hides); the refocus effect
// re-parks on the edit zone only if focus actually fell to body.
refocusEditZone.current = true
})
}, [launchListing])
}, [launchListing, pathDraft])
/** Abandon path editing (Escape or clicking away) and restore the crumb view. */
const cancelPathEdit = useCallback(() => {
// Cancel also withdraws a navigation the editor already launched: its
// late success must not jump to the cancelled path, so the pending
// request is superseded and the view leaves the loading state.
supersede()
setLoading(false)
setPathDraft(null)
setError(null)
// Editing may have superseded the selection's preview request; a
// selection with no preview would render a half-empty two-pane view, so
// cancel falls back to the single-pane level.
if (child === null) setSelected(null)
// With no level listed yet (the editor superseded the initial home
// listing), plain cancellation would leave a permanently blank picker:
// restart the home listing.
if (parent === null) navigate()
}, [supersede, child, parent, navigate])
/** A right-column pick advances the view one level: child becomes the level. */
const advance = useCallback((entry: DirectoryEntry) => {
@@ -213,14 +417,24 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
setSelected(null)
setChild(null)
setCreatingFolder(false)
setShowHidden(false)
navigate()
return
}
supersede()
// Closing mid-scan leaves nothing to load: without this edge the
// slow-scan effect keeps arming while hidden and the reopened dialog
// would show the indicator on its first frame instead of waiting out a
// fresh silence window (reopen's navigate() produces no loading edge).
setLoading(false)
setError(null)
setPathDraft(null)
setFolderDraft(null)
setCreateError(null)
// A close mid-flight (failed Enter, then Cancel) may leave refocus
// flags armed; retire them so a later render cannot consume them.
refocusPick.current = false
refocusEditZone.current = false
}, [open, navigate, supersede])
/** The folder a create or Open acts on: the selection, else the listed level. */
@@ -249,6 +463,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
// create target becomes the listed level and the new folder its selection.
const { seq, scan } = launchListing(targetPath)
setLoading(true)
// Symmetric with navigate/select: a launched scan clears the stale
// failure text (and keeps the floating indicator's corner the only
// occupant of the content's right edge while it shows).
setError(null)
scan.then((level) => {
/* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */
if (seq !== requestSeq.current) return
@@ -268,6 +486,19 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
})
}
// The slow-scan gate for the loading indicator: each listing call restarts
// the timer even when a superseding scan or a navigation's parent leg keeps
// `loading` continuously true. A settle inside its own window means the swap
// happened with nothing shown.
useEffect(() => {
if (!loading) {
setSlowScan(false)
return
}
const timer = window.setTimeout(() => { setSlowScan(true) }, SLOW_SCAN_DELAY_MS)
return () => { window.clearTimeout(timer) }
}, [loading, scanWindow])
// After the hooks: a closed dialog renders nothing and evaluates no copy.
const crumbSource = child ?? parent
const crumbs = crumbSource === null ? [] : displayCrumbs(crumbSource, t('browser.home'))
@@ -285,6 +516,37 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
const row = millerRowRef.current
if (row !== null && childPath !== undefined) row.scrollLeft = row.scrollWidth
}, [childPath])
// Every editor exit that would drop focus to body re-parks it after
// commit, so keyboard traversal stays inside the dialog (the Modal has no
// focus trap): a pick lands on the selection's row — aria-current in the
// freshly rendered left pane, which survives even a right-pane advance
// replacing the picked button's column — while Enter and an input-focused
// Escape land on the crumb edit zone that replaces the input.
useEffect(() => {
if (pathDraft !== null) return
if (refocusPick.current) {
refocusPick.current = false
refocusEditZone.current = false
const rowHost = millerRowRef.current
/* v8 ignore next -- narrowing guard: the miller row is mounted whenever a pick just committed. */
if (rowHost === null) return
const row = rowHost.querySelector<HTMLButtonElement>('button[aria-current="true"]')
/* v8 ignore next -- narrowing guard: the pick that set the flag just rendered its aria-current row. */
if (row === null) return
row.focus()
return
}
if (refocusEditZone.current) {
refocusEditZone.current = false
// Re-park only when the close actually dropped focus to body; focus
// the user parked elsewhere (a surviving row) stays theirs.
if (document.activeElement !== document.body) return
const zone = editZoneRef.current
/* v8 ignore next -- narrowing guard: crumb mode renders the edit zone whenever the editor just closed. */
if (zone === null) return
zone.focus()
}
})
if (!open) return null
const twoPane = selected !== null
@@ -310,149 +572,220 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
className={clsx(css.dialog)}
headless
>
<div className={css.header}>
<h2 className={css.title}>{t('browser.title')}</h2>
<div className={css.crumbBar}>
{pathDraft === null
? (
<>
<span className={css.crumbTrail} role="navigation" ref={crumbTrailRef}>
{crumbs.map((crumb, index) => (
<span key={crumb.path} className={css.crumbSeat}>
{index > 0 && <IconChevronRightOutline14 size={12} className={css.crumbChevron} />}
<button
type="button"
className={css.crumb}
disabled={parentInert}
onClick={() => { navigate(crumb.path) }}
>
{crumb.name}
</button>
</span>
))}
</span>
{/* The empty zone right of the crumbs is the path-edit affordance. */}
<button
type="button"
className={css.crumbEditZone}
aria-label={t('browser.editPath')}
// Stays available with no listed level: when the home
// listing itself fails, typing an absolute path is the one
// remaining way forward.
disabled={parentInert}
onClick={() => {
{/* Path-edit cancellation is observed at the card scope, not the
* input: once Tab parks focus on a filtered row the input is off the
* event path, yet Escape must still collapse the editor (not the
* dialog) and a further focus move out of the card must still
* cancel. display:contents keeps header/content/footer as direct
* flex children of the Modal card. */}
<div
className={css.editorScope}
onKeyDown={(event) => {
if (event.key !== 'Escape' || pathDraft === null) return
// stopPropagation keeps the card-scope Escape from the Modal's
// document listener — the same containment the input previously
// provided for itself.
event.stopPropagation()
// Escape while the input holds focus is about to unmount it; with
// focus already parked on a row, that row survives the cancel and
// keeps focus naturally. Assignment (not a conditional set) also
// retires a stale flag a failed or still-upgrading Enter left.
refocusEditZone.current = document.activeElement === pathInputRef.current
cancelPathEdit()
}}
// Focus leaving THIS dialog card while editing cancels like Escape.
// Guarded non-cancel paths: window/tab focus loss (document no
// longer focused); a focus move that stays inside the card (Tab
// onto the filtered rows or the footer toggle); and pointer paths,
// where rows and the toggle suppress focus steal on mousedown while
// editing so their click lands first. Enter keeps focus in the
// input while its navigation is in flight, so a submitted path is
// never withdrawn here. Anchored to this card via closest, not any
// [role="dialog"], so focus escaping into a sibling overlay cancels.
onBlur={(event) => {
if (pathDraft === null) return
if (!document.hasFocus()) return
const card = event.currentTarget.closest('[role="dialog"]')
/* v8 ignore next -- narrowing guard: this scope always renders inside the Modal card. */
if (card === null) return
if (event.relatedTarget instanceof Node && card.contains(event.relatedTarget)) return
// The user moved focus out of the card themselves: cancel without
// re-parking (a lingering Enter-failure flag must not yank focus
// back either).
refocusEditZone.current = false
cancelPathEdit()
}}
>
<div className={css.header}>
<h2 className={css.title}>{t('browser.title')}</h2>
<div className={css.crumbBar}>
{pathDraft === null
? (
<>
<span className={css.crumbTrail} role="navigation" ref={crumbTrailRef}>
{crumbs.map((crumb, index) => (
<span key={crumb.path} className={css.crumbSeat}>
{index > 0 && <IconChevronRightOutline14 size={12} className={css.crumbChevron} />}
<button
type="button"
className={css.crumb}
disabled={parentInert}
onClick={() => { navigate(crumb.path) }}
>
{crumb.name}
</button>
</span>
))}
</span>
{/* The empty zone right of the crumbs is the path-edit affordance. */}
<button
type="button"
className={css.crumbEditZone}
aria-label={t('browser.editPath')}
// Stays available with no listed level: when the home
// listing itself fails, typing an absolute path is the one
// remaining way forward.
disabled={parentInert}
ref={editZoneRef}
onClick={() => {
// Opening the editor supersedes any pending listing: a
// settlement landing before the first keystroke would
// otherwise close the editor via navigate's draft reset.
supersede()
setLoading(false)
setPathDraft(selected?.path ?? parent?.path ?? '')
}}
/>
</>
)
: (
<input
className={css.pathInput}
value={pathDraft}
aria-label={t('browser.editPath')}
autoFocus
disabled={parentInert}
onChange={(event) => {
supersede()
setLoading(false)
// Seed with a trailing separator so typing immediately
// continues into child names (and prefix-filters below).
// No listed level means nothing to seed from (the editor
// is the recovery path for a failed home listing).
if (parent === null) {
setPathDraft('')
return
}
const base = selected?.path ?? parent.path
const sep = separatorOf(parent)
setPathDraft(base.endsWith(sep) ? base : `${base}${sep}`)
}}
/>
</>
)
: (
<input
className={css.pathInput}
value={pathDraft}
aria-label={t('browser.editPath')}
autoFocus
ref={pathInputRef}
disabled={parentInert}
onChange={(event) => {
// Editing the draft supersedes any in-flight navigation:
// its completion must neither clear the newer text nor
// repopulate the view with the older path.
supersede()
setLoading(false)
setPathDraft(event.target.value)
}}
{...compositionGuard}
onKeyDown={(event) => {
if (event.key === 'Enter' && !composingRef.current) {
event.preventDefault()
// Trim only detects a blank draft; the Host gets the
// original text — a real directory name may end in
// whitespace, and trimming would list its sibling.
if (pathDraft.trim() !== '') navigate(pathDraft)
}
if (event.key === 'Escape') {
event.stopPropagation()
// Cancel also withdraws a navigation the editor already
// launched: its late success must not jump to the
// cancelled path, so the pending request is superseded
// and the view leaves the loading state.
supersede()
setLoading(false)
setPathDraft(null)
setError(null)
// Editing may have superseded the selection's preview
// request; a selection with no preview would render a
// half-empty two-pane view, so cancel falls back to the
// single-pane level.
if (child === null) setSelected(null)
// With no level listed yet (the editor superseded the
// initial home listing), plain cancellation would leave a
// permanently blank picker: restart the home listing.
if (parent === null) navigate()
}
}}
setPathDraft(event.target.value)
}}
{...compositionGuard}
// Escape and focus-leave cancellation live on the card-scope
// wrapper above (they must work after focus Tabs onto the
// rows); this handler owns only submission.
onKeyDown={(event) => {
if (event.key === 'Enter' && !composingRef.current) {
event.preventDefault()
// Trim only detects a blank draft; the Host gets the
// original text — a real directory name may end in
// whitespace, and trimming would list its sibling.
if (pathDraft.trim() !== '') {
// Success will unmount the still-focused input; park
// focus on the returning crumb edit zone (a failure
// keeps the editor, so the flag waits until close).
refocusEditZone.current = true
navigate(pathDraft)
}
}
}}
/>
)}
</div>
</div>
<div className={css.content}>
<div className={css.millerRow} ref={millerRowRef}>
{parent !== null && (
<LevelColumn
entries={parent.entries}
selectedPath={selected?.path ?? null}
busy={parentInert}
onPick={select}
showHidden={showHidden}
filterPrefix={draftPrefixFor(parent, pathDraft)}
pathEditing={draftPending}
/>
)}
</div>
</div>
<div className={css.content}>
<div className={css.millerRow} ref={millerRowRef}>
{parent !== null && (
<LevelColumn
entries={parent.entries}
selectedPath={selected?.path ?? null}
busy={parentInert}
onPick={select}
wide={!twoPane}
/>
)}
{twoPane && <span className={css.divider} />}
{twoPane && child !== null && (
<LevelColumn
entries={child.entries}
selectedPath={null}
busy={parentInert}
onPick={advance}
wide={false}
/>
)}
</div>
{loading && <div className={css.status} role="status">{t('browser.loading')}</div>}
{/* The backend bounds a level at its complete-result limit; say so
{twoPane && <span className={css.divider} />}
{twoPane && child !== null && (
<LevelColumn
entries={child.entries}
selectedPath={null}
busy={parentInert}
onPick={advance}
showHidden={showHidden}
filterPrefix={draftPrefixFor(child, pathDraft)}
pathEditing={draftPending}
/>
)}
</div>
{loading && slowScan
&& <div className={clsx(css.status, css.loadingFloat)} role="status">{t('browser.loading')}</div>}
{/* The backend bounds a level at its complete-result limit; say so
* whenever a visible pane was cut instead of letting the tail of a
* huge directory go silently missing. */}
{(parent?.truncated === true || child?.truncated === true) && !loading
* huge directory go silently missing. The note describes the panes
* on screen, so an in-flight scan leaves it alone — hiding it while
* the stale view still shows the cut level would shift the columns
* on every navigation away from it. */}
{(parent?.truncated === true || child?.truncated === true)
&& <div className={css.status} role="status">{t('browser.truncated')}</div>}
{error !== null && <div className={css.error} role="alert">{error}</div>}
</div>
<div className={css.footerBar}>
<Button
variant="outline"
icon={<IconPlusOutline16 size={14} />}
disabled={parent === null || loading || parentInert || draftPending}
onClick={() => {
setFolderDraft('')
setCreateError(null)
}}
>
{t('browser.newFolder')}
</Button>
<span className={css.footerGap} />
<Button variant="outline" className={clsx(css.footerAction)} disabled={parentInert} onClick={onClose}>{t('browser.cancel')}</Button>
<Button
variant="primary"
className={clsx(css.footerAction)}
disabled={targetPath === null || loading || parentInert || draftPending}
/* v8 ignore next -- narrowing guard: Open disables while no target exists. */
onClick={() => { if (targetPath !== null) onOpen(targetPath) }}
>
{t('browser.open')}
</Button>
{error !== null && <div className={css.error} role="alert">{error}</div>}
</div>
<div className={css.footerBar}>
<Button
variant="outline"
icon={<IconPlusOutline16 size={14} />}
disabled={parent === null || loading || parentInert || draftPending}
onClick={() => {
setFolderDraft('')
setCreateError(null)
}}
>
{t('browser.newFolder')}
</Button>
<button
type="button"
className={clsx(css.showHiddenToggle, showHidden && css.showHiddenToggleActive)}
aria-pressed={showHidden}
disabled={parentInert}
// The toggle composes with the path editor (dot-led prefixes and
// this filter interleave): while editing, don't steal focus, so
// toggling never blur-cancels a draft mid-thought. Outside editing
// it keeps native focus behavior.
onMouseDown={draftPending ? (event) => { event.preventDefault() } : undefined}
onClick={() => { setShowHidden(prev => !prev) }}
>
{t('browser.showHidden')}
{/* Trailing check (Menu's selected vocabulary): the label never
* shifts when the pressed state toggles. */}
{showHidden && <IconCheckOutline16 size={14} />}
</button>
<span className={css.footerGap} />
<Button variant="outline" className={clsx(css.footerAction)} disabled={parentInert} onClick={onClose}>{t('browser.cancel')}</Button>
<Button
variant="primary"
className={clsx(css.footerAction)}
disabled={targetPath === null || loading || parentInert || draftPending}
/* v8 ignore next -- narrowing guard: Open disables while no target exists. */
onClick={() => { if (targetPath !== null) onOpen(targetPath) }}
>
{t('browser.open')}
</Button>
</div>
</div>
{/* Nested create dialog (figma 813:23278): names one folder inside the target. */}
<Modal

View File

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

View File

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

View File

@@ -29,6 +29,25 @@ function listingFor(path?: string): DirectoryListing {
],
truncated: false,
},
'/': {
path: '/',
home: HOME,
crumbs: [{ name: '/', path: '/', hidden: false }],
entries: [{ name: 'home', path: '/home', hidden: false }],
truncated: false,
},
[`${HOME}/.config`]: {
path: `${HOME}/.config`,
home: HOME,
crumbs: [
{ name: '/', path: '/', hidden: false },
{ name: 'home', path: '/home', hidden: false },
{ name: 'u', path: HOME, hidden: false },
{ name: '.config', path: `${HOME}/.config`, hidden: true },
],
entries: [],
truncated: false,
},
[DOCS]: {
path: DOCS,
home: HOME,
@@ -92,6 +111,12 @@ function rowButton(item: HTMLElement): HTMLButtonElement {
}
describe('DirectoryBrowser', () => {
it('renders nothing and launches no listing while initially closed', () => {
const b = mount({ open: false })
expect(screen.queryByRole('dialog')).toBeNull()
expect(b.listDirectory).not.toHaveBeenCalled()
})
it('opens at the Host home as one wide column, hides hidden entries, and roots the crumbs at Home', async () => {
const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
@@ -103,6 +128,28 @@ describe('DirectoryBrowser', () => {
expect(screen.queryByRole('button', { name: '/' })).toBeNull()
})
it('shows hidden entries when the toggle is on and hides them again on close', async () => {
const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
expect(screen.queryByText('.config')).toBeNull()
// The fixed-label toggle reports its state through aria-pressed. Its
// mousedown never steals focus (so it composes with the path editor).
const toggle = screen.getByRole('button', { name: 'browser.showHidden' })
expect(toggle.getAttribute('aria-pressed')).toBe('false')
fireEvent.mouseDown(toggle)
fireEvent.click(toggle)
expect(toggle.getAttribute('aria-pressed')).toBe('true')
expect(screen.getByText('.config')).toBeTruthy()
fireEvent.click(toggle)
expect(toggle.getAttribute('aria-pressed')).toBe('false')
expect(screen.queryByText('.config')).toBeNull()
// Close resets the toggle.
b.view.rerender(<DirectoryBrowser {...b.props} open={false} />)
b.view.rerender(<DirectoryBrowser {...b.props} open />)
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
expect(screen.queryByText('.config')).toBeNull()
})
it('selects a row into the two-pane view: children preview right, crumbs follow the selection', async () => {
const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
@@ -153,7 +200,7 @@ describe('DirectoryBrowser', () => {
expect(signals[2]?.aborted).toBe(true)
})
it('jumps back through a crumb into a fresh single-column level', async () => {
it('a crumb jump to the display root (home) lands the single wide level', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(rowButton(screen.getByRole('listitem')))
@@ -164,6 +211,393 @@ 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('lands the target single-pane at the wait bound, aborts a superseded parent leg on the wire, and drops its late resolution', async () => {
vi.useFakeTimers()
try {
const signals: (AbortSignal | undefined)[] = []
const settlers: ((value: DirectoryListing) => void)[] = []
// Only the FIRST explicit HOME request (the parent leg) hangs; the
// later home crumb jump lists normally.
let homeCalls = 0
const listDirectory = vi.fn((path?: string, signal?: AbortSignal) => {
signals.push(signal)
if (path === HOME && ++homeCalls === 1) {
return new Promise<DirectoryListing>((resolve) => { settlers.push(resolve) })
}
return Promise.resolve(listingFor(path))
})
mount({ listDirectory })
await act(async () => {})
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 settled but the parent leg hangs: inside the wait bound
// nothing commits yet.
await act(async () => {})
expect(settlers).toHaveLength(1)
expect(screen.getByLabelText('browser.editPath', { selector: 'input' })).toBeTruthy()
// The wait bound expires: the target commits alone — editor closed,
// single-pane DOCS level.
await act(async () => { vi.advanceTimersByTime(200) })
expect(screen.getByRole('listitem').textContent).toBe('harness')
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
expect(columns()).toHaveLength(1)
// A newer jump aborts the pending parent leg ON THE WIRE, not merely
// dropping its settlement.
fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
expect(signals[2]?.aborted).toBe(true)
await act(async () => {})
expect(screen.getByRole('listitem').textContent).toBe('Documents')
// Its late resolution changes nothing either.
await act(async () => { settlers[0]!(listingFor(HOME)) })
expect(columns()).toHaveLength(1)
expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull()
} finally {
vi.useRealTimers()
}
})
/**
* Listing fake whose explicit-path scans stay pending until the test
* settles them by path; the absent-path form (the initial home listing)
* resolves normally so mounting is a one-flush setup.
*/
function manualLister() {
const settlers = new Map<string, (value: DirectoryListing) => void>()
const listDirectory = vi.fn((path?: string, _signal?: AbortSignal) => {
if (path === undefined) return Promise.resolve(listingFor(path))
return new Promise<DirectoryListing>((resolve) => { settlers.set(path, resolve) })
})
return { settlers, listDirectory }
}
it('lands a navigation as ONE two-pane frame: the stale view holds until both legs arrive', async () => {
vi.useFakeTimers()
try {
const { settlers, listDirectory } = manualLister()
mount({ listDirectory })
await act(async () => {})
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 settles while the parent leg is still in flight: nothing
// commits yet — the editor stays open over the stale home level, and no
// single-pane DOCS frame ever renders.
await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) })
expect(screen.getByLabelText('browser.editPath', { selector: 'input' })).toBeTruthy()
expect(screen.queryByText('harness')).toBeNull()
// The parent leg settles inside the wait bound: one commit straight to
// the two-pane landing, editor closed.
await act(async () => { settlers.get(HOME)!(listingFor(HOME)) })
expect(columns()).toHaveLength(2)
expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true')
expect(within(columns()[0]!).getByText('Documents')).toBeTruthy()
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
// The wait-bound timer firing after the landing is a no-op.
await act(async () => { vi.advanceTimersByTime(200) })
expect(columns()).toHaveLength(2)
} finally {
vi.useRealTimers()
}
})
it('a stalled parent leg lands the target alone at the wait bound, then upgrades in place', async () => {
vi.useFakeTimers()
try {
const { settlers, listDirectory } = manualLister()
mount({ listDirectory })
await act(async () => {})
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 can consume most of the outer scan's silence window.
await act(async () => { vi.advanceTimersByTime(250) })
await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) })
// Its parent leg gets a fresh silence window. Crossing the original
// scan's 300ms deadline therefore cannot flash the indicator during the
// bounded landing wait.
await act(async () => { vi.advanceTimersByTime(199) })
expect(screen.queryByText('browser.loading')).toBeNull()
// The parent leg outlives PARENT_LEG_WAIT_MS: the target lands alone.
await act(async () => { vi.advanceTimersByTime(1) })
expect(columns()).toHaveLength(1)
expect(screen.getByRole('listitem').textContent).toBe('harness')
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
// The late parent leg still upgrades the landing in place, exactly as
// if it had made the bound. (Reopening the editor meanwhile would
// supersede the upgrade — the editor-open handler withdraws pending
// listings — so a late upgrade can never close a resumed draft.)
await act(async () => { settlers.get(HOME)!(listingFor(HOME)) })
expect(columns()).toHaveLength(2)
expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true')
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
} finally {
vi.useRealTimers()
}
})
it('Escape inside the landing window withdraws the submitted navigation', async () => {
vi.useFakeTimers()
try {
const { settlers, listDirectory } = manualLister()
mount({ listDirectory })
await act(async () => {})
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
fireEvent.change(input, { target: { value: DOCS } })
fireEvent.keyDown(input, { key: 'Enter' })
await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) })
// Nothing has committed yet; Escape supersedes the landing entirely.
fireEvent.keyDown(input, { key: 'Escape' })
await act(async () => { vi.advanceTimersByTime(200) })
expect(columns()).toHaveLength(1)
expect(screen.queryByText('harness')).toBeNull()
expect(within(columns()[0]!).getByText('Documents')).toBeTruthy()
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('shows the loading indicator only once a scan outlives its silence window, floating over the stale view', async () => {
vi.useFakeTimers()
try {
// The home level is truncated so its note is on screen when the slow
// scan starts: dropping the note's old !loading guard means it must
// keep rendering through the scan, coexisting with the indicator.
const settlers = new Map<string, (value: DirectoryListing) => void>()
const listDirectory = vi.fn((path?: string, _signal?: AbortSignal) => {
if (path === undefined) return Promise.resolve({ ...listingFor(path), truncated: true })
return new Promise<DirectoryListing>((resolve) => { settlers.set(path, resolve) })
})
mount({ listDirectory })
await act(async () => {})
expect(screen.queryByText('browser.loading')).toBeNull()
expect(screen.getByText('browser.truncated')).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' })
// In flight but still inside the silence window: no indicator, and the
// stale level's truncated note stays put (no layout churn on launch).
expect(screen.queryByText('browser.loading')).toBeNull()
expect(screen.getByText('browser.truncated')).toBeTruthy()
await act(async () => { vi.advanceTimersByTime(300) })
// Past it: the indicator floats while the stale level — truncated note
// included — keeps rendering beneath it.
expect(screen.getByText('browser.loading')).toBeTruthy()
expect(screen.getByText('browser.truncated')).toBeTruthy()
expect(screen.getByText('Documents')).toBeTruthy()
// Landing (both legs) retires the indicator with the scan, and the
// fresh listings' own truncated state replaces the stale note.
await act(async () => { settlers.get(DOCS)!(listingFor(DOCS)) })
await act(async () => { settlers.get(HOME)!(listingFor(HOME)) })
expect(screen.queryByText('browser.loading')).toBeNull()
expect(screen.queryByText('browser.truncated')).toBeNull()
expect(columns()).toHaveLength(2)
} finally {
vi.useRealTimers()
}
})
it('restarts the silence window when a row pick supersedes a pending scan', async () => {
vi.useFakeTimers()
try {
const pending: ((value: DirectoryListing) => void)[] = []
const listDirectory = vi.fn((path?: string, _signal?: AbortSignal) => {
if (path === undefined) return Promise.resolve(listingFor(path))
return new Promise<DirectoryListing>((resolve) => { pending.push(resolve) })
})
mount({ listDirectory })
await act(async () => {})
const documents = rowButton(screen.getByRole('listitem'))
fireEvent.click(documents)
await act(async () => { vi.advanceTimersByTime(300) })
expect(screen.getByText('browser.loading')).toBeTruthy()
// The same row remains actionable while its preview is pending. A second
// pick starts a new listing without a false `loading` edge.
fireEvent.click(documents)
expect(screen.queryByText('browser.loading')).toBeNull()
await act(async () => { vi.advanceTimersByTime(299) })
expect(screen.queryByText('browser.loading')).toBeNull()
await act(async () => { vi.advanceTimersByTime(1) })
expect(screen.getByText('browser.loading')).toBeTruthy()
await act(async () => { pending.at(-1)!(listingFor(DOCS)) })
} finally {
vi.useRealTimers()
}
})
it('a close mid-scan resets the slow-scan gate: reopening waits a fresh silence window', async () => {
vi.useFakeTimers()
try {
// Every home listing hangs: the initial open's scan is the one the
// close interrupts, and the reopen's scan proves the fresh window.
const settlers: ((value: DirectoryListing) => void)[] = []
const listDirectory = vi.fn((_path?: string, _signal?: AbortSignal) =>
new Promise<DirectoryListing>((resolve) => { settlers.push(resolve) }))
const { view, props } = mount({ listDirectory })
await act(async () => { vi.advanceTimersByTime(300) })
expect(screen.getByText('browser.loading')).toBeTruthy()
// Close while the scan is in flight, then reopen: the first frame must
// wait out a fresh silence window, not inherit the armed indicator.
view.rerender(<DirectoryBrowser {...props} open={false} />)
view.rerender(<DirectoryBrowser {...props} open />)
await act(async () => {})
expect(screen.queryByText('browser.loading')).toBeNull()
await act(async () => { vi.advanceTimersByTime(300) })
expect(screen.getByText('browser.loading')).toBeTruthy()
// The reopened scan settles normally.
await act(async () => { settlers.at(-1)!(listingFor(undefined)) })
expect(screen.queryByText('browser.loading')).toBeNull()
expect(screen.getByText('Documents')).toBeTruthy()
} finally {
vi.useRealTimers()
}
})
it('keeps the single-pane landing when the truncated parent level lacks the target', async () => {
const listDirectory = vi.fn(async (path?: string) => {
// The parent leg names HOME explicitly; serve it a truncated window
// that misses Documents (the initial open uses the absent-path form).
if (path === HOME) return { ...listingFor(HOME), entries: [], truncated: true }
return listingFor(path)
})
mount({ listDirectory })
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: DOCS } })
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
// The upgrade would orphan the selection (no source row): it stays off.
await act(async () => {})
expect(columns()).toHaveLength(1)
expect(screen.queryByText('browser.truncated')).toBeNull()
})
it('anchors the upgrade on the parent level actual entry under Windows case folding', async () => {
const ROOT = 'C:\\'
const TYPED = 'c:\\users'
const winRoot: DirectoryListing = {
path: ROOT,
home: ROOT,
crumbs: [{ name: 'C:\\', path: ROOT, hidden: false }],
entries: [{ name: 'Users', path: 'C:\\Users', hidden: false }],
truncated: false,
}
const winUsers: DirectoryListing = {
path: TYPED,
home: ROOT,
crumbs: [{ name: 'C:\\', path: ROOT, hidden: false }, { name: 'users', path: TYPED, hidden: false }],
entries: [],
truncated: false,
}
mount({ listDirectory: vi.fn(async (path?: string) => (path === TYPED ? winUsers : winRoot)) })
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: TYPED } })
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
// The typed case differs from the real entry; the upgrade selects the
// parent level's ACTUAL entry so aria-current and exemptions hold.
await waitFor(() => {
expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true')
})
expect(within(columns()[0]!).getByText('Users')).toBeTruthy()
})
it('re-parks focus on the edit zone when a failed pick unmounts a dot-revealed row', async () => {
const listDirectory = vi.fn(async (path?: string) => {
if (path === `${HOME}/.config`) {
throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path } })
}
return listingFor(path)
})
mount({ listDirectory })
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: `${HOME}/.co` } })
const row = rowButton(screen.getByRole('listitem'))
fireEvent.mouseDown(row)
fireEvent.click(row)
// The failed selection re-hides the picked row; focus fell to body and
// re-parks on the crumb edit zone.
await screen.findByRole('alert')
expect(screen.queryByText('.config')).toBeNull()
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' }))
})
it('leaves focus on a surviving row when its pick fails', async () => {
const listDirectory = vi.fn(async (path?: string) => {
if (path === DOCS) {
throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path } })
}
return listingFor(path)
})
mount({ listDirectory })
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: `${HOME}/do` } })
const row = rowButton(screen.getByRole('listitem'))
row.focus()
fireEvent.mouseDown(row)
fireEvent.click(row)
// Documents survives the cleared selection (it is not hidden): the
// user's focus on it is not yanked to the edit zone.
await screen.findByRole('alert')
expect(document.activeElement).toBe(row)
})
it('falls back to the single-pane landing when the parent leg of a navigation fails', async () => {
const listDirectory = vi.fn(async (path?: string) => {
// The initial open lists home through the absent-path form; only the
// parent leg names HOME explicitly.
if (path === HOME) {
throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'parent gone', details: { path } })
}
return listingFor(path)
})
mount({ listDirectory })
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
fireEvent.change(screen.getByLabelText<HTMLInputElement>('browser.editPath'), { target: { value: DOCS } })
fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' })
// The target listed fine; the failed parent leg neither blocks the
// landing nor surfaces an error for a level nobody asked to see.
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
expect(columns()).toHaveLength(1)
expect(screen.queryByRole('alert')).toBeNull()
})
it('opens the selection, else the listed level; Cancel closes; busy freezes Open', async () => {
const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
@@ -186,18 +620,221 @@ describe('DirectoryBrowser', () => {
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
expect(input.value).toBe(HOME)
// The editor seeds with a trailing separator so typing continues into
// child names.
expect(input.value).toBe(`${HOME}/`)
fireEvent.change(input, { target: { value: DOCS } })
fireEvent.keyDown(input, { key: 'Enter' })
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
expect(columns()).toHaveLength(1)
// Away from the root a navigation lands two-pane: the target selected
// in its parent level, its own children on the right.
await waitFor(() => { expect(columns()).toHaveLength(2) })
expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true')
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
// The submitted navigation unmounted the focused input; focus parks on
// the crumb edit zone that replaced it.
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' }))
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const again = screen.getByLabelText<HTMLInputElement>('browser.editPath')
fireEvent.change(again, { target: { value: ' ' } })
fireEvent.keyDown(again, { key: 'Enter' })
expect(b.listDirectory).toHaveBeenCalledTimes(2)
// Initial home + the DOCS target leg + its parent leg; the blank draft
// added none.
expect(b.listDirectory).toHaveBeenCalledTimes(3)
fireEvent.keyDown(again, { key: 'Escape' })
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
// Escape with focus in the input parks focus on the returning edit zone.
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' }))
})
it('prefix-filters the listed level from the draft tail, dot revealing hidden matches', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
// The seeded empty segment leaves the level as-is: hidden stays hidden.
expect(screen.getByRole('listitem').textContent).toBe('Documents')
// Case-insensitive prefix narrows the rows.
fireEvent.change(input, { target: { value: `${HOME}/do` } })
expect(screen.getByRole('listitem').textContent).toBe('Documents')
// A dot-led prefix names hidden entries, so it reveals the match.
fireEvent.change(input, { target: { value: `${HOME}/.co` } })
expect(screen.getByRole('listitem').textContent).toBe('.config')
// A prefix matching nothing empties the level (no stale rows linger).
fireEvent.change(input, { target: { value: `${HOME}/zzz` } })
expect(screen.queryByRole('listitem')).toBeNull()
// A draft naming some other directory (or none) leaves the level whole.
fireEvent.change(input, { target: { value: 'no-separator' } })
expect(screen.getByRole('listitem').textContent).toBe('Documents')
})
it('filters the child pane in two-pane mode and follows the draft back up a level', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(rowButton(screen.getByRole('listitem')))
await waitFor(() => { expect(columns()).toHaveLength(2) })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
// The seed comes from the selection, so the draft tail addresses the
// RIGHT pane (the selection's children).
expect(input.value).toBe(`${DOCS}/`)
fireEvent.change(input, { target: { value: `${DOCS}/h` } })
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
fireEvent.change(input, { target: { value: `${DOCS}/zzz` } })
expect(within(columns()[1]!).queryAllByRole('listitem')).toHaveLength(0)
expect(within(columns()[0]!).getByText('Documents')).toBeTruthy()
// Erasing back into the parent's own path moves the filter to the LEFT
// pane and releases the right one. The selected row is exempt (it
// anchors the two-pane view), so it alone survives the miss.
fireEvent.change(input, { target: { value: `${HOME}/zz` } })
expect(within(columns()[0]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents'])
expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
})
it('keeps the draft and filter through window focus loss and in-dialog focus moves', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
fireEvent.change(input, { target: { value: `${HOME}/do` } })
// A blur while the document itself lost focus (window switch, dev-tools
// focus) must not discard the draft: value and filter both survive.
const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(false)
fireEvent.focusOut(input)
hasFocus.mockRestore()
expect(screen.getByLabelText<HTMLInputElement>('browser.editPath', { selector: 'input' }).value).toBe(`${HOME}/do`)
expect(screen.getByRole('listitem').textContent).toBe('Documents')
// A keyboard focus move that stays inside the dialog (Tab onto the
// filtered row) keeps the draft too — the results stay reachable.
fireEvent.focusOut(input, { relatedTarget: rowButton(screen.getByRole('listitem')) })
expect(screen.getByLabelText<HTMLInputElement>('browser.editPath', { selector: 'input' }).value).toBe(`${HOME}/do`)
// Toggling show-hidden mid-edit suppresses focus steal: the draft and
// its filter survive the toggle in both directions.
const toggle = screen.getByRole('button', { name: 'browser.showHidden' })
fireEvent.mouseDown(toggle)
fireEvent.click(toggle)
expect(toggle.getAttribute('aria-pressed')).toBe('true')
expect(screen.getByLabelText<HTMLInputElement>('browser.editPath', { selector: 'input' }).value).toBe(`${HOME}/do`)
expect(screen.getByRole('listitem').textContent).toBe('Documents')
// Focus landing outside the dialog cancels like Escape — even when the
// departure happens from a row the user had Tabbed onto, not the input
// (the observer lives on the card scope, not the input).
fireEvent.focusOut(rowButton(screen.getByRole('listitem')), { relatedTarget: document.body })
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
// Outside editing the card-scope observer is inert.
fireEvent.focusOut(screen.getByRole('button', { name: 'browser.showHidden' }))
expect(screen.getByRole('button', { name: 'browser.editPath' })).toBeTruthy()
})
it('Escape with focus on a filtered row collapses the editor, not the dialog', async () => {
const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
fireEvent.change(input, { target: { value: `${HOME}/do` } })
// Tab parked focus on the result row; Escape must still mean "leave
// path editing", not "close the whole dialog".
const row = rowButton(screen.getByRole('listitem'))
row.focus()
fireEvent.keyDown(row, { key: 'Escape' })
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
expect(b.onClose).not.toHaveBeenCalled()
// Focus was already on a surviving row, so nothing re-parks it.
expect(document.activeElement).toBe(row)
// With no draft left, Escape falls through to the Modal and closes.
fireEvent.keyDown(row, { key: 'Escape' })
expect(b.onClose).toHaveBeenCalledTimes(1)
})
it('a picked dot-revealed hidden row stays visible as the selection', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
fireEvent.change(input, { target: { value: `${HOME}/.co` } })
const row = rowButton(screen.getByRole('listitem'))
expect(row.textContent).toBe('.config')
fireEvent.mouseDown(row)
fireEvent.click(row)
// The pick cleared the draft (and with it the dot-reveal), but the
// selection is exempt from the hidden filter: the anchor row survives.
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
await waitFor(() => { expect(columns()).toHaveLength(2) })
expect(within(columns()[0]!).getByText('.config')).toBeTruthy()
})
it('picking a filtered row adopts it and closes the path editor', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
fireEvent.change(input, { target: { value: `${HOME}/do` } })
// The row suppresses focus steal on mousedown (no blur-cancel unmounts
// the filtered rows mid-gesture), then the click both selects the row
// and closes the editor.
const row = rowButton(screen.getByRole('listitem'))
fireEvent.mouseDown(row)
fireEvent.click(row)
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
// Focus parks on the picked row (the editor's input just unmounted and
// the Modal has no focus trap to catch a fall to body).
expect(document.activeElement).toBe(row)
await waitFor(() => { expect(columns()).toHaveLength(2) })
expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy()
})
it('a right-pane pick while editing parks focus on the advanced selection', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(rowButton(screen.getByRole('listitem')))
await waitFor(() => { expect(columns()).toHaveLength(2) })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
fireEvent.change(input, { target: { value: `${DOCS}/h` } })
// The advance replaces BOTH panes (the picked button's own column
// unmounts), so focus is re-parked on the selection's aria-current row
// in the freshly rendered left pane rather than the clicked node.
const row = rowButton(within(columns()[1]!).getByRole('listitem'))
fireEvent.mouseDown(row)
fireEvent.click(row)
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
await waitFor(() => { expect(document.activeElement?.textContent).toBe('harness') })
expect(document.activeElement?.getAttribute('aria-current')).toBe('true')
})
it('seeds and filters with backslashes on a Windows-rooted listing', async () => {
const ROOT = 'C:\\'
const windowsListing: DirectoryListing = {
path: ROOT,
home: ROOT,
crumbs: [{ name: 'C:\\', path: ROOT, hidden: false }],
entries: [
{ name: 'Program Files', path: `${ROOT}Program Files`, hidden: false },
{ name: 'Users', path: `${ROOT}Users`, hidden: false },
],
truncated: false,
}
mount({ listDirectory: vi.fn(async () => windowsListing) })
await waitFor(() => { expect(screen.getAllByRole('listitem')).toHaveLength(2) })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
// The root already ends in its separator: no doubled backslash.
expect(input.value).toBe(ROOT)
fireEvent.change(input, { target: { value: `${ROOT}u` } })
expect(screen.getByRole('listitem').textContent).toBe('Users')
})
it('clicking away from the path editor cancels it back to the crumb view', async () => {
mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
fireEvent.change(input, { target: { value: '/somewhere/else' } })
// Focus moving anywhere outside the editor abandons the draft like Escape.
fireEvent.focusOut(input)
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
// The crumb view is back and the abandoned draft was never navigated to.
expect(screen.getByRole('button', { name: 'browser.editPath' })).toBeTruthy()
expect(screen.getByRole('listitem').textContent).toBe('Documents')
})
it('restarts the home listing when Escape cancels an edit opened before any level listed', async () => {
@@ -713,11 +1350,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 () => {