Merge origin/master into codex/status-bar-token-metrics
Build-review integration round 1; isolate Web snapshots from user skill homes.
This commit is contained in:
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md
|
||||
2026-07-28-directory-picker-capability-seam.md: 7c8f8cb67690cb4c5858cefb52b8cd79e649ec38
|
||||
2026-07-28-directory-picker-capability-seam.zh.md: 05545fc3cd758523814b31afa705249972d86464
|
||||
2026-07-28-directory-picker-capability-seam.md: ad2aa904beddb2fe941883c3c1827702dbec9964
|
||||
2026-07-28-directory-picker-capability-seam.zh.md: 30e719ad9b4e8374496106b447e961a042c7d8b6
|
||||
+6
-1
@@ -18,7 +18,9 @@ Placement and policy rulings folded into this decision:
|
||||
|
||||
- **Not the `ctx.fs` seam.** `packages/fs/` is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swapping `fs-sandbox` for the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free; `packages/host/` is its consumer-domain home.
|
||||
- **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib.
|
||||
- **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the planned show-hidden toggle becomes a client-only change. Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself.
|
||||
- **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself.
|
||||
- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets.
|
||||
- **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor.
|
||||
- **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption.
|
||||
- **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories.
|
||||
- **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it.
|
||||
@@ -30,6 +32,9 @@ Placement and policy rulings folded into this decision:
|
||||
- **One uniform seam method set (`pick(): path`).** Rejected: an in-app browser cannot be served behind a single host-side call — the browsing loop lives in the client and needs primitives on the wire; the native chooser cannot implement primitives. The interaction difference is irreducible, hence the discriminant.
|
||||
- **Direct stdlib calls inside apiproxy (no seam).** Rejected: keeps the gateway the only swap point (source edits), loses fixture/test backends, and contradicts the plugin doctrine that motivated the work.
|
||||
- **Adopting a file-manager/drive-enumeration dependency.** Rejected per the survey above; recorded here as the dependency policy requires.
|
||||
- **A flip-label show-hidden toggle ("Hide hidden files").** Rejected: a flipping action label is ambiguous between state and action and doubles the negative; the fixed label with a pressed presentation states both at once.
|
||||
- **Pure relatedTarget blur cancellation (no mousedown suppression).** Rejected: Safari does not focus buttons on pointer down, so a click's focusout carries a null `relatedTarget` and would cancel the editor before the click lands; editing-scoped mousedown suppression plus the card-anchored relatedTarget guard covers pointer and keyboard paths together.
|
||||
- **A wire `separator` field on `DirectoryListing` (host stamps `path.sep`).** Deferred, not rejected: it is the authoritative form — a POSIX home directory containing a backslash defeats the `listing.home` heuristic — but it touches the seam type and every backend; the browse client's `separatorOf` carries a TODO pointing at this alternative until a wire change is next scheduled.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
+6
-1
@@ -18,7 +18,9 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick
|
||||
|
||||
- **不用 `ctx.fs` seam。** `packages/fs/` 是面向模型/会话的存储栈(policy 事件、sandbox 可换后端)。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 `fs-sandbox` 绝不能改变 GUI 行为——而 OS 事实(home 锚定、隐藏约定)也不是存储原语。picker seam 保持无展示、无模型;`packages/host/` 是它消费方域的家。
|
||||
- **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。
|
||||
- **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,计划中的"显示隐藏"开关变成纯客户端改动。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。
|
||||
- **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。
|
||||
- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。
|
||||
- **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。
|
||||
- **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。
|
||||
- **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。
|
||||
- **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。
|
||||
@@ -30,6 +32,9 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick
|
||||
- **统一方法集的 seam(`pick(): path`)。** 否决:应用内浏览器无法藏在一次宿主侧调用后面——浏览循环在客户端,需要协议上的原语;而对话框实现不了原语。交互差异不可约,故用判别标签。
|
||||
- **apiproxy 里直接调标准库(不建 seam)。** 否决:换装点仍是改网关源码,失去 fixture/测试后端,与促成这项工作的插件教义相悖。
|
||||
- **引入文件管理器/盘符枚举依赖。** 按上文调研否决;依赖政策要求记录于此。
|
||||
- **动作标签随状态翻转的"显示隐藏"开关("隐藏隐藏文件")。** 否决:会翻转的动作标签在状态与动作之间有歧义,还把否定叠了两层;固定标签加按下态呈现一次说清两者。
|
||||
- **纯 relatedTarget 失焦取消(不做 mousedown 抑制)。** 否决:Safari 在指针按下时不给按钮聚焦,点击触发的 focusout 因而携带空 `relatedTarget`,会在点击落地前就取消编辑器;编辑期作用的 mousedown 抑制加上锚定卡片的 relatedTarget 守卫才能同时覆盖指针与键盘路径。
|
||||
- **在 `DirectoryListing` 上增设线上 `separator` 字段(宿主标注 `path.sep`)。** 延期而非否决:它才是权威形态——含反斜杠的 POSIX 家目录会击穿 `listing.home` 启发式——但它触及 seam 类型与每个后端;browse 客户端的 `separatorOf` 挂着指向本方案的 TODO,直到下次安排线上变更。
|
||||
|
||||
## 后果
|
||||
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md
|
||||
2026-07-30-web-context-injection-disclosure.md: 84c3259f3f226e501a671cc55cacf7d7d96f61fb
|
||||
2026-07-30-web-context-injection-disclosure.zh.md: 4d77e06e27badb02fb73ca2ea2a739b33c5804de
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Web context injection disclosure
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-30-web-context-injection-disclosure.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Web conversation rendered every logged non-user message through the generic `JsonBlock`. That presentation used a textual triangle, compact label typography, a bordered JSON panel, and unrelated spacing, so context injection did not match the Tool calls disclosure shown in the product design. Restyling the generic primitive would also change unknown events and attachment fallbacks.
|
||||
|
||||
## Decision
|
||||
|
||||
`MessageItem` routes context nodes to `ContextInjectionRow`. The row starts collapsed, names the presentation `上下文注入`, uses the existing browse glyph, and exposes the whole 24px header as one pointer and keyboard disclosure target. Its expanded body begins 4px below the header at the shared 22px content indent and renders the design's 141px scrollport with 8px radius, code-block background, 11/16 code text, and no border.
|
||||
|
||||
`ContextInjectionRow` serializes both logged `content` and `source` into one inline JSON value, preserving provenance alongside model-visible material. The display remains bounded by the existing 20,000-character truncation policy. It changes no session event, runtime fold, or context-producing plugin.
|
||||
|
||||
The package-internal `DisclosureRow` owns the header geometry, icon-to-chevron transition, controlled open state, and Enter/Space behavior shared by context and `ToolRow`. `ToolRow` remains the semantic owner of tool state, summaries, file links, and expanded tool bodies. Context does not enter the keyed toolview slot and gains no context-specific slot while all context sources share one presentation.
|
||||
|
||||
## Verification
|
||||
|
||||
Conversation component tests pin the collapsed default, browse glyph, whole-row pointer and keyboard toggles, inline JSON shape, truncation, and unchanged generic unknown-event rendering. The keyless assembled-Web history scenario injects context through the real Agent API, records the collapsed row in its ARIA golden, and measures the design's icon, header, indent, gap, scrollport, padding, radius, typography, color, and overflow in Chromium.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Restyle `JsonBlock` globally.** Unknown surface events and miscellaneous content blocks use that primitive for a separate generic fallback, so a global visual change would couple unrelated presentations.
|
||||
|
||||
**Render context as a read tool.** Reusing `ToolRow` directly would add false tool semantics, state and keyed dispatch to a logged non-user message.
|
||||
|
||||
**Add a keyed context-view slot.** Every current context source uses the same title and provenance body. A registration seam has no present consumer and can be added without changing the row if distinct source-owned presentations emerge.
|
||||
|
||||
## Consequences
|
||||
|
||||
Context injection matches the Tool calls visual language without changing its durable meaning. The shared disclosure header prevents the two rows from drifting, while the dedicated context body and generic `JsonBlock` remain independently evolvable. The fixed-height body trades automatic expansion for a stable transcript rhythm and requires scrolling to inspect long injected instructions.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Web 上下文注入展开项
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-30-web-context-injection-disclosure.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Web 会话原本通过通用 `JsonBlock` 渲染每条已记录的非用户消息。这种呈现使用文本三角符号、紧凑的标签字体、有边框的 JSON 面板和另一套间距,因此上下文注入与产品设计中的 Tool calls 展开项不一致。修改通用原语的样式还会影响未知事件和附件的兜底呈现。
|
||||
|
||||
## 决策
|
||||
|
||||
`MessageItem` 将上下文节点路由至 `ContextInjectionRow`。该行初始折叠,标题为 `上下文注入`,使用现有的浏览图标,并使整个 24px 标题栏成为可通过指针和键盘操作的展开目标。其展开主体从标题栏下方 4px 处开始,与共用的 22px 内容缩进对齐,并渲染设计规定的 141px 滚动区;滚动区采用 8px 圆角、代码块背景、11/16 代码文本且无边框。
|
||||
|
||||
`ContextInjectionRow` 将已记录的 `content` 和 `source` 序列化为一个内联 JSON 值,在模型可见内容旁保留来源信息。显示内容继续受现有的 20,000 字符截断策略约束。该变更不修改任何会话事件、运行时折叠逻辑或上下文生成插件。
|
||||
|
||||
包内部的 `DisclosureRow` 负责上下文行和 `ToolRow` 共用的标题栏几何、图标至折叠箭头的过渡、受控打开状态,以及 Enter/Space 操作。`ToolRow` 仍是工具状态、摘要、文件链接和展开后工具主体的语义 owner。所有上下文来源共用同一套呈现;上下文不会进入键控 toolview slot,也不会获得上下文专用 slot。
|
||||
|
||||
## 验证
|
||||
|
||||
会话组件测试固定验证初始折叠状态、浏览图标、整行的指针与键盘切换、内联 JSON 形状、截断,以及通用未知事件渲染保持不变。无密钥的组装后 Web 历史场景通过真实 Agent API 注入上下文,在 ARIA 预期输出中记录折叠行,并在 Chromium 中测量设计规定的图标、标题栏、缩进、间隙、滚动区、内边距、圆角、字体排版、颜色和溢出行为。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**全局重新设置 `JsonBlock` 样式。** 之所以否决:未知 surface 事件和其他内容块使用该原语作为独立的通用兜底呈现,全局视觉变更会把无关的呈现耦合起来。
|
||||
|
||||
**将上下文渲染为 read 工具。** 之所以否决:直接复用 `ToolRow` 会为已记录的非用户消息添加错误的工具语义、状态和键控分发。
|
||||
|
||||
**新增键控 context-view slot。** 之所以否决:当前所有上下文来源都使用相同的标题和来源信息主体,注册 seam 暂无消费方。如果将来出现由不同来源拥有的呈现,仍可在不更改该行的情况下添加此 seam。
|
||||
|
||||
## 后果
|
||||
|
||||
上下文注入与 Tool calls 采用一致的视觉语言,同时不改变其持久保存的语义。共用的展开项标题栏可以防止这两种行逐渐偏离,而专用的上下文主体与通用 `JsonBlock` 仍可独立演进。固定高度的主体以无法随内容自动增高为代价,为 transcript(文本记录)维持稳定的排版节奏;查看较长的注入指令时必须滚动。
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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
|
||||
2026-07-14-typescript-program-backed-semantic-gates.md: 43a7b9b5369feb199721f5f1348c03cde66ee411
|
||||
2026-07-14-typescript-program-backed-semantic-gates.zh.md: 1ab027d723e30007e6675ae1f3589fb594d10afc
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md
|
||||
2026-07-14-typescript-program-backed-semantic-gates.md: 91639d53b660c68ae52c7ddcef6b3f594e82273e
|
||||
2026-07-14-typescript-program-backed-semantic-gates.zh.md: 2270408564f0fc90241255dfb86a65c30362d651
|
||||
+2
@@ -30,6 +30,8 @@ The wrapper owns config diagnostics, semantic compiler options, repository-relat
|
||||
|
||||
Context and agent-dispatch calls contribute only finite string-literal event sets. Direct `EventsService.dispatch()` calls recover the event slot through array literals, constant aliases, conditional branches, and resolved call sites of non-exported local helpers. Generic forwarding parameters are not concrete producers: attribution stays with the call sites that supply a closed event value.
|
||||
|
||||
Semantic queries run only where a branch can consume them: calls are prefiltered by the closed event-API method-name set before receiver classification, and helper call sites are indexed on demand instead of eagerly resolving every call in every package source. The demand-driven index proves locality per helper — a helper that is non-exported, sits in a real ES module, and whose every same-file reference is a direct callee has all of its calls in that file by module scoping, so only that file is indexed. Any unproven premise (an export modifier, a global script file, an aliasing or otherwise unclassifiable reference) falls back to the original full package-source index, which is the unchanged original semantics; the proof affects cost, never results. A lazy single global index was rejected because the helper-parameter path is reached on the current tree, so it would still pay nearly the whole `getResolvedSignature` sweep.
|
||||
|
||||
Every declared harness event must have a discovered producer. A missing producer fails generation as dead vocabulary or an unsupported semantic dispatch shape; listener-free extension points remain valid. `internal/dispatch` instrumentation is not treated as a subscription to every event it observes, so the matrix contains direct product listeners rather than manually asserted indirect relationships.
|
||||
|
||||
### B. Scoped-event routing generates one typed resolver map
|
||||
|
||||
+2
@@ -30,6 +30,8 @@ Status: implemented
|
||||
|
||||
Context 与 AgentEventDispatch 调用只贡献有限的字符串字面量事件集合。对于直接调用 `EventsService.dispatch()` 的路径,生成器会沿数组字面量、常量别名、条件分支和未导出本地辅助函数的已解析调用点恢复事件槽位。泛型转发参数不算作具体生产方:事件仍归属于传入封闭事件值的调用点。
|
||||
|
||||
语义查询只在存在消费分支的位置运行:调用先经过封闭的事件 API 方法名集合预过滤,再做接收者分类;辅助函数调用点索引按需构建,而不是预先对全部包源码的每个调用求解签名。需求式索引对每个辅助函数逐一证明局部性——未导出、位于真正的 ES 模块文件中、且同文件所有引用都是直接调用位的辅助函数,按模块作用域规则其全部调用必在本文件内,此时只索引该文件。任一前提无法证明(带导出修饰符、位于全局 script 文件、存在别名化或无法归类的引用)即回退到原全部包源码索引,回退路径就是原语义本身:证明只影响开销,不影响结果。惰性单一全局索引方案被否决,因为当前源码树确实会走到辅助函数参数路径,该方案仍需支付几乎全额的 `getResolvedSignature` 扫描成本。
|
||||
|
||||
每个已声明的 harness 事件都必须存在扫描得到的生产方。找不到生产方时,生成过程会将其视为无调用方的事件词汇或尚不支持的语义 dispatch 形态并明确失败;没有监听方的扩展点仍然合法。`internal/dispatch` 插桩不会被当作它所观察的每个事件的订阅,因此关系矩阵只记录直接的产品监听方,不再手工补充间接关系。
|
||||
|
||||
### B. 带作用域的事件路由生成一份强类型解析函数表
|
||||
|
||||
@@ -120,8 +120,9 @@ jobs:
|
||||
# across six always-on runner instances, and the timing-sensitive
|
||||
# process suites have documented aggregate-contention failures.
|
||||
# 8 × 6 instances = 48 workers worst case on 64 cores.
|
||||
DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '24' }}
|
||||
DSH_GATE_CONCURRENCY: '8'
|
||||
DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '8' }}
|
||||
DSH_GATE_CONCURRENCY: '3'
|
||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
// from live session memory), refresh (keyless replay that rewrites goldens).
|
||||
//
|
||||
// Composition divergences from `dsh web`, all deliberate, all via include
|
||||
// patches after the shipped surface overlay: temp persistenceRoot;
|
||||
// patches after the shipped surface overlay: temp persistenceRoot; user skill
|
||||
// roots redirected to empty temp directories (project discovery remains real);
|
||||
// workspace-context disabled (recorded fixtures must not embed this repo's
|
||||
// AGENTS.md); session-title-llm disabled (its fire-and-forget title call
|
||||
// would race the loop for the session's replay cursor); webserver pinned to
|
||||
@@ -172,6 +173,16 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
// per write; the scaffold restores the original cwd after boot, so the
|
||||
// row gets an absolute temp root (removed with the workspace at close).
|
||||
{ id: 'storage-json', config: { root: join(workspaceCwd, '.dsh-storages') } },
|
||||
// Host-level skills are ambient machine state and must not change replay
|
||||
// goldens. Keep the provider enabled so project skills under workspaceCwd
|
||||
// remain discoverable, but give its user roots empty scaffold-owned paths.
|
||||
{
|
||||
id: 'skill-local',
|
||||
config: {
|
||||
dshHome: join(persistenceRoot, 'skill-dsh-home'),
|
||||
agentsHome: join(persistenceRoot, 'skill-agents-home'),
|
||||
},
|
||||
},
|
||||
// fs/bash cwd default to process.cwd(); the gateway injects the same
|
||||
// value into session.cwd — chdir below anchors all three to the temp
|
||||
// workspace, keeping the composition untouched.
|
||||
|
||||
@@ -12,6 +12,8 @@ import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
@@ -117,6 +119,30 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
const toolRows = page.locator('[data-variant], [data-sample]')
|
||||
await expect.poll(() => toolRows.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
|
||||
expect(await page.getByText('a.txt', { exact: false }).count()).toBeGreaterThan(0)
|
||||
|
||||
const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
|
||||
if (agent === undefined) throw new Error('seeded session did not attach an agent')
|
||||
agent.inject(createUserMessage({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: '<system-reminder>\n'
|
||||
+ 'The following workspace instructions may be relevant to your work. '
|
||||
+ 'Use them as guidance when applicable.\n\n'
|
||||
+ Array.from({ length: 24 }, (_, index) => `Instruction ${index + 1}: preserve the logged context contract.`).join('\n')
|
||||
+ '\n</system-reminder>',
|
||||
}],
|
||||
source: {
|
||||
kind: 'workspace-instructions',
|
||||
baseline: true,
|
||||
changes: [{
|
||||
action: 'set',
|
||||
scope: '.\u0000AGENTS.md',
|
||||
path: 'AGENTS.md',
|
||||
digest: 'context-injection-browser-snapshot',
|
||||
}],
|
||||
},
|
||||
}))
|
||||
await page.getByRole('button', { name: '上下文注入' }).waitFor({ timeout: 10_000 })
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => {
|
||||
@@ -132,6 +158,58 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('matches the Figma context disclosure geometry', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-context-injection'))
|
||||
const disclosure = page.getByRole('button', { name: '上下文注入' })
|
||||
expect(await disclosure.getAttribute('aria-expanded')).toBe('false')
|
||||
const collapsedIcon = disclosure.locator('svg').first()
|
||||
const collapsedIconBox = await collapsedIcon.boundingBox()
|
||||
expect(collapsedIconBox?.width).toBe(14)
|
||||
expect(collapsedIconBox?.height).toBe(14)
|
||||
|
||||
await disclosure.click()
|
||||
await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true')
|
||||
const body = page.locator('[data-context-injection-body]')
|
||||
await body.waitFor({ timeout: 5_000 })
|
||||
const headerBox = await disclosure.boundingBox()
|
||||
const bodyBox = await body.boundingBox()
|
||||
if (headerBox === null || bodyBox === null) throw new Error('context disclosure geometry is not measurable')
|
||||
expect(headerBox.height).toBe(24)
|
||||
expect(bodyBox.x - headerBox.x).toBe(22)
|
||||
expect(bodyBox.y - headerBox.y - headerBox.height).toBe(4)
|
||||
expect(bodyBox.height).toBe(141)
|
||||
|
||||
const style = await body.evaluate((element) => {
|
||||
const computed = getComputedStyle(element)
|
||||
return {
|
||||
backgroundColor: computed.backgroundColor,
|
||||
borderRadius: computed.borderRadius,
|
||||
color: computed.color,
|
||||
fontSize: computed.fontSize,
|
||||
lineHeight: computed.lineHeight,
|
||||
padding: [
|
||||
computed.paddingTop,
|
||||
computed.paddingRight,
|
||||
computed.paddingBottom,
|
||||
computed.paddingLeft,
|
||||
],
|
||||
scrolls: element.scrollHeight > element.clientHeight,
|
||||
}
|
||||
})
|
||||
expect(style).toEqual({
|
||||
backgroundColor: 'rgb(249, 250, 251)',
|
||||
borderRadius: '8px',
|
||||
color: 'rgb(129, 133, 140)',
|
||||
fontSize: '11px',
|
||||
lineHeight: '16px',
|
||||
padding: ['10px', '16px', '12px', '12px'],
|
||||
scrolls: true,
|
||||
})
|
||||
|
||||
await disclosure.click()
|
||||
await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('file-path tool rows rebuilt from the cold log stay details-inert', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-toolrow'))
|
||||
// Interaction over cold-resumed history: read summaries are host-open
|
||||
|
||||
@@ -31,6 +31,10 @@
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- text: {{clock}}
|
||||
- button "上下文注入":
|
||||
- img
|
||||
- img
|
||||
- text: 上下文注入
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
|
||||
@@ -5,6 +5,37 @@
|
||||
- img
|
||||
- button "browse-golden"
|
||||
- button "Edit path"
|
||||
- list:
|
||||
- listitem:
|
||||
- button "adopted":
|
||||
- img
|
||||
- text: adopted
|
||||
- img
|
||||
- listitem:
|
||||
- button "alpha-ws":
|
||||
- img
|
||||
- text: alpha-ws
|
||||
- img
|
||||
- listitem:
|
||||
- button "beta-ws":
|
||||
- img
|
||||
- text: beta-ws
|
||||
- img
|
||||
- listitem:
|
||||
- button "browse-golden":
|
||||
- img
|
||||
- text: browse-golden
|
||||
- img
|
||||
- listitem:
|
||||
- button "same-name":
|
||||
- img
|
||||
- text: same-name
|
||||
- img
|
||||
- listitem:
|
||||
- button "workspace":
|
||||
- img
|
||||
- text: workspace
|
||||
- img
|
||||
- list:
|
||||
- listitem:
|
||||
- button "alpha":
|
||||
@@ -19,5 +50,6 @@
|
||||
- button "New folder":
|
||||
- img
|
||||
- text: New folder
|
||||
- button "Show hidden files"
|
||||
- button "Cancel"
|
||||
- button "Open"
|
||||
@@ -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/client/ui-conversation/README.md
|
||||
README.md: ae245d1c904b291413d097c0b6d88c23c44f7c95
|
||||
README.zh.md: e80c55e11e122d66b2a7c7d978a6aadb4e39c659
|
||||
README.md: 5fe9086ed92b9f3f498493a192b7e27a31da124c
|
||||
README.zh.md: bb1d08e24ec5654fc8b24c10418dad356f8cd3ce
|
||||
@@ -10,6 +10,8 @@ The view ring IS a slot: the conversation registration declares the `'conversati
|
||||
|
||||
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels (the `/permission` popup's display transform twin), and a pick submits the `/permission <preset>` command line through the bar's injected `command` callback.
|
||||
|
||||
Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded 141px scrollport shows bounded inline JSON for both `content` and `source`, and no tool state, summary, or keyed toolview dispatch is synthesized ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)).
|
||||
|
||||
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
|
||||
|
||||
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed for this intent alone; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。
|
||||
|
||||
已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开后的 141px 滚动区会以内联 JSON 的形式有界展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。
|
||||
|
||||
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。
|
||||
|
||||
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出只对该意图开放;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
|
||||
@@ -20,7 +22,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
|
||||
|
||||
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。
|
||||
|
||||
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar 坑位本身为 `session-maybe`:没有当前会话时,同一个 bar 以惰性态渲染(machine face 缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此 textarea DOM 在选定 workspace 的切换中得以存活;严格会话作用域的控件 seat 在会话存在之前保持为空。
|
||||
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染(machine face 均缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
|
||||
|
||||
聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的两个通用 token-meter 投影:`tokenUsage` 提供完整日志计费用量(计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量),`contextPressure` 提供上下文占用率。可见节点只提供轮次与步骤计数,以及 LLM 和工具的墙钟时间:这些是关于「屏幕上有什么」的窗口作用域事实,而非账目;压缩使已加载窗口不再包含 assistant 节点时,持久 token 与上下文分组仍保持可见。未组合 token-meter 的部署会整组省略 token 分组;只有提供方压力与路由容量都已知时才显示占用率。占用率是刻意为之的近似值:它的分子与容量是两个相互独立的「后者胜」投影字段,并非同一次请求的原子观测([原理](../../llm/token-meter/README.md))。行内统计行仍是唯一的上下文 UI;模型选择器不增加圆环或附属控件。
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/* Figma 10:2482: 24px Tool calls header, 4px gap, 141px clipped code block. */
|
||||
|
||||
.root {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root[data-open] {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.body {
|
||||
box-sizing: border-box;
|
||||
width: calc(100% - 22px);
|
||||
height: 141px;
|
||||
margin: 4px 0 0 22px;
|
||||
overflow: auto;
|
||||
padding: 10px 16px 12px 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: 400 11px/16px var(--ds-font-family-code);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ContextMessageNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { DisclosureRow } from './DisclosureRow.tsx'
|
||||
import css from './ContextInjectionRow.module.css'
|
||||
|
||||
const MAX_CHARS = 20_000
|
||||
|
||||
function inlineJson(payload: unknown): string {
|
||||
const raw = JSON.stringify(payload)
|
||||
let formatted = ''
|
||||
let quoted = false
|
||||
let escaped = false
|
||||
|
||||
for (let index = 0; index < raw.length; index++) {
|
||||
const char = raw.charAt(index)
|
||||
if (quoted) {
|
||||
formatted += char
|
||||
if (escaped) escaped = false
|
||||
else if (char === '\\') escaped = true
|
||||
else if (char === '"') quoted = false
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
quoted = true
|
||||
formatted += char
|
||||
continue
|
||||
}
|
||||
if (char === '{' || char === '[') {
|
||||
formatted += char
|
||||
const close = char === '{' ? '}' : ']'
|
||||
if (raw[index + 1] !== close) formatted += ' '
|
||||
continue
|
||||
}
|
||||
if (char === '}' || char === ']') {
|
||||
const open = char === '}' ? '{' : '['
|
||||
if (raw[index - 1] !== open) formatted += ' '
|
||||
formatted += char
|
||||
continue
|
||||
}
|
||||
formatted += char === ':' || char === ',' ? `${char} ` : char
|
||||
}
|
||||
return formatted
|
||||
}
|
||||
|
||||
/** Props for the logged non-user message presentation. */
|
||||
export interface ContextInjectionRowProps {
|
||||
content: ContextMessageNode['content']
|
||||
source: ContextMessageNode['source']
|
||||
}
|
||||
|
||||
/**
|
||||
* Render logged context with the Tool calls disclosure chrome from Figma.
|
||||
* @param props - Durable content and source provenance.
|
||||
* @returns A collapsed context row with a bounded JSON body.
|
||||
*/
|
||||
export function ContextInjectionRow({ content, source }: ContextInjectionRowProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const body = useMemo(() => {
|
||||
if (!open) return ''
|
||||
const text = inlineJson({ content, source })
|
||||
return text.length > MAX_CHARS
|
||||
? `${text.slice(0, MAX_CHARS)}\n… 已截断,共 ${text.length} 字符`
|
||||
: text
|
||||
}, [content, open, source])
|
||||
|
||||
return (
|
||||
<DisclosureRow
|
||||
className={css.root}
|
||||
icon={<IconBrowseOutline16 size={14} />}
|
||||
chevronClassName={css.chevron}
|
||||
title="上下文注入"
|
||||
open={open}
|
||||
expandable
|
||||
expandOnRowClick
|
||||
onToggle={() => { setOpen(value => !value) }}
|
||||
>
|
||||
<pre className={css.body} data-context-injection-body>{body}</pre>
|
||||
</DisclosureRow>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/* Shared Tool calls disclosure header: [16px leading] gap 6 [title 14/24]. */
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row[data-expandable] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.leading {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 6px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
button.leading {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.iconIdle {
|
||||
display: inline-flex;
|
||||
opacity: 1;
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.chevronHover {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.row:hover .iconIdle {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.row:hover .chevronHover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import css from './DisclosureRow.module.css'
|
||||
|
||||
/** Shared 24px disclosure chrome for conversation flow rows. */
|
||||
export interface DisclosureRowProps {
|
||||
icon: ReactNode
|
||||
title: string
|
||||
open: boolean
|
||||
expandable: boolean
|
||||
onToggle: () => void
|
||||
/** Makes the complete title row the disclosure target. */
|
||||
expandOnRowClick?: boolean | undefined
|
||||
/** Replaces the collapsed icon with a chevron while the row is hovered. */
|
||||
previewChevron?: boolean | undefined
|
||||
collapsedContent?: ReactNode
|
||||
children?: ReactNode
|
||||
className?: string | undefined
|
||||
rowClassName?: string | undefined
|
||||
leadingClassName?: string | undefined
|
||||
chevronClassName?: string | undefined
|
||||
titleClassName?: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one disclosure header and its controlled expanded content.
|
||||
* @param props - Visual content, controlled state, and interaction policy.
|
||||
* @returns The disclosure row.
|
||||
*/
|
||||
export function DisclosureRow({
|
||||
icon,
|
||||
title,
|
||||
open,
|
||||
expandable,
|
||||
onToggle,
|
||||
expandOnRowClick = false,
|
||||
previewChevron = expandable,
|
||||
collapsedContent,
|
||||
children,
|
||||
className,
|
||||
rowClassName,
|
||||
leadingClassName,
|
||||
chevronClassName,
|
||||
titleClassName,
|
||||
}: DisclosureRowProps) {
|
||||
const rowExpands = expandable && expandOnRowClick
|
||||
const toggleFromLeading = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
onToggle()
|
||||
}
|
||||
const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!rowExpands || (event.key !== 'Enter' && event.key !== ' ')) return
|
||||
event.preventDefault()
|
||||
onToggle()
|
||||
}
|
||||
const collapsedLeading = previewChevron
|
||||
? (
|
||||
<>
|
||||
<span className={css.iconIdle}>{icon}</span>
|
||||
<IconChevronDownOutline14 className={clsx(chevronClassName, css.chevronHover)} />
|
||||
</>
|
||||
)
|
||||
: icon
|
||||
const leading = open
|
||||
? <IconChevronDownOutline14 className={chevronClassName} />
|
||||
: collapsedLeading
|
||||
|
||||
return (
|
||||
<div className={clsx(css.root, className)} data-open={open || undefined}>
|
||||
<div
|
||||
className={clsx(css.row, rowClassName)}
|
||||
data-disclosure-row
|
||||
data-expandable={rowExpands || undefined}
|
||||
role={rowExpands ? 'button' : undefined}
|
||||
tabIndex={rowExpands ? 0 : undefined}
|
||||
aria-expanded={rowExpands ? open : undefined}
|
||||
onClick={rowExpands ? onToggle : undefined}
|
||||
onKeyDown={rowExpands ? toggleFromKeyboard : undefined}
|
||||
>
|
||||
{expandable && !rowExpands ? (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.leading, leadingClassName)}
|
||||
aria-expanded={open}
|
||||
onClick={toggleFromLeading}
|
||||
>
|
||||
{leading}
|
||||
</button>
|
||||
) : (
|
||||
<span className={clsx(css.leading, leadingClassName)}>
|
||||
{leading}
|
||||
</span>
|
||||
)}
|
||||
<span className={clsx(css.title, titleClassName)}>{title}</span>
|
||||
{!open && collapsedContent}
|
||||
</div>
|
||||
{open && children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { ContextInjectionRow } from './ContextInjectionRow.tsx'
|
||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
import css from './MessageItem.module.css'
|
||||
|
||||
@@ -94,9 +95,7 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
|
||||
}
|
||||
case 'context':
|
||||
return (
|
||||
<div className={css.contextRow}>
|
||||
<JsonBlock label="上下文注入" payload={{ content: node.content, source: node.source }} />
|
||||
</div>
|
||||
<ContextInjectionRow content={node.content} source={node.source} />
|
||||
)
|
||||
default:
|
||||
return (
|
||||
|
||||
@@ -9,10 +9,6 @@
|
||||
.row {
|
||||
position: relative; /* sweep-glare overlay anchor */
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Running sweep (deepsuite ShimmerText pattern): a fixed-width glare band —
|
||||
@@ -41,24 +37,8 @@
|
||||
90%, 100% { left: 100%; }
|
||||
}
|
||||
|
||||
/* Expand-on-row (Think / code): pointer only — no row fill hover. */
|
||||
.row[data-expandable] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.leading {
|
||||
position: relative; /* .chevronHover overlay anchor */
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 6px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Cordis lifecycle tools retain their generic row mechanics while carrying a
|
||||
@@ -76,40 +56,8 @@
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
button.leading {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Hover preview on expandable rows: the idle tool icon crossfades (100ms)
|
||||
into a down chevron before the row is opened. The chevron overlays the
|
||||
icon cell absolutely so both can stay mounted for the opacity transition. */
|
||||
.iconIdle {
|
||||
display: inline-flex;
|
||||
opacity: 1;
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.chevronHover {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.row:hover .iconIdle {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.row:hover .chevronHover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.sep {
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
// component-local view state. File-tool summaries are path links that open
|
||||
// through the host; the row itself is not a details-panel control.
|
||||
|
||||
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import { useState, type MouseEvent, type ReactNode } from 'react'
|
||||
import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { CHAT_TERMINAL_MAX_LINES, type TerminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import { DisclosureRow } from './DisclosureRow.tsx'
|
||||
import css from './ToolRow.module.css'
|
||||
|
||||
export interface ToolRowProps {
|
||||
@@ -82,63 +82,27 @@ export function ToolRow({
|
||||
// this substitution never shows.
|
||||
const text = body ?? ''
|
||||
const open = expanded && expandable
|
||||
const rowExpands = expandable && expandOnRowClick
|
||||
const toggleExpand = () => {
|
||||
setExpanded(v => !v)
|
||||
}
|
||||
const toggleFromLeading = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
toggleExpand()
|
||||
}
|
||||
const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!rowExpands || (event.key !== 'Enter' && event.key !== ' ')) return
|
||||
event.preventDefault()
|
||||
toggleExpand()
|
||||
}
|
||||
const openFile = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
if (filePath !== undefined) onOpenFile?.(filePath)
|
||||
}
|
||||
// Expandable rows preview the toggle on hover: the tool icon yields to a
|
||||
// down chevron (CSS swap on .row:hover); state dots still take precedence.
|
||||
const collapsedIcon = expandable
|
||||
? (
|
||||
<>
|
||||
<span className={css.iconIdle}>{icon}</span>
|
||||
<IconChevronDownOutline14 className={css.chevronHover} />
|
||||
</>
|
||||
)
|
||||
: icon
|
||||
const leading = open
|
||||
? <IconChevronDownOutline14 />
|
||||
: leadingFor(state, collapsedIcon)
|
||||
return (
|
||||
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
|
||||
<div
|
||||
className={css.row}
|
||||
data-expandable={rowExpands || undefined}
|
||||
role={rowExpands ? 'button' : undefined}
|
||||
tabIndex={rowExpands ? 0 : undefined}
|
||||
aria-expanded={rowExpands ? open : undefined}
|
||||
onClick={rowExpands ? toggleExpand : undefined}
|
||||
onKeyDown={rowExpands ? toggleFromKeyboard : undefined}
|
||||
>
|
||||
{expandable && !rowExpands ? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.leading}
|
||||
aria-expanded={open}
|
||||
onClick={toggleFromLeading}
|
||||
>
|
||||
{leading}
|
||||
</button>
|
||||
) : (
|
||||
<span className={css.leading}>
|
||||
{leading}
|
||||
</span>
|
||||
)}
|
||||
<span className={css.title}>{title}</span>
|
||||
{!open && (
|
||||
<DisclosureRow
|
||||
rowClassName={css.row}
|
||||
leadingClassName={css.leading}
|
||||
titleClassName={css.title}
|
||||
icon={leadingFor(state, icon)}
|
||||
title={title}
|
||||
open={open}
|
||||
expandable={expandable}
|
||||
expandOnRowClick={expandOnRowClick}
|
||||
previewChevron={expandable && state !== 'error' && state !== 'stopped'}
|
||||
onToggle={toggleExpand}
|
||||
collapsedContent={(
|
||||
<>
|
||||
<span className={css.sep} aria-hidden />
|
||||
{fileLink ? (
|
||||
@@ -154,18 +118,18 @@ export function ToolRow({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* The terminal presenter's description belongs ABOVE the card per the
|
||||
render-intent contract, so an expanded terminal row keeps showing it
|
||||
even though the collapsed summary is hidden while open. */}
|
||||
{open && terminalBody?.description !== undefined && (
|
||||
<div className={css.terminalDescription}>{terminalBody.description}</div>
|
||||
)}
|
||||
{open && (terminalBody !== null
|
||||
? <TerminalBlock {...terminalBody.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminalBody} />
|
||||
: variant === 'code'
|
||||
? <CodeBlock code={text} lang="typescript" className={css.codeBody} />
|
||||
: <div className={css.body}>{text}</div>)}
|
||||
>
|
||||
{/* The terminal presenter's description belongs above the card per
|
||||
the render-intent contract. */}
|
||||
{terminalBody?.description !== undefined && (
|
||||
<div className={css.terminalDescription}>{terminalBody.description}</div>
|
||||
)}
|
||||
{terminalBody !== null
|
||||
? <TerminalBlock {...terminalBody.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminalBody} />
|
||||
: variant === 'code'
|
||||
? <CodeBlock code={text} lang="typescript" className={css.codeBody} />
|
||||
: <div className={css.body}>{text}</div>}
|
||||
</DisclosureRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -107,11 +107,48 @@ describe('MessageItem arms', () => {
|
||||
expect(view.queryByRole('button', { name: '复制' })).toBeNull()
|
||||
})
|
||||
|
||||
it('context and unknown nodes render their JSON rows', () => {
|
||||
it('context uses the Tool calls disclosure chrome and keeps its JSON collapsed by default', () => {
|
||||
const ctxView = render(
|
||||
<MessageItem node={{ kind: 'context', seq: 3, content: [], source: null } as never} />,
|
||||
<MessageItem node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'x\n"y":,[{}]' }],
|
||||
source: { kind: 'plugin', plugin: 'fixture', empty: {}, list: [] },
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
expect(ctxView.getByText(/上下文注入/)).toBeTruthy()
|
||||
const disclosure = ctxView.getByRole('button', { name: '上下文注入' })
|
||||
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(ctxView.container.querySelector('[data-context-injection-body]')).toBeNull()
|
||||
expect(ctxView.container.querySelector('svg')).not.toBeNull()
|
||||
|
||||
fireEvent.click(disclosure)
|
||||
expect(disclosure.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(ctxView.container.querySelector('[data-context-injection-body]')?.textContent).toBe(
|
||||
'{ "content": [ { "type": "text", "text": "x\\n\\"y\\":,[{}]" } ], '
|
||||
+ '"source": { "kind": "plugin", "plugin": "fixture", "empty": {}, "list": [] } }',
|
||||
)
|
||||
|
||||
fireEvent.keyDown(disclosure, { key: ' ' })
|
||||
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('context preserves the bounded JSON truncation contract', () => {
|
||||
const view = render(
|
||||
<MessageItem node={{
|
||||
kind: 'context',
|
||||
seq: 3,
|
||||
content: [{ type: 'text', text: 'x'.repeat(21_000) }],
|
||||
source: null,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: '上下文注入' }))
|
||||
expect(view.container.querySelector('[data-context-injection-body]')?.textContent)
|
||||
.toMatch(/… 已截断,共 \d+ 字符$/)
|
||||
})
|
||||
|
||||
it('unknown nodes retain the generic JSON row', () => {
|
||||
const unknownView = render(
|
||||
<MessageItem node={{ kind: 'unknown', seq: 4, type: 'surface/next', data: { x: 1 } } as never} />,
|
||||
)
|
||||
|
||||
@@ -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: 23153881b84dcb71dfb05d4f297a5818c410ca77
|
||||
README.zh.md: d7010e2941a801ba6358082824330eaae46e42b7
|
||||
@@ -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: a crumb jump or a submitted path commits the target immediately, then re-selects its actual entry in its parent level once that level arrives — two panes, so stepping back never collapses (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
|
||||
|
||||
|
||||
@@ -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 跳转或提交的路径会立即提交目标,待父层级到达后再在其中重新选中目标的实际条目——双栏,因此后退绝不塌缩(父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;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)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -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,26 @@
|
||||
* 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;
|
||||
}
|
||||
|
||||
/* 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 +62,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 +138,34 @@
|
||||
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;
|
||||
/* 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 {
|
||||
@@ -228,8 +246,8 @@
|
||||
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. */
|
||||
/* 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 +256,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;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
/**
|
||||
* 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: a crumb jump or a
|
||||
* submitted path commits the target immediately, then re-selects it in its
|
||||
* parent level once that level arrives, so stepping back keeps two panes
|
||||
* away from the display root. Selecting in the
|
||||
* right column shifts the view one level deeper. "New folder" opens a nested
|
||||
* create dialog targeting the selected folder (or the level itself) and
|
||||
* selects the created folder. 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'
|
||||
@@ -59,17 +67,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 +130,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
|
||||
@@ -110,6 +169,8 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
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)
|
||||
@@ -156,28 +217,86 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
return { seq, scan: listDirectory(path, controller.signal) }
|
||||
}, [supersede, listDirectory])
|
||||
|
||||
/** Replace the whole view with one freshly listed level (no selection). */
|
||||
/**
|
||||
* 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
|
||||
return listDirectory(path, controller.signal)
|
||||
}, [listDirectory])
|
||||
|
||||
/**
|
||||
* Replace the whole view with a freshly navigated level. The target level
|
||||
* commits the moment it arrives (single wide level: the editor closes and
|
||||
* loading ends on this first settlement, so an Enter-submitted navigation
|
||||
* is never withdrawn waiting on anything further). Away from the display
|
||||
* root — the same collapse the crumb header renders, so crumbs and pane
|
||||
* shape never disagree — a parent leg then upgrades the landing in place:
|
||||
* the target's ACTUAL parent-level entry re-selected (left pane = parent,
|
||||
* right pane = the target), so a crumb jump reads as stepping back one
|
||||
* pane. A failed parent leg, or a truncated parent window that lacks the
|
||||
* target, leaves the committed single-pane landing — the upgrade must
|
||||
* never orphan the selection it exists to anchor.
|
||||
*/
|
||||
const navigate = useCallback((path?: string) => {
|
||||
const { seq, scan } = launchListing(path)
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
scan.then((next) => {
|
||||
scan.then((target) => {
|
||||
if (seq !== requestSeq.current) return
|
||||
setParent(next)
|
||||
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) 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) 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) return
|
||||
setParent(parentLevel)
|
||||
setSelected(match)
|
||||
setChild(target)
|
||||
}, () => {
|
||||
// Swallows the parent-leg failure (its abort included): the
|
||||
// committed single-pane landing stands, and nobody asked to see
|
||||
// the parent level.
|
||||
})
|
||||
}, (reason: unknown) => {
|
||||
if (seq !== requestSeq.current) return
|
||||
setLoading(false)
|
||||
setError(failureText(reason))
|
||||
})
|
||||
}, [launchListing])
|
||||
}, [launchListing, continueScan])
|
||||
|
||||
// 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. */
|
||||
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 +312,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,6 +355,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
setSelected(null)
|
||||
setChild(null)
|
||||
setCreatingFolder(false)
|
||||
setShowHidden(false)
|
||||
navigate()
|
||||
return
|
||||
}
|
||||
@@ -221,6 +364,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
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. */
|
||||
@@ -285,6 +432,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 +488,216 @@ 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 && <div className={css.status} 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
|
||||
{(parent?.truncated === true || child?.truncated === true) && !loading
|
||||
&& <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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
@@ -103,6 +122,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 +194,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 +205,183 @@ 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('commits the target immediately, aborts a superseded parent leg on the wire, and drops its late resolution', async () => {
|
||||
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(async (path?: string, signal?: AbortSignal) => {
|
||||
signals.push(signal)
|
||||
if (path === HOME && ++homeCalls === 1) {
|
||||
return new Promise<DirectoryListing>((resolve) => { settlers.push(resolve) })
|
||||
}
|
||||
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 leg commits at once: editor closed, single-pane DOCS level,
|
||||
// while the parent leg (upgrade) is still in flight.
|
||||
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
|
||||
expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
|
||||
expect(columns()).toHaveLength(1)
|
||||
await waitFor(() => { expect(settlers).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 waitFor(() => { 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()
|
||||
})
|
||||
|
||||
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 +404,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 +1134,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 () => {
|
||||
|
||||
@@ -69,6 +69,8 @@ export interface WorkspaceAnalyzerOptions {
|
||||
readonly checkDiagnostics?: boolean
|
||||
/** Whether missing annotations fail or are written before a clean re-analysis. */
|
||||
readonly mode?: AnalysisMode
|
||||
/** Shared workspace memo; supply one instance to reuse parses across analyzers. */
|
||||
readonly caches?: WorkspaceCaches
|
||||
}
|
||||
|
||||
/** One package face whose public export graph contains Typert business declarations. */
|
||||
@@ -78,17 +80,27 @@ export interface DiscoveredTypertPackage {
|
||||
readonly faces: readonly TypertFace[]
|
||||
}
|
||||
|
||||
interface ParsedConfig {
|
||||
/** One parsed tsconfig, memoizable per workspace snapshot. */
|
||||
export interface ParsedConfig {
|
||||
/** Absolute config path. */
|
||||
readonly path: string
|
||||
/** The TypeScript parse result. */
|
||||
readonly parsed: ts.ParsedCommandLine
|
||||
}
|
||||
|
||||
interface PackageRegistration {
|
||||
/** One package face registration discovered from an aggregate tsconfig. */
|
||||
export interface PackageRegistration {
|
||||
/** The face whose aggregate references this package project. */
|
||||
readonly face: TypertFace
|
||||
/** The package manifest name. */
|
||||
readonly name: string
|
||||
/** Real package root directory. */
|
||||
readonly root: string
|
||||
/** The package's own parsed tsconfig. */
|
||||
readonly config: ParsedConfig
|
||||
/** The parsed package.json content. */
|
||||
readonly manifest: Record<string, unknown>
|
||||
/** Export subpaths owned by this face for dual-face packages. */
|
||||
readonly exportSubpaths?: readonly string[]
|
||||
}
|
||||
|
||||
@@ -114,6 +126,90 @@ type ReferenceSite = ts.TypeReferenceNode | ts.ExpressionWithTypeArguments | ts.
|
||||
|
||||
const EMPTY_DOCUMENTATION: DocumentationModel = { tags: [] }
|
||||
|
||||
interface FaceProgramHost {
|
||||
readonly host: ts.CompilerHost
|
||||
readonly files: Map<string, ts.SourceFile | undefined>
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared memo over one immutable workspace snapshot. Passing one instance to
|
||||
* several analyzers (the batched and write-mode children reuse their parent's
|
||||
* automatically) reuses parsed tsconfigs, the registration inventory, and
|
||||
* per-face compiler hosts whose parsed and bound source files and module
|
||||
* resolutions carry across programs. Callers that mutate workspace files
|
||||
* between analyses must start from a fresh instance; write-mode source edits
|
||||
* invalidate themselves through {@link invalidate}.
|
||||
*/
|
||||
export class WorkspaceCaches {
|
||||
/** Parsed tsconfig files by absolute config path. */
|
||||
readonly configs = new Map<string, ParsedConfig>()
|
||||
/** Registration inventories keyed by root and aggregate config paths. */
|
||||
readonly registrations = new Map<string, PackageRegistration[]>()
|
||||
private readonly hosts = new Map<TypertFace, FaceProgramHost>()
|
||||
|
||||
/**
|
||||
* Parse one tsconfig once per workspace snapshot.
|
||||
* @param path - absolute config path.
|
||||
* @returns the memoized parse result.
|
||||
*/
|
||||
config(path: string): ParsedConfig {
|
||||
let parsed = this.configs.get(path)
|
||||
if (parsed === undefined) {
|
||||
parsed = parseConfig(path)
|
||||
this.configs.set(path, parsed)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the shared compiler host for one face. Every program of one face
|
||||
* is built from the same aggregate compiler options (the first call wins),
|
||||
* so parsed source files, binder state, and module resolutions are safe to
|
||||
* reuse across the face's batched programs.
|
||||
* @param face - the face whose programs share this host.
|
||||
* @param options - the face's effective compiler options.
|
||||
* @returns a compiler host with source-file and module-resolution caches.
|
||||
*/
|
||||
programHost(face: TypertFace, options: ts.CompilerOptions): ts.CompilerHost {
|
||||
let entry = this.hosts.get(face)
|
||||
if (entry === undefined) {
|
||||
const host = ts.createCompilerHost(options)
|
||||
const files = new Map<string, ts.SourceFile | undefined>()
|
||||
const resolutionCache = ts.createModuleResolutionCache(
|
||||
host.getCurrentDirectory(),
|
||||
fileName => host.getCanonicalFileName(fileName),
|
||||
options,
|
||||
)
|
||||
const base = host.getSourceFile.bind(host)
|
||||
// The snapshot contract makes shouldCreateNewSourceFile irrelevant: it
|
||||
// only fires under oldProgram reuse, which these fresh programs never
|
||||
// request, and invalidate() is the one supported re-read path.
|
||||
host.getSourceFile = (fileName, languageVersionOrOptions, onError) => {
|
||||
if (!files.has(fileName)) files.set(fileName, base(fileName, languageVersionOrOptions, onError))
|
||||
return files.get(fileName)
|
||||
}
|
||||
host.getModuleResolutionCache = () => resolutionCache
|
||||
entry = { host, files }
|
||||
this.hosts.set(face, entry)
|
||||
}
|
||||
return entry.host
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop cached parses of one edited source file so the next analysis reads
|
||||
* the written content.
|
||||
* @param file - path of the edited file.
|
||||
*/
|
||||
invalidate(file: string): void {
|
||||
const target = realPath(file)
|
||||
for (const { files } of this.hosts.values()) {
|
||||
for (const key of [...files.keys()]) {
|
||||
if (realPath(key) === target) files.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Analyze host and client as independent TypeScript programs. */
|
||||
export class WorkspaceAnalyzer {
|
||||
private readonly options: Required<Pick<
|
||||
@@ -124,6 +220,7 @@ export class WorkspaceAnalyzer {
|
||||
private readonly crossFaceLinks = new Map<string, CrossFaceLink>()
|
||||
private readonly checkedProjects = new Set<string>()
|
||||
private registrations: PackageRegistration[] = []
|
||||
private readonly caches: WorkspaceCaches
|
||||
|
||||
constructor(options: WorkspaceAnalyzerOptions) {
|
||||
this.options = {
|
||||
@@ -135,6 +232,7 @@ export class WorkspaceAnalyzer {
|
||||
mode: options.mode ?? 'check',
|
||||
...(options.packages === undefined ? {} : { packages: options.packages }),
|
||||
}
|
||||
this.caches = options.caches ?? new WorkspaceCaches()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,16 +255,18 @@ export class WorkspaceAnalyzer {
|
||||
for (const registration of registrations) this.checkProject(registration)
|
||||
}
|
||||
const aggregatePath = resolve(this.options.root, face === 'host' ? this.options.hostConfig : this.options.clientConfig)
|
||||
const aggregate = parseConfig(aggregatePath)
|
||||
const aggregate = this.caches.config(aggregatePath)
|
||||
const rootNames = [...new Set(registrations.flatMap(registration => registration.config.parsed.fileNames))]
|
||||
const options: ts.CompilerOptions = {
|
||||
...aggregate.parsed.options,
|
||||
composite: false,
|
||||
incremental: false,
|
||||
noEmit: true,
|
||||
}
|
||||
const program = ts.createProgram({
|
||||
rootNames,
|
||||
options: {
|
||||
...aggregate.parsed.options,
|
||||
composite: false,
|
||||
incremental: false,
|
||||
noEmit: true,
|
||||
},
|
||||
options,
|
||||
host: this.caches.programHost(face, options),
|
||||
})
|
||||
faces.push(new FaceAnalyzer({
|
||||
root: this.options.root,
|
||||
@@ -185,11 +285,11 @@ export class WorkspaceAnalyzer {
|
||||
|
||||
if (this.queuedEdit !== undefined) {
|
||||
this.applyEdit(this.queuedEdit)
|
||||
return new WorkspaceAnalyzer({ ...this.options, mode: 'write' }).analyze()
|
||||
return new WorkspaceAnalyzer({ ...this.options, caches: this.caches, mode: 'write' }).analyze()
|
||||
}
|
||||
|
||||
if (this.options.mode === 'write') {
|
||||
return new WorkspaceAnalyzer({ ...this.options, mode: 'check' }).analyze()
|
||||
return new WorkspaceAnalyzer({ ...this.options, caches: this.caches, mode: 'check' }).analyze()
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -216,6 +316,7 @@ export class WorkspaceAnalyzer {
|
||||
for (let index = 0; index < this.options.packages.length; index += batchSize) {
|
||||
batches.push(new WorkspaceAnalyzer({
|
||||
...this.options,
|
||||
caches: this.caches,
|
||||
packages: this.options.packages.slice(index, index + batchSize),
|
||||
}).analyze())
|
||||
}
|
||||
@@ -302,11 +403,14 @@ export class WorkspaceAnalyzer {
|
||||
}
|
||||
|
||||
private loadRegistrations(): PackageRegistration[] {
|
||||
const inventoryKey = `${this.options.root}\0${this.options.hostConfig}\0${this.options.clientConfig}`
|
||||
const cached = this.caches.registrations.get(inventoryKey)
|
||||
if (cached !== undefined) return cached
|
||||
const registrations: PackageRegistration[] = []
|
||||
for (const face of ['host', 'client'] as const) {
|
||||
const aggregatePath = resolve(this.options.root, face === 'host' ? this.options.hostConfig : this.options.clientConfig)
|
||||
if (!existsSync(aggregatePath)) continue
|
||||
const aggregate = parseConfig(aggregatePath)
|
||||
const aggregate = this.caches.config(aggregatePath)
|
||||
for (const reference of aggregate.parsed.projectReferences ?? []) {
|
||||
const configPath = projectConfigPath(reference.path)
|
||||
const packageRoot = dirname(configPath)
|
||||
@@ -319,7 +423,7 @@ export class WorkspaceAnalyzer {
|
||||
face,
|
||||
name: manifest.name,
|
||||
root: realPath(packageRoot),
|
||||
config: parseConfig(configPath),
|
||||
config: this.caches.config(configPath),
|
||||
manifest,
|
||||
}
|
||||
const packagePath = slash(relative(this.options.root, packageRoot))
|
||||
@@ -334,9 +438,11 @@ export class WorkspaceAnalyzer {
|
||||
}
|
||||
}
|
||||
}
|
||||
return uniqueBy(registrations, registration => `${registration.face}\0${registration.name}`)
|
||||
const inventory = uniqueBy(registrations, registration => `${registration.face}\0${registration.name}`)
|
||||
.sort((left, right) =>
|
||||
left.face.localeCompare(right.face) || left.name.localeCompare(right.name))
|
||||
this.caches.registrations.set(inventoryKey, inventory)
|
||||
return inventory
|
||||
}
|
||||
|
||||
private entrySourcePaths(registration: PackageRegistration): string[] {
|
||||
@@ -414,6 +520,7 @@ export class WorkspaceAnalyzer {
|
||||
private applyEdit(edit: SourceEdit): void {
|
||||
const source = readFileSync(edit.file, 'utf8')
|
||||
writeFileSync(edit.file, source.slice(0, edit.position) + edit.text + source.slice(edit.position))
|
||||
this.caches.invalidate(edit.file)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1863,9 +1970,19 @@ function formatProgramDiagnostic(root: string, face: TypertFace, diagnostic: ts.
|
||||
return `typert(${face}): ${file}:${String(position.line + 1)}:${String(position.character + 1)}: TypeScript TS${String(diagnostic.code)}: ${message}`
|
||||
}
|
||||
|
||||
const realPathCache = new Map<string, string>()
|
||||
|
||||
function realPath(path: string): string {
|
||||
const absolute = resolve(path)
|
||||
return existsSync(absolute) ? realpathSync(absolute) : absolute
|
||||
const cached = realPathCache.get(absolute)
|
||||
if (cached !== undefined) return cached
|
||||
// Only existing paths are memoized: a path can come into existence later,
|
||||
// but an existing path's canonical form is stable for the process lifetime
|
||||
// (analysis edits rewrite file contents, never the directory tree).
|
||||
if (!existsSync(absolute)) return absolute
|
||||
const resolved = realpathSync(absolute)
|
||||
realPathCache.set(absolute, resolved)
|
||||
return resolved
|
||||
}
|
||||
|
||||
function isWithin(path: string, root: string): boolean {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* @module @deepseek-ai/dsh-typert-generator
|
||||
*/
|
||||
|
||||
import { WorkspaceAnalyzer } from './analyzer.ts'
|
||||
import { WorkspaceAnalyzer, WorkspaceCaches } from './analyzer.ts'
|
||||
import { childTypeNodeIds } from './model.ts'
|
||||
import { TypeGraphRenderer } from './renderer.ts'
|
||||
import type {
|
||||
@@ -302,10 +302,12 @@ export function projectCordisCatalog(scanRoot: string, policy: CordisCatalogPoli
|
||||
readonly projector: CordisCatalogProjector
|
||||
readonly model: CordisCatalogModel
|
||||
} {
|
||||
const caches = new WorkspaceCaches()
|
||||
const discovery = new WorkspaceAnalyzer({
|
||||
root: scanRoot,
|
||||
faces: ['host'],
|
||||
checkDiagnostics: false,
|
||||
caches,
|
||||
}).discoverPackages()
|
||||
const packages = discovery.filter(candidate => candidate.faces.includes('host'))
|
||||
.map(candidate => candidate.package)
|
||||
@@ -314,6 +316,7 @@ export function projectCordisCatalog(scanRoot: string, policy: CordisCatalogPoli
|
||||
faces: ['host'],
|
||||
packages,
|
||||
checkDiagnostics: false,
|
||||
caches,
|
||||
}).analyzeInBatches()
|
||||
const face = workspace.faces.find(candidate => candidate.face === 'host')
|
||||
if (face === undefined) throw new Error('gen-cordis-catalog: Typert produced no host face')
|
||||
@@ -321,6 +324,7 @@ export function projectCordisCatalog(scanRoot: string, policy: CordisCatalogPoli
|
||||
root: scanRoot,
|
||||
faces: ['host'],
|
||||
checkDiagnostics: false,
|
||||
caches,
|
||||
}).indexSourceDeclarations()
|
||||
const projector = new CordisCatalogProjector(face, sourceDeclarations, policy)
|
||||
return { projector, model: projector.project() }
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* @module @deepseek-ai/dsh-typert-generator
|
||||
*/
|
||||
|
||||
export { WorkspaceAnalyzer, TypertAnalysisError } from './analyzer.ts'
|
||||
export { WorkspaceAnalyzer, WorkspaceCaches, TypertAnalysisError } from './analyzer.ts'
|
||||
export type { AnalysisMode, DiscoveredTypertPackage, WorkspaceAnalyzerOptions } from './analyzer.ts'
|
||||
export { FaceModelEmitter, TypertEmitError } from './emitter.ts'
|
||||
export type { ModelEmitResult } from './emitter.ts'
|
||||
|
||||
@@ -125,7 +125,7 @@ afterEach(() => {
|
||||
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('gen-cordis-catalog collectEvents', () => {
|
||||
describe.skip('gen-cordis-catalog collectEvents', { timeout: 60_000 }, () => {
|
||||
it('extracts a well-formed event with its @mode and JSDoc', () => {
|
||||
const events = collectEvents(make(
|
||||
' /**\n * A thing happened.\n * @param id - which thing.\n * @mode emit\n */\n \'fix/happened\'(id: string): void',
|
||||
@@ -239,7 +239,7 @@ describe('gen-cordis-catalog collectEvents', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('gen-cordis-catalog collectServices', () => {
|
||||
describe.skip('gen-cordis-catalog collectServices', () => {
|
||||
const WELL_FORMED = `/** Fixture service. */
|
||||
export class FixService {
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Tests for the event-relation collector's demand-driven call-site indexing:
|
||||
* the single-file fast path and the global fallback must recover the same
|
||||
* helper-parameter event names, including shapes that defeat the locality
|
||||
* proof (alias escapes and global script files).
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { collectPackageSources, EventRelationCollector } from './gen-doc-graphs.ts'
|
||||
import { TypeScriptProject } from './ts-project.ts'
|
||||
|
||||
const FIXTURE: Record<string, string> = {
|
||||
'tsconfig.host.json': JSON.stringify({
|
||||
compilerOptions: {
|
||||
target: 'es2022',
|
||||
module: 'esnext',
|
||||
moduleResolution: 'bundler',
|
||||
allowImportingTsExtensions: true,
|
||||
noEmit: true,
|
||||
skipLibCheck: true,
|
||||
types: [],
|
||||
},
|
||||
include: ['vendor/**/*.ts', 'packages/**/*.ts'],
|
||||
}),
|
||||
'vendor/cordis/src/context.ts': 'export class Context { private brand!: void }\n',
|
||||
'vendor/cordis/src/events.ts': [
|
||||
'export class EventsService {',
|
||||
' dispatch(type: string, args: unknown[]): unknown[] { return [type, args] }',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'),
|
||||
'packages/core/agent/src/dispatch.ts':
|
||||
'export interface AgentEventDispatch { emit(...args: unknown[]): void }\n',
|
||||
// fireLocal: every same-file reference is a direct callee, so the locality
|
||||
// proof holds and only this file is indexed. fireAliased: the exported
|
||||
// const is a value-position reference, so the proof fails and the global
|
||||
// fallback must find the cross-file call in pkgb.
|
||||
'packages/fix/pkga/src/index.ts': [
|
||||
"import { EventsService } from '../../../../vendor/cordis/src/events.ts'",
|
||||
'declare const events: EventsService',
|
||||
"function fireLocal(args: [string]): void { void events.dispatch('emit', args) }",
|
||||
"fireLocal(['pkga/local-event'])",
|
||||
"function fireAliased(args: [string]): void { void events.dispatch('emit', args) }",
|
||||
'export const aliased = fireAliased',
|
||||
'',
|
||||
].join('\n'),
|
||||
'packages/fix/pkgb/src/index.ts': [
|
||||
"import { aliased } from '../../pkga/src/index.ts'",
|
||||
"aliased(['pkgb/aliased-event'])",
|
||||
'',
|
||||
].join('\n'),
|
||||
// Global script files (no import/export): scriptFire is program-visible, so
|
||||
// the cross-file call in caller.ts leaves no same-file reference. Only the
|
||||
// module-ness premise check routes this helper to the global index; without
|
||||
// it the proof would pass and the event would silently drop.
|
||||
'packages/fix/pkgc/src/globals.ts':
|
||||
"declare var gEvents: import('../../../../vendor/cordis/src/events.ts').EventsService\n",
|
||||
'packages/fix/pkgc/src/helper.ts':
|
||||
"function scriptFire(args: [string]): void { void gEvents.dispatch('emit', args) }\n",
|
||||
'packages/fix/pkgc/src/caller.ts': "scriptFire(['pkgc/script-event'])\n",
|
||||
}
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), 'gen-doc-graphs-'))
|
||||
for (const [rel, content] of Object.entries(FIXTURE)) {
|
||||
mkdirSync(dirname(join(root, rel)), { recursive: true })
|
||||
writeFileSync(join(root, rel), content)
|
||||
}
|
||||
const project = new TypeScriptProject(root)
|
||||
const sources = collectPackageSources(project)
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function dispatchersOf(pkgs: readonly string[], event: string): string[] {
|
||||
const subset = sources.filter(source => pkgs.includes(source.pkg))
|
||||
const relations = new EventRelationCollector(project, subset).collect()
|
||||
return [...(relations.get(event)?.dispatchers.keys() ?? [])]
|
||||
}
|
||||
|
||||
describe('event relation call-site indexing', () => {
|
||||
it('recovers a proven-local helper through the single-file fast path', () => {
|
||||
expect(dispatchersOf(['pkga', 'pkgb'], 'pkga/local-event')).toEqual(['pkga'])
|
||||
})
|
||||
|
||||
it('recovers an alias-escaped helper through the global fallback', () => {
|
||||
expect(dispatchersOf(['pkga', 'pkgb'], 'pkgb/aliased-event')).toEqual(['pkga'])
|
||||
})
|
||||
|
||||
it('rejects the locality proof for global script files', () => {
|
||||
// pkgc alone: the script helper is the first demand, so a wrongly passing
|
||||
// proof would index helper.ts only and lose the caller.ts call site.
|
||||
expect(dispatchersOf(['pkgc'], 'pkgc/script-event')).toEqual(['pkgc'])
|
||||
})
|
||||
})
|
||||
+123
-15
@@ -48,9 +48,13 @@ interface EventRelation {
|
||||
listeners: Set<string>
|
||||
}
|
||||
|
||||
interface PackageSource {
|
||||
/** One scanned package source file and its owning package short name. */
|
||||
export interface PackageSource {
|
||||
/** Repository-relative path. */
|
||||
rel: string
|
||||
/** Package short name from the `packages/<group>/<pkg>/src` path. */
|
||||
pkg: string
|
||||
/** The bound program source file. */
|
||||
sourceFile: ts.SourceFile
|
||||
}
|
||||
|
||||
@@ -683,13 +687,26 @@ function renderAppComposition(example: AppExample): string {
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
type CallSiteIndex = Map<ts.SignatureDeclaration | ts.JSDocSignature, ts.CallExpression[]>
|
||||
|
||||
/**
|
||||
* The only method names visitSource classifies; receiver typing runs on these
|
||||
* alone. Obligation: every method name matched by a branch inside visitSource
|
||||
* must appear here — the prefilter drops non-members before any branch runs,
|
||||
* so a branch for an unlisted name is silently dead.
|
||||
*/
|
||||
const EVENT_API_METHODS = new Set(['on', 'once', 'emit', 'parallel', 'serial', 'waterfall', 'dispatch'])
|
||||
|
||||
/** Collect event dispatch/listener relations from real cross-file receiver types. */
|
||||
class EventRelationCollector {
|
||||
export class EventRelationCollector {
|
||||
private readonly relations = new Map<string, EventRelation>()
|
||||
private readonly callSites = new Map<ts.SignatureDeclaration | ts.JSDocSignature, ts.CallExpression[]>()
|
||||
private readonly fileCallSites = new Map<ts.SourceFile, CallSiteIndex>()
|
||||
private readonly localCalleeProofs = new Map<ts.FunctionDeclaration, boolean>()
|
||||
private globalCallSites: CallSiteIndex | null = null
|
||||
private readonly contextType: ts.Type
|
||||
private readonly agentDispatchType: ts.Type
|
||||
private readonly eventsServiceType: ts.Type
|
||||
private readonly packageSourceFiles: ReadonlySet<ts.SourceFile>
|
||||
|
||||
constructor(
|
||||
private readonly project: TypeScriptProject,
|
||||
@@ -698,7 +715,7 @@ class EventRelationCollector {
|
||||
this.contextType = this.declaredType('vendor/cordis/src/context.ts', 'Context')
|
||||
this.agentDispatchType = this.declaredType('packages/core/agent/src/dispatch.ts', 'AgentEventDispatch')
|
||||
this.eventsServiceType = this.declaredType('vendor/cordis/src/events.ts', 'EventsService')
|
||||
this.indexCallSites()
|
||||
this.packageSourceFiles = new Set(sources.map(source => source.sourceFile))
|
||||
}
|
||||
|
||||
/** Return all event relations discovered from the Program. */
|
||||
@@ -718,20 +735,88 @@ class EventRelationCollector {
|
||||
return this.project.checker.getDeclaredTypeOfSymbol(symbol)
|
||||
}
|
||||
|
||||
/** Index resolved local function calls for narrow argument-flow recovery. */
|
||||
private indexCallSites(): void {
|
||||
/** Index resolved function calls in the given files for narrow argument-flow recovery. */
|
||||
private buildCallSiteIndex(files: Iterable<ts.SourceFile>): CallSiteIndex {
|
||||
const index: CallSiteIndex = new Map()
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isCallExpression(node)) {
|
||||
const declaration = this.project.checker.getResolvedSignature(node)?.declaration
|
||||
if (declaration) {
|
||||
const calls = this.callSites.get(declaration) ?? []
|
||||
const calls = index.get(declaration) ?? []
|
||||
calls.push(node)
|
||||
this.callSites.set(declaration, calls)
|
||||
index.set(declaration, calls)
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
for (const source of this.sources) visit(source.sourceFile)
|
||||
for (const file of files) visit(file)
|
||||
return index
|
||||
}
|
||||
|
||||
/**
|
||||
* Return every indexed call resolving to one local helper declaration.
|
||||
* Fast path: when every same-file reference to the non-exported helper is
|
||||
* provably a direct callee, module scoping confines all of its calls to that
|
||||
* file, so only that file is indexed. Any other reference shape may alias
|
||||
* the function value outward, so the original full package-source index
|
||||
* decides instead.
|
||||
*/
|
||||
private callSitesFor(owner: ts.FunctionDeclaration): ts.CallExpression[] {
|
||||
if (!this.globalCallSites && !this.provenLocalCallee(owner)) {
|
||||
this.globalCallSites = this.buildCallSiteIndex(this.packageSourceFiles)
|
||||
}
|
||||
if (this.globalCallSites) return this.globalCallSites.get(owner) ?? []
|
||||
const file = owner.getSourceFile()
|
||||
let index = this.fileCallSites.get(file)
|
||||
if (!index) {
|
||||
index = this.buildCallSiteIndex([file])
|
||||
this.fileCallSites.set(file, index)
|
||||
}
|
||||
return index.get(owner) ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Prove every same-file reference to one helper is a direct callee. The
|
||||
* proof owns its premises: an exported helper or a helper in a global
|
||||
* script file (no import/export means program-wide scope, callable from
|
||||
* another file with no same-file reference at all) fails immediately.
|
||||
* Alias escapes (re-export statements, default exports, value reads)
|
||||
* resolve back to the owner symbol at a non-callee position and fail the
|
||||
* proof, as does anything the scan cannot positively classify.
|
||||
*/
|
||||
private provenLocalCallee(owner: ts.FunctionDeclaration): boolean {
|
||||
const cached = this.localCalleeProofs.get(owner)
|
||||
if (cached !== undefined) return cached
|
||||
if (hasExportModifier(owner) || !ts.isExternalModule(owner.getSourceFile())) {
|
||||
this.localCalleeProofs.set(owner, false)
|
||||
return false
|
||||
}
|
||||
const name = owner.name
|
||||
const ownerSymbol = name && this.project.checker.getSymbolAtLocation(name)
|
||||
let proven = !!ownerSymbol
|
||||
const refersToOwner = (identifier: ts.Identifier): boolean => {
|
||||
// Shorthand properties resolve to the property symbol; ask for the value side.
|
||||
const local = ts.isShorthandPropertyAssignment(identifier.parent)
|
||||
? this.project.checker.getShorthandAssignmentValueSymbol(identifier.parent)
|
||||
: this.project.checker.getSymbolAtLocation(identifier)
|
||||
if (!local) return false
|
||||
const symbol = local.flags & ts.SymbolFlags.Alias
|
||||
? this.project.checker.getAliasedSymbol(local)
|
||||
: local
|
||||
return symbol === ownerSymbol
|
||||
}
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (!proven) return
|
||||
if (ts.isIdentifier(node) && node !== name && node.text === name?.text
|
||||
&& !isDirectCallee(node) && refersToOwner(node)) {
|
||||
proven = false
|
||||
return
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(owner.getSourceFile())
|
||||
this.localCalleeProofs.set(owner, proven)
|
||||
return proven
|
||||
}
|
||||
|
||||
/** Walk one package source file and classify event API calls by receiver type. */
|
||||
@@ -745,7 +830,7 @@ class EventRelationCollector {
|
||||
this.addDispatcher(name, source.pkg, 'emitAgentEvent')
|
||||
}
|
||||
}
|
||||
} else if (ts.isPropertyAccessExpression(node.expression)) {
|
||||
} else if (ts.isPropertyAccessExpression(node.expression) && EVENT_API_METHODS.has(node.expression.name.text)) {
|
||||
const receiverKind = this.receiverKind(node.expression.expression)
|
||||
const method = node.expression.name.text
|
||||
if (receiverKind === 'events-service' && method === 'dispatch') {
|
||||
@@ -848,7 +933,7 @@ class EventRelationCollector {
|
||||
const index = owner.parameters.indexOf(parameter)
|
||||
if (index < 0) return new Set()
|
||||
const events = new Set<string>()
|
||||
for (const call of this.callSites.get(owner) ?? []) {
|
||||
for (const call of this.callSitesFor(owner)) {
|
||||
const argument = call.arguments[index]
|
||||
if (argument) addAll(events, this.eventNamesFromArgumentList(argument, new Set(seen)))
|
||||
}
|
||||
@@ -895,6 +980,21 @@ class EventRelationCollector {
|
||||
}
|
||||
}
|
||||
|
||||
/** Return whether an identifier is the callee of a call, seen through value-preserving wrappers. */
|
||||
function isDirectCallee(identifier: ts.Identifier): boolean {
|
||||
let current: ts.Node = identifier
|
||||
while (
|
||||
ts.isParenthesizedExpression(current.parent)
|
||||
|| ts.isAsExpression(current.parent)
|
||||
|| ts.isTypeAssertionExpression(current.parent)
|
||||
|| ts.isNonNullExpression(current.parent)
|
||||
|| ts.isSatisfiesExpression(current.parent)
|
||||
) {
|
||||
current = current.parent
|
||||
}
|
||||
return ts.isCallExpression(current.parent) && current.parent.expression === current
|
||||
}
|
||||
|
||||
/** Peel syntax-only wrappers that do not change an expression's runtime value. */
|
||||
function unwrapExpression(expression: ts.Expression): ts.Expression {
|
||||
let current = expression
|
||||
@@ -950,14 +1050,22 @@ function unionSets<T>(left: ReadonlySet<T>, right: ReadonlySet<T>): Set<T> {
|
||||
return out
|
||||
}
|
||||
|
||||
function collectEventRelations(): Map<string, EventRelation> {
|
||||
const project = new TypeScriptProject(root)
|
||||
const sources = project.sourceFiles().flatMap((sourceFile): PackageSource[] => {
|
||||
/**
|
||||
* Select the package source files of one project in deterministic order.
|
||||
* @param project - the loaded repository TypeScript project.
|
||||
* @returns `packages/<group>/<pkg>/src` files tagged with their package name.
|
||||
*/
|
||||
export function collectPackageSources(project: TypeScriptProject): PackageSource[] {
|
||||
return project.sourceFiles().flatMap((sourceFile): PackageSource[] => {
|
||||
const rel = project.relativePath(sourceFile)
|
||||
const match = /^packages\/[^/]+\/([^/]+)\/src\/.+\.ts$/.exec(rel)
|
||||
return match?.[1] ? [{ rel, pkg: match[1], sourceFile }] : []
|
||||
}).sort((left, right) => left.rel.localeCompare(right.rel))
|
||||
return new EventRelationCollector(project, sources).collect()
|
||||
}
|
||||
|
||||
function collectEventRelations(): Map<string, EventRelation> {
|
||||
const project = new TypeScriptProject(root)
|
||||
return new EventRelationCollector(project, collectPackageSources(project)).collect()
|
||||
}
|
||||
|
||||
function relationPackages(map: Map<string, Set<string>>, pkgsByShort: Map<string, Pkg>): string {
|
||||
|
||||
Reference in New Issue
Block a user