diff --git a/.agents/notes/implemented/architecture/2026-10-20-workspace-tree-state-per-pane.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-20-workspace-tree-state-per-pane.i18n.yaml new file mode 100644 index 0000000000..bcc252082f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-20-workspace-tree-state-per-pane.i18n.yaml @@ -0,0 +1,37 @@ +# Bilingual-pair consistency record for 2026-10-20-workspace-tree-state-per-pane.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-20-workspace-tree-state-per-pane.md +/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open: + en: 3768a6ac42af3a0b + zh: ba3c0826b2a4d287 +/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open/problem: + en: 3a7f14dd68a3ff14 + zh: b21be07e8a7e6024 +/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open/decision: + en: a15c7c13f167e5ac + zh: ff261e3705c47ff0 +/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open/decision/the-tree-is-drawn-inside-each-file-tab-and-its-state-is-kept-per-pane: + en: 432aff475f1dfd21 + zh: bbc058c1f967c067 +/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open/decision/the-rows-are-a-controlled-component-factory-over-flat-entries: + en: a06955933f9f7f0e + zh: ccda2bd27175d36b +/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open/decision/the-plugin-that-mounts-the-tree-decides-where-a-file-lands: + en: 7cf8e3671900fe71 + zh: 952f69cba9c44c10 +/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open/decision/navigation-is-consumed-by-the-destination-tab: + en: e7b254df55d9768d + zh: d3303f20bb9ebdd6 +/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open/decision/each-package-works-without-the-others: + en: dd160808a3ac77bc + zh: f63741f159717072 +/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open/alternatives-considered: + en: 1c5501c71afc7748 + zh: 3a2b0ec2d7923a12 +/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open/consequences: + en: 586ec09cfcf60b82 + zh: ef683318fbf68d71 +/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open/verification: + en: eae354783022bed8 + zh: fe2563a3bb259d55 diff --git a/.agents/notes/implemented/architecture/2026-10-20-workspace-tree-state-per-pane.md b/.agents/notes/implemented/architecture/2026-10-20-workspace-tree-state-per-pane.md new file mode 100644 index 0000000000..3f1756978a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-20-workspace-tree-state-per-pane.md @@ -0,0 +1,74 @@ +# Agent Note: The workspace tree is per-pane state drawn inside each file tab, and the plugin mounting it decides where files open + +Status: implemented + +English | [中文](2026-10-20-workspace-tree-state-per-pane.zh.md) + +## Problem + +Reading several workspace files needs a tree beside their previews without losing expansion, loaded levels, width, scroll, or keyboard position on every tab switch. The dock mounts only the selected tab's body, so a component instance cannot own the state shared by a pane's file tabs. + +A floating panel holds one tab. A preview's file picker must replace that tab in its panel, while the files page must remain available and open content in the dock. A covering tree must close where content is navigated to, not where the initiating click happened. + +## Decision + +### The tree is drawn inside each file tab, and its state is kept per pane + +`ui-sidebar-right` supplies `useTabInfo().panel` with the containing pane's `id` and `isFloating`; moving a tab updates those fields without changing `tab.id`. It supplies no pane lifetime. `ui-sidebar-files` owns each pane's tree state, visibility, width, listing, directory watches, scroll, and last revealed file. The navigator and toggle are Session-scoped Component Factories; `ui-sidebar-documentpreview` renders them inside the tab's content row and header, with stable positions across loading states. The docking kit does not acquire a files-specific overlay or layout rule. + +A pane's records follow the tabs whose navigator last drew there, using each tab's `signal`. A replacement in the same pane retains the records. A moved tab uses its destination pane's records; a pane without records starts hidden at the default width. Tab-owned preview scroll is independent of that tree state. Records are released after their last associated tab closes or draws the navigator elsewhere. + +Directory watches follow visible drawings rather than retained records. Hidden trees hold no Host directory watch; showing them again refreshes the root and expanded directories while retaining rows. A same-pane tab switch keeps the listing and watches across the replacement drawing. This extends the [directory watch lifetimes](../feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md) from one tab to the pane's drawn tree. + +The files face owns ancestor reveal, scrolling, row measurement, and focus across drawings, so replacing a drawing does not discard an ongoing interaction. [The navigator reference](../../../../packages/client/ui-sidebar-files/README.md#the-navigator) owns layout, workspace-command, and keyboard rules. + +### The rows are a controlled Component Factory over flat entries + +The root-scoped `sidebar.files.tree` Factory keeps reusable rows, its local `accessory` slot, and localized copy with the files plugin. Callers own data access, expansion, opening, and reveal. The workspace navigator adapts its pane's directory states to these inputs. + +### The plugin that mounts the tree decides where a file lands + +`FileNavigatorInput.onOpen(address)` is the containing plugin's callback. The navigator constructs the file address but neither chooses placement nor closes the initiating tree after the call. Its `page` input controls layout only. A callback keeps the same navigator usable for the files page and document previews without teaching its rows either host's opening policy. + +The [preview and files-page callbacks](../../../../packages/client/ui-sidebar-files/README.md#the-navigator) express different lifetimes: the preview gives way to new content, while the files page remains a directory browser. [Right Sidebar placement](../../../../packages/client/ui-sidebar-right/README.md#ctxsidebarright) owns replacement and reveal; the navigator does not duplicate those policies. + +### Navigation is consumed by the destination tab + +The files face follows the existing navigation observable only for tabs attached to its navigator. It retains each tab's consumed revision independently of the drawing and releases that subscription on tab closure or plugin disposal. It does not require a layout inventory, navigation-origin field, or an opening result from the right Sidebar. + +The face waits for the destination drawing and measures its current row, so the previous tab's width cannot decide whether content is covered. It consumes navigation even while hidden to prevent a later manual show from replaying an old close. [The navigator reference](../../../../packages/client/ui-sidebar-files/README.md#the-navigator) defines the navigation sources and revision exclusions. + +### Each package works without the others + +The packages compose through the right Sidebar's tab information, the files Factories, and the tab-type registry. The preview imports only the files Factory declarations; files has no preview dependency. Without a registered viewer, the files page still lists the workspace and disables file rows it cannot open. Without the files plugin, the unregistered Factory positions render nothing and the preview keeps its own header, body, and file-handoff slots. + +## Alternatives considered + +**A static tree package or a component in `ui-primitives`.** The [package-shape rule](../../../../docs/defensive-patterns/package-surface.md) excludes new static libraries. A primitive would also require each caller to supply the generic tree labels. The Factory keeps that copy and registration with files, at the cost of requiring that plugin to draw rows. + +**A tree that owns directory reads or requires a complete path list.** A workspace is listed one expanded directory at a time. The tree therefore accepts partial entries and per-directory loading, failure, and truncation state. Owning reads inside each drawing would split the state and watch lifetimes that must survive tab switches. + +**A pane lifetime signal.** Floating and docking can replace the pane while retaining the tab. A pane signal would end tree state too early and invite attaching tab-owned resources to the wrong lifetime; transferring the signal would make it outlive the pane id used as the state key. The tabs' existing signals provide the required release condition. + +**A pane-level overlay that stays mounted.** It requires a layer above docked and floating tab hosts and changes to layout, focus, measurement, and drop targets to avoid intercepting input, including floating resize handles. Drawing inside tabs keeps these concerns in their existing owners. + +**Keep visited bodies mounted or copy per-tab trees.** Retaining every body still creates independent trees and watches for tabs sharing a pane and retains each preview's rendered content. Copying state cannot transfer watches and needs a conflict rule when tabs move between panes. + +**Keep watches for a pane's whole lifetime or release by inspecting its layout.** The main docked pane rarely disappears, so lifetime-bound watches remain after hiding. Inspecting tab kinds in the layout couples files to every navigator consumer and still does not express whether the tree is drawn. + +**A focus request bit consumed by a component effect.** A request stored on the initiating pane can survive a reveal elsewhere and affect a later drawing; a hidden consumer has no element to focus. The face ties focus to its actual drawings instead. + +**Carry a dragged tab's tree preference into its new pane.** This needs another transfer policy and conflicts with the state already owned by the destination pane. A new pane starts with the same defaults as every other new pane. + +## Consequences + +Tab remounts do not own or reset the pane's tree. Moving a tab selects the destination's state rather than copying the source's; hiding releases directory watches without deleting expansion and scroll. Navigation consumption is per tab, while display preferences remain per pane, so an open in another pane does not close the source tree. + +The files page retains directory reload and directory-action controls; a preview retains its file controls and handoff. Opening policy remains with those hosts, not the reusable rows. + +## Verification + +- `packages/client/ui-sidebar-right/tests/tab-info.client.spec.tsx`, `service.client.spec.ts`, and `seat.client.spec.tsx` cover live pane information, replacement, reveal, and framework tab hooks. +- `packages/client/ui-sidebar-files/tests/` covers per-pane state, navigation consumption, drawing lifetimes, workspace roots, and file opening callbacks. +- `packages/client/ui-sidebar-files/tests/file-tree.client.spec.tsx`, `file-tree-interactions.client.spec.tsx`, and `file-tree-factory.client.spec.tsx` cover generic rows, independent accessories, and real Factory composition with locale and fallback. +- `apps/web/tests/file-browser.e2e.ts` and `apps/web/tests/file-browser-composition.e2e.ts` own browser evidence; the latter disables each of `ui-sidebar-documentpreview` and `ui-sidebar-files` in the shipped Web composition. diff --git a/.agents/notes/implemented/architecture/2026-10-20-workspace-tree-state-per-pane.zh.md b/.agents/notes/implemented/architecture/2026-10-20-workspace-tree-state-per-pane.zh.md new file mode 100644 index 0000000000..e3580c7fe1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-20-workspace-tree-state-per-pane.zh.md @@ -0,0 +1,74 @@ +# Agent Note: 工作区目录树是按分栏保存的状态,在每个文件 tab 内绘制;挂载它的插件决定文件在哪里打开 + +Status: implemented + +[English](2026-10-20-workspace-tree-state-per-pane.md) | 中文 + +## 问题 + +连续阅读多个工作区文件需要预览旁的目录树在每次切换 tab 后保留展开状态、已加载的层、宽度、滚动位置与键盘位置。停靠布局只挂载选中 tab 的正文,因此组件实例不能拥有分栏内各文件 tab 共用的状态。 + +一个浮窗只容纳一个 tab。预览的文件选择器必须在浮窗内替换该 tab,文件页则必须保持可用,并在停靠区打开内容。遮盖正文的目录树应在内容被导航到的地方关闭,而不是在发起点击的地方关闭。 + +## 决定 + +### 目录树在每个文件 tab 内绘制,其状态按分栏保存 + +`ui-sidebar-right` 通过 `useTabInfo().panel` 提供所在分栏的 `id` 与 `isFloating`;移动 tab 时更新这些字段,`tab.id` 保持不变。它不提供分栏生命周期。`ui-sidebar-files` 拥有各分栏的目录树状态、可见性、宽度、列举、目录监听、滚动与上次定位的文件。导航栏和开关是 Session 作用域的 Component Factory;`ui-sidebar-documentpreview` 在 tab 的内容行与头部渲染它们,加载状态变化时保持位置稳定。停靠套件不增加 files 专属的覆盖层或布局规则。 + +分栏记录跟随最近在该分栏绘制导航栏的 tab,以各 tab 的 `signal` 为依据。同一分栏内的替换保留记录。移走的 tab 使用目标分栏的记录;没有记录的分栏默认隐藏目录树、使用默认宽度。tab 拥有的预览滚动状态独立于目录树状态。最后一个关联 tab 关闭或在别处绘制导航栏后,记录被释放。 + +目录监听跟随可见的绘制,而不是保留的记录。隐藏的目录树不持有 Host 目录监听;再次显示时保留行并刷新根目录与已展开目录。同一分栏内切换 tab 时,替代绘制之间保留目录列表与监听。这把[目录监听生命周期](../feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md)从单个 tab 扩展为分栏中已绘制的目录树。 + +files face 负责跨绘制的祖先定位、滚动、行宽测量与焦点,因此替换绘制不会丢弃进行中的交互。[导航栏参考](../../../../packages/client/ui-sidebar-files/README.zh.md#the-navigator)定义布局、工作区命令与键盘规则。 + +### 各行是以扁平条目为输入的受控 Component Factory + +root 作用域的 `sidebar.files.tree` Factory 将可复用的行、局部 `accessory` slot 和本地化文案留在 files 插件。调用方负责数据访问、展开、打开与定位。工作区导航栏把分栏的目录状态转换为这些输入。 + +### 挂载目录树的插件决定文件落在哪里 + +`FileNavigatorInput.onOpen(address)` 是容纳导航栏的插件提供的回调。导航栏构造文件地址,但不选择落位,也不在调用后关闭发起侧目录树。`page` 输入只控制布局。回调使同一导航栏能同时用于文件页与文档预览,不必让各行知道这两种宿主的打开策略。 + +[预览与文件页的回调](../../../../packages/client/ui-sidebar-files/README.zh.md#the-navigator)表达不同的生命周期:预览让位给新内容,文件页则保持为目录浏览器。[右侧 Sidebar 落位](../../../../packages/client/ui-sidebar-right/README.zh.md#ctxsidebarright)负责替换与揭示,导航栏不重复这些策略。 + +### 导航由目标 tab 消费 + +files face 仅跟随已关联到其导航栏的 tab 的现有导航可观察值。它独立于绘制保留各 tab 已消费的 revision,并在 tab 关闭或插件卸载时释放订阅。它不需要右侧 Sidebar 提供布局清单、导航来源字段或打开结果。 + +face 等待目标绘制并测量其当前行宽,因此上一个 tab 的宽度不能决定内容是否被覆盖。隐藏期间也消费导航,防止之后手动显示时重放旧的关闭操作。[导航栏参考](../../../../packages/client/ui-sidebar-files/README.zh.md#the-navigator)定义导航来源与 revision 排除规则。 + +### 每个包都可以单独工作 + +各包通过右侧 Sidebar 的 tab 信息、files Factory 和 tab 类型注册表组合。预览仅导入 files Factory 的声明;files 不依赖预览。没有已注册查看器时,文件页仍列出工作区,并禁用无法打开的文件行。没有 files 插件时,未注册的 Factory 位置不绘制内容,预览保留自己的头部、正文和文件交接 slot。 + +## 考虑过的替代方案 + +**静态目录树包或 `ui-primitives` 中的组件。** [包形态规则](../../../../docs/defensive-patterns/package-surface.zh.md)排除新增静态库。primitive 还要求各调用方传入通用目录树文案。Factory 使文案与注册留在 files,代价是绘制各行必须加载该插件。 + +**由目录树读取目录,或要求完整路径列表。** 工作区逐个展开的目录列出。因此目录树接收不完整条目,以及各目录的加载、失败与截断状态。在每次绘制中拥有读取会拆散必须跨 tab 切换保留的状态和监听生命周期。 + +**分栏生命周期信号。** 浮出和停靠可能替换分栏,但保留 tab。分栏信号会过早结束目录树状态,还会诱使调用方把 tab 资源绑到错误的生命周期;转移信号则让它比用作状态键的 pane id 活得更久。tab 已有的信号提供了所需的释放条件。 + +**始终挂载的分栏级覆盖层。** 它需要在停靠与浮动 tab 宿主之上增加一层,并修改布局、焦点、测量与拖放目标,避免拦截包括浮窗缩放手柄在内的输入。在 tab 内绘制则让这些职责留在原有所有者处。 + +**保留已访问正文或复制逐 tab 的目录树。** 保留所有正文仍会为同一分栏的不同 tab 创建独立目录树与监听,并保留各预览的已渲染内容。复制状态无法转移监听,还需要在 tab 跨分栏移动时决定冲突如何处理。 + +**让监听与分栏同寿,或检查布局后释放。** 主停靠分栏很少消失,因此按其生命周期持有的监听在隐藏后仍存在。检查布局中的 tab 类型会让 files 与每个导航栏消费方耦合,仍不能表达目录树是否正在绘制。 + +**由组件 effect 消费的聚焦请求位。** 保存在发起分栏的请求可能在揭示别处后留存,影响之后的绘制;隐藏的消费方也没有可聚焦元素。face 则把焦点与实际绘制绑定。 + +**把被拖动 tab 的目录树偏好带到新分栏。** 这需要另一套转移策略,并与目标分栏已经拥有的状态冲突。新分栏使用与其他新分栏相同的默认值。 + +## 后果 + +tab 重新挂载不拥有也不重置分栏目录树。移动 tab 时选择目标状态,不复制源状态;隐藏释放目录监听,但不删除展开与滚动状态。导航消费按 tab 保存,显示偏好仍按分栏保存,因此在另一分栏打开内容不会关闭源目录树。 + +文件页保留目录重新读取与目录操作控件;预览保留文件控件与交接操作。打开策略由这些宿主拥有,不属于可复用的行。 + +## 验证 + +- `packages/client/ui-sidebar-right/tests/tab-info.client.spec.tsx`、`service.client.spec.ts` 与 `seat.client.spec.tsx` 覆盖实时分栏信息、原位替换、揭示与框架 tab hook。 +- `packages/client/ui-sidebar-files/tests/` 覆盖逐分栏状态、导航消费、绘制生命周期、工作区根目录与文件打开回调。 +- `packages/client/ui-sidebar-files/tests/file-tree.client.spec.tsx`、`file-tree-interactions.client.spec.tsx` 与 `file-tree-factory.client.spec.tsx` 覆盖通用行、独立附件,以及带 locale 和 fallback 的真实 Factory 组合。 +- `apps/web/tests/file-browser.e2e.ts` 与 `apps/web/tests/file-browser-composition.e2e.ts` 负责浏览器验证;后者在随包发布的 Web 组合中分别禁用 `ui-sidebar-documentpreview` 与 `ui-sidebar-files`。 diff --git a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.i18n.yaml index 368a5a645b..2db7ec7484 100644 --- a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.i18n.yaml @@ -18,8 +18,8 @@ en: ca63fb903b447c2d zh: 3014adbdb0a890ee /agent-note-on-demand-watching-and-automatic-refresh-for-sidebar-previews-and-file-trees/implementation-and-responsibilities/client-and-sidebar: - en: 5cb8326d8a5b0407 - zh: a31173f990276856 + en: 5be003d933357fee + zh: 0aec7cea1b06a7ce /agent-note-on-demand-watching-and-automatic-refresh-for-sidebar-previews-and-file-trees/watch-requests-and-notifications: en: 025ba83ae78824df zh: 264d74d2bdfe3d27 @@ -42,8 +42,8 @@ en: d688642e5f272d48 zh: 96088fbc0471aeae /agent-note-on-demand-watching-and-automatic-refresh-for-sidebar-previews-and-file-trees/directory-node-tree-and-lifecycle: - en: 2ea8615be8d5229b - zh: 0c73fa4ebb06445e + en: eb011d181eeb83e3 + zh: 7d2cb793fd1737b5 /agent-note-on-demand-watching-and-automatic-refresh-for-sidebar-previews-and-file-trees/user-flows: en: eb4005a334103895 zh: e2c207faffa64e57 @@ -57,8 +57,8 @@ en: 4f343f7a6c4d3ba4 zh: a4390843a20f8dde /agent-note-on-demand-watching-and-automatic-refresh-for-sidebar-previews-and-file-trees/readiness-races-and-refresh-coalescing: - en: caadd187479e9074 - zh: 3deedd8093f60703 + en: baff3282e8925728 + zh: 2052ca0afadd7a1b /agent-note-on-demand-watching-and-automatic-refresh-for-sidebar-previews-and-file-trees/alternatives-considered: en: 8a8c19229647c760 zh: 2de283fc2c815e65 diff --git a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md index 99639db2e5..8d9005858d 100644 --- a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md +++ b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md @@ -60,10 +60,11 @@ Notifications invoke existing reload/list operations; a failed reload does not r | Package-private `ResourceGroup` | [resource-group.ts](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/resource-group.ts) | Observes members through existing `resources.source(address)`; `add` joins a resource, `set` reconciles full membership, and `close` releases all members without changing the generic Resource service | | `DocumentBodyOwner`, `HtmlBody`, `createReadHtmlRelative` | [Renderer inputs](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts), [HTML body](../../../../packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx), [Related-file reader](../../../../packages/client/ui-sidebar-documentpreview/src/client/html/read-relative.ts) | Renderers declare read members through `addResource` and publish the current dependency list through `setResources`; HTML adds CSS/JS resources from Host-reported paths after each read returns and releases unreferenced members after parsing | | Document renderers | [DocumentContent](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) and renderer directories in the same package | Do not watch directly; keep consuming text, bytes, or renderer revisions; `failed()` ends renderer loading without recording a successful version. Check cancellation of previous work, release of old objects, and retention of existing view preferences during refresh | -| `filesFace`, `FilesInjected` | [Files face](../../../../packages/client/ui-sidebar-files/src/client/face.ts) | Remain the existing business entry point, directly owning each Tab's root node; bind directory reads and change streams and forward expand, collapse, refresh, and automatic-refresh operations without a new `FilesController` | +| `filesFace`, `FilesInjected` | [Files face](../../../../packages/client/ui-sidebar-files/src/client/face.ts) | Remain the existing business entry point, directly owning each pane's root node while a navigator draws the pane's tree ([per-pane state](../architecture/2026-10-20-workspace-tree-state-per-pane.md)); bind directory reads and change streams and forward expand, collapse, refresh, and automatic-refresh operations without a new `FilesController` | | Package-private `DirectoryNode` | [directory-node.ts](../../../../packages/client/ui-sidebar-files/src/client/directory-node.ts) | Represents one open directory, owning its stream, read state, and open children; handles local refresh, expansion, recursive closure, and propagation of automatic-refresh state; `setExpanded` updates pending restoration from the latest expansion preferences | -| `createFilesStore`, `FilesTabState`, `LevelState` | [Files store](../../../../packages/client/ui-sidebar-files/src/client/store.ts) | Retain expansion, scrolling, and displayed directory data; distinguish initial loading from refresh over existing content to avoid unmounting subtrees on every background read; hold no watcher or AbortController | -| `FilesBody`, `Level`, `Entry` | [FilesBody.tsx](../../../../packages/client/ui-sidebar-files/src/client/FilesBody.tsx) | Delegate expansion, collapse, refresh, and panel unmount to injected callbacks; render node results without scanning the entire tree into a watch set, using existing framework hooks and store reads | +| `createFilesStore`, `FilesPaneState`, `FilesTreeState`, `LevelState` | [Files store](../../../../packages/client/ui-sidebar-files/src/client/store.ts) | Retain expansion and displayed directory data; distinguish initial loading from refresh over existing content to avoid unmounting subtrees on every background read; hold no watcher or AbortController | +| `FileNavigator` | [FileNavigator.tsx](../../../../packages/client/ui-sidebar-files/src/client/FileNavigator.tsx), [workspace-tree.ts](../../../../packages/client/ui-sidebar-files/src/client/workspace-tree.ts) | Read per-pane state through framework hooks; convert listed levels into entries and directory states; pass expansion, collapse, and drawing lifetimes to the injected Files face, which owns listing, refresh, and watches | +| `FileTree` | [FileTree.tsx](../../../../packages/client/ui-sidebar-files/src/client/FileTree.tsx) | Draw entries, directory states, and expansion from props; report row actions through `onToggle` and `onOpen`. The root-scoped `sidebar.files.tree` Factory reads localized copy and its local `accessory` Component through framework seats, without owning listings or watches | | Files registration | [Files client/index.ts](../../../../packages/client/ui-sidebar-files/src/client/index.ts) | Add a directory-change callback to the existing `filesFace`; keep async effects in injection, without importing another feature plugin's runtime exports | ## Watch requests and notifications @@ -176,13 +177,14 @@ Pause and play use existing icons; manual reload retains its circular arrow. No ```text Files.open - → FilesBody → Session.cwd + Tab - → filesFace.start → DirectoryNode.open + → FileNavigator → Session.cwd + pane + → filesFace.hold → DirectoryNode.open → changes(sessionId, path) → Host.watch(depth: 0) → ready → DirectoryNode → list(sessionId, path) - → createFilesStore.levels[path] - → Level / Entry + → createFilesStore.byPane[paneId].tree.levels[path] + → FileNavigator → workspace-tree.ts: workspaceEntries / workspaceDirectories + → renderFactorySlot('sidebar.files.tree', input) → Level / Entry Entry.expand → filesFace.toggle @@ -204,7 +206,7 @@ The directory panel obtains invalidations and listings through injected Remote c ## Directory-node tree and lifecycle -The Files runtime is itself an on-demand directory tree. It does not first construct the whole Workspace tree or separately derive a flat watcher set. Existing `filesFace` owns each Tab's root; `DirectoryNode` owns reads, observation, and release recursively, without another controller class. +The Files runtime is itself an on-demand directory tree. It does not first construct the whole Workspace tree or separately derive a flat watcher set. Existing `filesFace` owns each pane's root; `DirectoryNode` owns reads, observation, and release recursively, without another controller class. | Node responsibility | Behavior | |---|---| @@ -239,14 +241,14 @@ The parent's watch still detects deletion, renaming, or replacement of a collaps | First open | Subscribe to the file Resource and read contents | Open the root node, which subscribes and lists | | Expand child | No effect | The parent opens the child; the child subscribes and reads direct entries, progressively restoring still-present descendants from preferences | | Collapse child | Open file previews keep their watches | Release that directory and its hidden descendants; expansion preferences and displayed cache may remain | -| Switch Tab or unmount panel | The Tab pin retains file metadata; an unmounted body does not reread, and refreshes on return according to pending Resource changes and toggle state | Retain expanded nodes and watchers for the Tab lifetime; do not introduce another panel-visibility lifetime | +| Switch Tab or hide the tree | The Tab pin retains file metadata; an unmounted body does not reread, and refreshes on return according to pending Resource changes and toggle state | Switching between a pane's file tabs keeps expanded nodes and watchers, because the next tab draws the tree in the same commit; hiding the tree or showing a tab without it closes them while the store keeps expansion and displayed levels, which the next drawing lists again in place | | Manual refresh | Reuse the current preview reload without rebuilding the Resource system | Recursively refresh open children from the root, without entering collapsed directories or rebuilding all watchers | -| Last holder closes | Resource abort ends the file stream; the Host awaits watcher closure | Tab closure ends every directory stream, cancels reads, and forgets Tab state | +| Last holder closes | Resource abort ends the file stream; the Host awaits watcher closure | Once no navigator is mounted in a pane and no open tab last drew one there, every directory stream ends, reads are cancelled, and the pane's state is forgotten | | Connection recovers | Stat again after the new stream's ready frame, discovering changes even without event replay | List every still-watched directory after its new stream's ready frame | -Files watches follow Tab lifetimes. Collapse closes the corresponding subtree; Tab closure releases the root and its entire active tree. Temporarily hiding the panel does not rebuild nodes, keeping implementation within the existing entry point and directory objects. The store retains expansion preferences; nodes use them for progressive restoration only when reopened, without a second global expansion set. +Files watches follow each pane's drawn tree. Collapse closes the corresponding subtree; the end of the pane's last drawing releases the root and its entire active tree, and the pane's state is forgotten once no open tab last drew the tree there. The store retains expansion preferences and displayed levels; nodes use them for progressive restoration only when reopened, without a second global expansion set. -`ResourceRegistry` continues sharing identical complete file Resource addresses. Parent-child ownership prevents duplicate active branches inside one Tab. This version adds no global watcher deduplication across Sessions, windows, or path aliases. Ordinary file rows do not each subscribe to file watches; only files opened as preview Resources acquire independent file observation. +`ResourceRegistry` continues sharing identical complete file Resource addresses. Parent-child ownership prevents duplicate active branches inside one pane. This version adds no global watcher deduplication across Sessions, windows, or path aliases. Ordinary file rows do not each subscribe to file watches; only files opened as preview Resources acquire independent file observation. ## User flows @@ -281,8 +283,8 @@ Observation, reads, and presentation need a few explicit ordering rules, not dis - Events mean that a target may have changed, not exact replay of a user operation. Duplicate Chokidar events and consecutive writes may coalesce; files deduplicate versions, and directories coalesce pending refreshes. - Each directory has at most one active list. Further invalidation during that request or its completion marks a pending reread, which runs afterwards rather than building an arbitrary queue of refresh jobs. - File refresh reuses read generations and renderer revisions. Consecutive notifications must not let old requests overwrite new content or endlessly reload an already handled version. -- Background directory refresh retains the displayed listing without a display-unused `refreshing` flag. Only a directory without displayable entries enters `loading`, avoiding subtree flicker and repeated watcher reconstruction. -- After collapse, deletion, or Tab closure, late cancelled reads cannot recreate state or restore watchers. Cancellation closes the Host watcher even while the generator is suspended at a yielded frame; stream and plugin teardown await closure. +- Background directory refresh retains the displayed listing and marks it `refreshing`; the display ignores the mark, while locating a file waits on it for the directory to be listed again. Only a directory without displayable entries enters `loading`, avoiding subtree flicker and repeated watcher reconstruction. +- After collapse, deletion, the end of a drawing, or the pane's state being forgotten, late cancelled reads cannot recreate state or restore watchers. Cancellation closes the Host watcher even while the generator is suspended at a yielded frame; stream and plugin teardown await closure. - Temporary file disappearance while its parent remains does not end observation. Ordinary file changes and watcher failures remain distinct; same-path file watches cover atomic saves and delete/recreate. - Existing file reads are not transaction snapshots: writes can occur between stat and content reading. Later notifications trigger refresh; automatic refresh does not promise an atomic snapshot for every read. diff --git a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md index 5d11041a5c..ef557b336a 100644 --- a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md +++ b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md @@ -60,10 +60,11 @@ Sidebar 的 Document Preview 和 Files 面板都需要反映磁盘上的最新 | 包内 `ResourceGroup` | [resource-group.ts](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/resource-group.ts) | 通过现有 `resources.source(address)` 观察成员;`add` 加入资源,`set` 更新完整成员列表,`close` 释放全部成员,不改变通用 Resource 服务 | | `DocumentBodyOwner`、`HtmlBody`、`createReadHtmlRelative` | [渲染器输入](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts)、[HTML body](../../../../packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx)、[关联文件读取](../../../../packages/client/ui-sidebar-documentpreview/src/client/html/read-relative.ts) | 渲染器通过 `addResource` 声明读取到的成员,通过 `setResources` 提交本次依赖列表;HTML 在每次读取返回后按 Host 报告的路径加入 CSS/JS 资源,解析结束后释放不再引用的成员 | | 各文档渲染器 | [DocumentContent](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) 及同包各渲染器目录 | 不接 watch;继续消费文本、字节或 renderer revision;`failed()` 结束渲染器加载但不记录成功版本。检查刷新时取消旧任务、释放旧对象及保留现有视图偏好 | -| `filesFace`、`FilesInjected` | [Files face](../../../../packages/client/ui-sidebar-files/src/client/face.ts) | 继续作为已有业务入口,直接持有各 Tab 根节点;绑定目录读取和变化流,转发展开、折叠、刷新及自动刷新开关,不新增 `FilesController` | +| `filesFace`、`FilesInjected` | [Files face](../../../../packages/client/ui-sidebar-files/src/client/face.ts) | 继续作为已有业务入口,在导航栏绘制分栏目录树期间直接持有各分栏的根节点([按分栏保存状态](../architecture/2026-10-20-workspace-tree-state-per-pane.zh.md));绑定目录读取和变化流,转发展开、折叠、刷新及自动刷新开关,不新增 `FilesController` | | 包内 `DirectoryNode` | [directory-node.ts](../../../../packages/client/ui-sidebar-files/src/client/directory-node.ts) | 表示一个已打开的目录,拥有本目录变化流、读取状态和已打开子节点;负责局部刷新、展开、递归关闭,以及向子节点传递自动刷新状态;`setExpanded` 按最新展开偏好更新待恢复节点 | -| `createFilesStore`、`FilesTabState`、`LevelState` | [Files store](../../../../packages/client/ui-sidebar-files/src/client/store.ts) | 保留展开、滚动和目录显示数据;区分首次加载与已有内容上的刷新,避免每次后台重读都把子树卸载;不保存 watcher 或 AbortController | -| `FilesBody`、`Level`、`Entry` | [FilesBody.tsx](../../../../packages/client/ui-sidebar-files/src/client/FilesBody.tsx) | 将展开、折叠、刷新和面板卸载交给注入回调;按节点展示结果渲染,不扫描整棵树生成监听集合,展示仍只读已有框架钩子与存储 | +| `createFilesStore`、`FilesPaneState`、`FilesTreeState`、`LevelState` | [Files store](../../../../packages/client/ui-sidebar-files/src/client/store.ts) | 保留展开和目录显示数据;区分首次加载与已有内容上的刷新,避免每次后台重读都把子树卸载;不保存 watcher 或 AbortController | +| `FileNavigator` | [FileNavigator.tsx](../../../../packages/client/ui-sidebar-files/src/client/FileNavigator.tsx)、[workspace-tree.ts](../../../../packages/client/ui-sidebar-files/src/client/workspace-tree.ts) | 通过框架钩子读取各分栏状态;将已列出的层级转换为条目与目录状态;将展开、折叠和绘制生命周期交给注入的 Files face,由它负责列出目录、刷新与监听 | +| `FileTree` | [FileTree.tsx](../../../../packages/client/ui-sidebar-files/src/client/FileTree.tsx) | 根据属性绘制条目、目录状态与展开状态,通过 `onToggle` 和 `onOpen` 报告行操作。root 作用域的 `sidebar.files.tree` Factory 通过框架席位读取本地化文案与局部 `accessory` 组件,不持有目录列表或监听 | | Files 注册入口 | [Files client/index.ts](../../../../packages/client/ui-sidebar-files/src/client/index.ts) | 给现有 `filesFace` 增加目录变化流回调;异步副作用留在注入实现,不引入另一个功能插件的运行时导出 | ## 监听接口与事件内容 @@ -176,13 +177,14 @@ Document Preview 和 Files 各自保留原 reload 按钮。独立自动刷新开 ```text Files.open - → FilesBody → Session.cwd + Tab - → filesFace.start → DirectoryNode.open + → FileNavigator → Session.cwd + pane + → filesFace.hold → DirectoryNode.open → changes(sessionId, path) → Host.watch(depth: 0) → ready → DirectoryNode → list(sessionId, path) - → createFilesStore.levels[path] - → Level / Entry + → createFilesStore.byPane[paneId].tree.levels[path] + → FileNavigator → workspace-tree.ts: workspaceEntries / workspaceDirectories + → renderFactorySlot('sidebar.files.tree', input) → Level / Entry Entry.expand → filesFace.toggle @@ -204,7 +206,7 @@ Process → create / remove / rename ## 目录节点树与生命周期 -Files 的运行时对象本身就是一棵按需打开的目录树,不先建立整棵 Workspace 树,也不另算一个扁平的 watcher 集合。现有 `filesFace` 持有各 Tab 根节点,读取、监听和释放逐层交给 `DirectoryNode`,不另抽控制器类。 +Files 的运行时对象本身就是一棵按需打开的目录树,不先建立整棵 Workspace 树,也不另算一个扁平的 watcher 集合。现有 `filesFace` 持有各分栏的根节点,读取、监听和释放逐层交给 `DirectoryNode`,不另抽控制器类。 | 节点职责 | 具体行为 | |---|---| @@ -239,14 +241,14 @@ workspace/ watch | 首次打开 | 打开文件 Resource 监听并读取正文 | 打开根节点,由根节点建立监听并读取列表 | | 展开子目录 | 无影响 | 父节点打开对应子节点;子节点监听并读取直接子项,按偏好逐层恢复仍存在的后代 | | 折叠子目录 | 已打开的文件预览继续监听 | 释放该目录及其当前不可见后代的监听;可保留展开偏好和显示缓存 | -| 切走 Tab 或面板卸载 | Tab pin 继续持有文件元数据流;正文组件不在时不主动重读,切回来按待处理 Resource 变化与开关状态补刷新 | 此版沿用 Tab 生命周期,保留已展开目录节点和监听;不另外引入面板显隐生命周期 | +| 切走 Tab 或隐藏目录树 | Tab pin 继续持有文件元数据流;正文组件不在时不主动重读,切回来按待处理 Resource 变化与开关状态补刷新 | 在分栏的文件 tab 之间切换时保留已展开目录节点和监听,因为下一个 tab 在同一次提交中绘制目录树;隐藏目录树或显示不带目录树的 tab 时关闭它们,存储保留展开状态与已显示的层级,下一次绘制时就地重新列出 | | 手动刷新 | 重用当前预览 reload,不重建整套 Resource | 从根节点递归刷新已打开子节点,不进入折叠目录,也不重建全部 watcher | -| 关闭最后一个持有者 | Resource abort,文件流结束,Host 等待 watcher 关闭 | Tab 关闭时终止全部目录流、取消读取并删除 Tab 状态 | +| 关闭最后一个持有者 | Resource abort,文件流结束,Host 等待 watcher 关闭 | 分栏中没有挂载的导航栏、也没有最近一次在那里绘制导航栏的打开 tab 时,终止全部目录流、取消读取并删除该分栏的状态 | | 连接恢复 | 新流 ready 后重新 stat,即使断线期间没有可回放事件也能发现变化 | 新流 ready 后重新 list 所有仍需观察的目录 | -Files 监听跟随 Tab 生命周期;折叠关闭对应子树,关闭 Tab 则释放根节点与整棵活动树。暂时隐藏面板不额外拆除和重建节点,这使改动集中在现有入口和目录对象。存储保留展开偏好,节点只在重新展开时用这份偏好逐层恢复,不维护第二份全局展开集合。 +Files 监听跟随各分栏已绘制的目录树;折叠关闭对应子树,分栏最后一次绘制结束时释放根节点与整棵活动树,没有打开的 tab 最近一次在该分栏绘制目录树时,还会删除其状态。存储保留展开偏好与已显示的层级,节点只在重新展开时用这份偏好逐层恢复,不维护第二份全局展开集合。 -同一个完整文件 Resource 地址的共享继续由 `ResourceRegistry` 完成。目录节点的父子所有权保证一个 Tab 内同一分支不会重复打开;第一版不增加跨 Session、跨窗口或路径别名的全局 watcher 去重系统。目录下的普通文件项不各自订阅文件 watch,只有真正打开为预览 Resource 的文件才取得独立文件监听。 +同一个完整文件 Resource 地址的共享继续由 `ResourceRegistry` 完成。目录节点的父子所有权保证一个分栏内同一分支不会重复打开;第一版不增加跨 Session、跨窗口或路径别名的全局 watcher 去重系统。目录下的普通文件项不各自订阅文件 watch,只有真正打开为预览 Resource 的文件才取得独立文件监听。 ## 用户可见动线 @@ -281,8 +283,8 @@ Files 监听跟随 Tab 生命周期;折叠关闭对应子树,关闭 Tab 则 - 事件表示“目标可能变化”,不是一次用户操作的精确回放。Chokidar 的重复通知和连续写入可以合并;文件按版本去重,目录按待刷新状态合并。 - 同一目录最多有一次有效 list 在途;在途或读取收尾期间再次失效,只记录需要补读,随后读取最新状态,不累计任意长度的刷新任务队列。 - 文件自动刷新复用读取代次和 renderer revision。连续通知不能让旧请求覆盖新内容,也不能因同一已处理版本而无限 reload。 -- 目录后台刷新保留当前列表,不另存没有显示用途的 `refreshing` 标记;只有没有可显示列表时才进入 `loading`,避免子树闪烁和监听反复重建。 -- 目录折叠、删除或 Tab 关闭后,已取消读取的晚到结果不得重新创建状态或恢复监听。即使生成器停在已产出的帧上,取消也会关闭 Host watcher;流与插件拆除等待关闭完成。 +- 目录后台刷新保留当前列表并标记 `refreshing`;显示不受该标记影响,定位文件据此等待目录重新列出;只有没有可显示列表时才进入 `loading`,避免子树闪烁和监听反复重建。 +- 目录折叠、删除、绘制结束或分栏状态被删除后,已取消读取的晚到结果不得重新创建状态或恢复监听。即使生成器停在已产出的帧上,取消也会关闭 Host watcher;流与插件拆除等待关闭完成。 - 父目录保持存在时,文件短暂消失不结束监听;正常文件变化与监听自身失败分别处理。同路径文件监听覆盖原子保存与删除重建。 - 原有文件读取并非事务快照,stat 与正文读取之间仍可能发生写入;后续通知触发刷新,自动刷新不保证每次读取都获得原子快照。 diff --git a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml index 8cbe83c2f7..31c799468d 100644 --- a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml @@ -29,6 +29,9 @@ /agent-note-frontend-large-session-performance-budgets/calibration/history-selector-invalidation: en: 2d2f78f4cf6aad8a zh: aec6af5f7c66de16 +/agent-note-frontend-large-session-performance-budgets/calibration/streaming-assistant-visibility: + en: 5dc3c84b80be0e8e + zh: 4bbd16e1f455ef85 /agent-note-frontend-large-session-performance-budgets/alternatives-considered: en: 4d2a4a64ccd14507 zh: 8a4aaf4d4abea13d diff --git a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md index 16277f4f37..50cc863788 100644 --- a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md +++ b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md @@ -102,6 +102,23 @@ These local runs both failed the independent open budget: their open medians wer Post-GC browser heap was 80.70/80.71 MiB. GNU time reported maximum RSS of 535.78/561.80 MiB for each benchmark command and its children; this is not input-phase memory or an aggregate browser-memory measurement. Moving the composer's selection reveal into an animation frame was rejected: profiling showed the style flush moved to another geometry read, without improving typing. The product fix removes the invalidation source instead. +### Streaming Assistant visibility + +Chat retains visibility per Assistant block. Same-kind text and reasoning appends inspect only the incoming delta until the block becomes visible, then reuse that result. This avoids repeated `trim()` calls on accumulated strings without changing whitespace, first-token timing, or visible anchors. Replacements recalculate visibility, retry resets it, and final messages rebuild it. Copying the block array remains proportional to its length; this does not make every Assembler operation constant-time. + +The [conversation-fold worker](../../../../benchmarks/conversation-fold/conversation-fold.worker.client.ts) measures a single 256 KiB block split into 4,096 or 16,384 deltas, using ordinary text or reasoning whose first half is whitespace. Five fresh compiled Node children per case reuse the existing benchmark registrations, production Assembler and Chat builder. Timing includes every append, one final flush, and reading every output character. Fixture preparation and GC are excluded; retained heap is the post-GC increment with the Assembler, live entries and published state still reachable. + +Local macOS arm64 measurements on Node 26.8.2 give these elapsed medians. The time limits use reference allowances of 12/32 ms and the existing shared scale and headroom; they are not new hosted-runner calibration. + +| Workload | Deltas | Repeated accumulated trim | Cached visibility | Time limit | +|---|---:|---:|---:|---:| +| Ordinary text | 4,096 | 42.25 ms | 7.32 ms | 30 ms | +| Half-leading-whitespace reasoning | 4,096 | 252.75 ms | 7.96 ms | 30 ms | +| Ordinary text | 16,384 | 206.71 ms | 23.06 ms | 80 ms | +| Half-leading-whitespace reasoning | 16,384 | 1114.18 ms | 23.08 ms | 80 ms | + +All four cases reject accumulated trimming and pass with cached visibility. Retained heap remains approximately 2.8/9.9 MiB for the two fragment counts, below the 20 MiB limit derived from a 16 MiB allowance. Maximum worker RSS across these samples falls from 185.70 to 89.36 MiB; it is a whole-process diagnostic, not aggregate application memory. This endpoint measures local assembly with one final publication, excluding network, browser rendering and Trajectory. Separate browser regressions cover settlement, cancellation, retry, reload, reopening and blank reasoning without asserting a browser speedup. + ## Alternatives considered **Use the Node fold as paint evidence.** Rejected because it never performs DOM mutation, layout, or browser scheduling. The focused reconnect case likewise makes no GUI speed claim. diff --git a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md index 159c31f935..1c469a7768 100644 --- a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md +++ b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md @@ -102,6 +102,23 @@ Trajectory 激活在动画帧上采样可见搜索框与 `[data-trajectory-scrol 强制 GC 后的浏览器 heap 为 80.70/80.71 MiB。GNU time 为两次基准命令及其子进程报告的最大 RSS 为 535.78/561.80 MiB;这不是输入阶段内存,也不是浏览器总内存测量。曾尝试将输入框的选区显示推迟到动画帧,但分析显示样式刷新转移到另一处几何读取,没有改善键入,因此未采用。产品修复直接移除样式失效的来源。 +### 流式 Assistant 可见性 + +Chat 保存每个 Assistant 块的可见状态。同类型文本和推理追加只检查新增片段,直到该块可见,之后复用结果。这避免对累计字符串重复调用 `trim()`,同时保留空白、首 token 时间及可见锚点。块替换重新计算可见性,重试清空状态,最终消息重建状态。块数组复制仍与其长度成正比,因此并非每次 Assembler 操作都是常数时间。 + +[conversation-fold worker](../../../../benchmarks/conversation-fold/conversation-fold.worker.client.ts) 测量单个 256 KiB 块,拆成 4,096 或 16,384 个片段,内容为普通文本或前半段为空白的推理。每个用例使用五个全新的已编译 Node 子进程,复用现有基准注册、生产 Assembler 和 Chat builder。计时包含每次追加、一次最终 flush 及逐字符读取输出。夹具准备和 GC 不计时;保留堆内存为 GC 后的增量,此时 Assembler、实时条目和已发布状态仍可达。 + +macOS arm64、Node 26.8.2 上的本地测量得到以下耗时中位数。时间上限使用 12/32 ms 参考额度及现有共享缩放与余量,不代表新的托管运行器校准。 + +| 工作负载 | 片段数 | 累计文本重复 trim | 缓存可见性 | 时间上限 | +|---|---:|---:|---:|---:| +| 普通文本 | 4,096 | 42.25 ms | 7.32 ms | 30 ms | +| 前半段为空白的推理 | 4,096 | 252.75 ms | 7.96 ms | 30 ms | +| 普通文本 | 16,384 | 206.71 ms | 23.06 ms | 80 ms | +| 前半段为空白的推理 | 16,384 | 1114.18 ms | 23.08 ms | 80 ms | + +四个用例都拒绝累计文本重复 trim,并在缓存可见性后通过。两个片段规模的保留堆内存仍约为 2.8/9.9 MiB,低于 16 MiB 额度经余量计算后的 20 MiB 上限。这些样本中的 worker 最大 RSS 从 185.70 降至 89.36 MiB;它是整个进程的诊断值,不是应用总内存。该终点只测量一次最终发布的本地组装,不包含网络、浏览器渲染和 Trajectory。独立浏览器回归覆盖结算、取消、重试、刷新、重开及空白推理,不据此声称浏览器提速。 + ## 考虑过的替代方案 **用 Node 折叠作为绘制证据。** 拒绝,因为它不执行 DOM 修改、布局或浏览器调度。聚焦重连用例同样不声称 GUI 提速。 diff --git a/apps/web/tests/code-language.snapshot.ts b/apps/web/tests/code-language.snapshot.ts index 54abf83a52..038608a011 100644 --- a/apps/web/tests/code-language.snapshot.ts +++ b/apps/web/tests/code-language.snapshot.ts @@ -55,7 +55,7 @@ it('replays highlighted Python stubs, PowerShell and CSV reads with their docume await expandTurnProcesses(page) await page.locator('[data-sidebar-right-expand]').click() await page.locator('[data-sidebar-right-guide-entry="files"]').click() - const filesTab = page.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) }) + const toggle = page.locator('[data-files-navigator-toggle]:visible') const preview = page.locator('[data-textpreview-url]') const snapshots: string[] = [] for (const file of FILES) { @@ -66,8 +66,10 @@ it('replays highlighted Python stubs, PowerShell and CSV reads with their docume await expect.poll(() => read.locator('[data-read] span[style*="color:"]').count()).toBeGreaterThan(0) expect(await read.getByText(file.lang, { exact: true }).count()).toBe(1) snapshots.push(`## Read ${file.name}\n\n${await captureStableAria(page, readSelector, scaffold.workspaceCwd)}`) - await filesTab.click() - await page.locator('[data-files-entry="file"]').getByRole('button', { name: file.name, exact: true }).click() + // The first file replaces the files page; later ones open from the pane's navigator. + // The files page always shows its tree and draws no toggle. + if (await toggle.count() > 0 && await toggle.getAttribute('aria-expanded') !== 'true') await toggle.click() + await page.locator('[data-files-navigator]:visible [data-files-entry="file"]').getByRole('button', { name: file.name, exact: true }).click() const viewer = preview.locator('[data-document-viewer-menu]') if (file.name === 'table.csv') { await expect.poll(() => viewer.innerText()).toBe('Spreadsheet') diff --git a/apps/web/tests/context-menus.e2e.ts b/apps/web/tests/context-menus.e2e.ts index 9e350ec6d4..0cc7a7abf8 100644 --- a/apps/web/tests/context-menus.e2e.ts +++ b/apps/web/tests/context-menus.e2e.ts @@ -268,6 +268,26 @@ describe.each(['MacIntel', 'Win32', 'Linux x86_64'])('web context menus on %s', await file.click({ button: 'right' }) await page.getByRole('menuitem', { name: 'Open in sidebar', exact: true }).click() await page.locator('[data-document-preview]').getByText('SECOND', { exact: true }).waitFor() + // The same link moves that tab into a floating panel and back, delivering its line each time. + const floating = page.locator('[data-sidebar-right-session]:not([hidden]) [data-dockkit-float]') + const dockedFirst = page.locator('[data-rightbar-col] [data-dockkit-pane] [data-dockkit-tab]', { hasText: 'first.txt' }) + await file.click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Open in floating panel', exact: true }).click() + await expect.poll(() => floating.count()).toBe(1) + await floating.locator('[data-textpreview-target="2"]', { hasText: 'SECOND' }).waitFor() + await expect.poll(() => dockedFirst.count()).toBe(0) + // The new panel covers the link, so the keyboard raises its menu. + await file.focus() + await page.keyboard.press('Shift+F10') + await page.getByRole('menuitem', { name: 'Open in floating panel', exact: true }).click() + await floating.locator('[data-textpreview-target="2"]').waitFor() + expect(await floating.count()).toBe(1) + await file.focus() + await page.keyboard.press('Shift+F10') + await page.getByRole('menuitem', { name: 'Open in sidebar', exact: true }).click() + await expect.poll(() => floating.count()).toBe(0) + await expect.poll(() => dockedFirst.count()).toBe(1) + await page.locator('[data-rightbar-col] [data-textpreview-target="2"]').waitFor() await page.getByRole('link', { name: 'Website', exact: true }).click({ button: 'right' }) await page.getByRole('menuitem', { name: 'Copy link address', exact: true }).click() expect(await page.evaluate(() => navigator.clipboard.readText())).toBe('https://example.com/path?q=1#fragment') diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index 84a34241f8..e73eaa5d0b 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -113,7 +113,7 @@ async function sidebarSnapshot(page: Page) { coversViewport: expanded && rect.x === 0 && rect.y === 0 && Math.round(rect.width) === window.innerWidth && Math.round(rect.height) === window.innerHeight, resizeHandleWidth: handle === null ? 0 : Math.round(handle.getBoundingClientRect().width), - expandedDirectories: [...panel.querySelectorAll('[data-files-entry="directory"] > button[aria-expanded="true"]')] + expandedDirectories: [...panel.querySelectorAll('[data-files-entry="directory"] button[aria-expanded="true"]')] .map(button => button.textContent?.trim()), } }) @@ -196,6 +196,14 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S .toBe('none') await expect.poll(() => blankColumn.locator('[data-sidebar-right-panel]').boundingBox()) .toEqual({ x: 0, y: 0, width: 767, height: blankViewport.height }) + // A preview the files page opened starts with its tree hidden; shown, the tree fills the viewport. + const treeToggle = blankColumn.locator('[data-files-navigator-toggle]') + await expect.poll(() => treeToggle.getAttribute('aria-expanded')).toBe('false') + await treeToggle.click() + await blankColumn.locator('[data-files-navigator]').waitFor({ state: 'visible' }) + expect(await blankColumn.locator('[data-textpreview-body]').evaluate(node => getComputedStyle(node).visibility)).toBe('hidden') + await treeToggle.click() + await expect.poll(() => treeToggle.getAttribute('aria-expanded')).toBe('false') await blankColumn.getByText('Workspace preview is available.', { exact: true }).waitFor() await blankColumn.locator('[data-sidebar-right-toggle]').click() await blankColumn.locator('[data-dockkit-pane]').first().waitFor({ state: 'hidden' }) @@ -369,7 +377,8 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S expect(await panel.getAttribute('data-sidebar-right-panel')).toBe('push') await column.locator('[data-sidebar-right-guide-entry="files"]').click() await column.locator('[data-files-state="tree"]').waitFor({ timeout: 15_000 }) - const workspaceDirectory = column.locator('[data-files-entry="directory"] > button').filter({ hasText: /^workspace$/ }) + const workspaceDirectory = column.locator('[data-files-entry="directory"] button') + .and(column.getByRole('button', { name: 'workspace', exact: true })) await workspaceDirectory.waitFor({ timeout: 15_000 }) await workspaceDirectory.click() await expect.poll(() => workspaceDirectory.getAttribute('aria-expanded')).toBe('true') diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index a3d2eb21e6..e164ae38e5 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -1563,6 +1563,62 @@ else process.exit(1); await compareOrRefreshGolden(EXPECTED, sections.join('\n\n'), MODE) await assertFixtureInventory(SNAPSHOT_DIR, ['document.expected.md', 'applications.expected.md', 'applications-no-default.expected.md', 'paging.patch.yml']) }) + + it('preserves Markdown reading position beyond the first page after automatic and manual refresh', async () => { + onTestFailed(() => saveFailureShot(page, `screenshots/0908-document-preview/refresh-scroll-${process.pid}`)) + if (await page.getByText('LIGHTHOUSE', { exact: true }).count() === 0) { + const settled = scaffold.whenTurnSettled() + const input = page.locator('[data-composer-input]').first() + await input.fill(PROMPT) + await input.press('Enter') + await settled + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor() + } + const cwd = scaffold.ctx.agents.list()[0]?.session.header.cwd + if (cwd === undefined) throw new Error('Session has no workspace cwd') + const path = join(cwd, 'refresh-scroll.md') + const markdown = '# Before refresh\n\n' + Array.from({ length: PAGE_LINES * 2 }, (_, index) => `Paragraph ${index + 1}\n`).join('\n') + await writeFile(path, markdown) + const column = page.locator('[data-rightbar-col]') + const expand = page.locator('[data-sidebar-right-expand]') + if (await expand.isVisible()) await expand.click() + const filesTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) }) + if (await filesTab.count() === 0) await column.locator('[data-sidebar-right-guide-entry="files"]').click() + await filesTab.click() + await column.locator('[data-files-reload]').click() + const preview = column.locator('[data-textpreview-url]') + await openPreviewFile(column, filesTab, preview, 'refresh-scroll.md') + const body = preview.locator('[data-textpreview-body]') + const heading = body.getByRole('heading', { level: 1 }) + await expect.poll(() => heading.textContent()).toBe('Before refresh') + await expect.poll(() => preview.locator('[data-textpreview-more]').isEnabled()).toBe(true) + const target = await body.evaluate(node => node.scrollHeight + 100) + await scrollForNextPage(body) + await expect.poll(() => body.evaluate(node => node.scrollHeight - node.clientHeight)).toBeGreaterThan(target) + await body.evaluate((node, top) => new Promise((resolve) => { + node.addEventListener('scroll', () => { resolve() }, { once: true }) + node.scrollTop = top + }), target) + const original = await body.evaluate(node => node.scrollTop) + expect(original).toBe(target) + // Content growing above the viewport keeps the pixel offset instead of following the content. + await heading.evaluate((node) => { node.style.paddingTop = '300px' }) + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))) + expect(await body.evaluate(node => node.scrollTop)).toBe(original) + await heading.evaluate((node) => { node.style.paddingTop = '' }) + + await writeFile(path, markdown.replace('Before refresh', 'After refresh')) + await expect.poll(() => heading.textContent()).toBe('After refresh') + await expect.poll(() => body.evaluate((node, top) => Math.abs(node.scrollTop - top), original)).toBeLessThanOrEqual(1) + + const rendered = await heading.elementHandle() + await preview.locator('[data-textpreview-tool="reload"]').click() + await expect.poll(() => rendered!.evaluate(node => node.isConnected)).toBe(false) + await expect.poll(() => heading.textContent()).toBe('After refresh') + await expect.poll(() => body.evaluate((node, top) => Math.abs(node.scrollTop - top), original)).toBeLessThanOrEqual(1) + await rendered!.dispose() + expect(tripwire.pageErrors).toEqual([]) + }) }) describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { diff --git a/apps/web/tests/excel-opc.e2e.ts b/apps/web/tests/excel-opc.e2e.ts index a3a49e3248..9cd298aed2 100644 --- a/apps/web/tests/excel-opc.e2e.ts +++ b/apps/web/tests/excel-opc.e2e.ts @@ -54,7 +54,7 @@ describe.skipIf(mode === 'record')('web e2e: independent-writer Excel previews', await column.locator('[data-sidebar-right-guide-entry="files"]').click() await column.locator('[data-files-state="tree"]').waitFor() await column.locator('[data-files-reload]').click() - const files = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) }) + const toggle = column.locator('[data-files-navigator-toggle]:visible') const preview = column.locator('[data-textpreview-url]') const excel = preview.locator('[data-excel-preview]') const sheets = excel.locator('.luckysheet-sheets-item-name') @@ -62,8 +62,10 @@ describe.skipIf(mode === 'record')('web e2e: independent-writer Excel previews', const rows = ['# Independent-writer Excel previews', ''] await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin }) for (const name of [...excelOpcCases, ...excelOpeningCases, ...excelXmlCases]) { - await files.click() - await column.locator('[data-files-entry="file"]').getByRole('button', { name: `${name}.xlsx`, exact: true }).click() + // The first file replaces the files page; later ones open from the pane's navigator. + // The files page always shows its tree and draws no toggle. + if (await toggle.count() > 0 && await toggle.getAttribute('aria-expanded') !== 'true') await toggle.click() + await column.locator('[data-files-navigator]:visible [data-files-entry="file"]').getByRole('button', { name: `${name}.xlsx`, exact: true }).click() await expect.poll(async () => (await preview.getAttribute('data-textpreview-url'))?.endsWith(`/${name}.xlsx`)).toBe(true) await sheets.getByText('数据', { exact: true }).waitFor() await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } }) diff --git a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md index 120b33549a..08a0750772 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md @@ -29,20 +29,20 @@ - textbox "模型 ID 1": - /placeholder: 模型 ID - text: deepseek-flash + - button "模型选项 1" + - button "删除模型 1" - textbox "显示名称 1": - /placeholder: 显示名称 - text: DeepSeek-V4.1-Flash - - button "模型选项 1" - - button "删除模型 1" - button "调整模型顺序 2" - textbox "模型 ID 2": - /placeholder: 模型 ID - text: deepseek-v4-pro + - button "模型选项 2" + - button "删除模型 2" - textbox "显示名称 2": - /placeholder: 显示名称 - text: DeepSeek-V4-Pro - - button "模型选项 2" - - button "删除模型 2" - button "添加模型" - button "取消" - button "保存" diff --git a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md index fd9cc97859..0f6c28c0f2 100644 --- a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md +++ b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md @@ -36,11 +36,11 @@ - textbox "模型 ID 1": - /placeholder: 模型 ID - text: gpt-6-astra + - button "模型选项 1" [expanded] + - button "删除模型 1" - textbox "显示名称 1": - /placeholder: 显示名称 - text: GPT-6 Astra - - button "模型选项 1" [expanded] - - button "删除模型 1" - text: 上下文窗口 - textbox "上下文窗口 1": - /placeholder: 1M diff --git a/apps/web/tests/expected/models-settings/declared-edit.expected.md b/apps/web/tests/expected/models-settings/declared-edit.expected.md index be77f7ceee..8fe2a7d83c 100644 --- a/apps/web/tests/expected/models-settings/declared-edit.expected.md +++ b/apps/web/tests/expected/models-settings/declared-edit.expected.md @@ -44,10 +44,10 @@ - textbox "模型 ID 1": - /placeholder: 模型 ID - text: acme-large - - textbox "显示名称 1": - - /placeholder: 显示名称 - button "模型选项 1" [expanded] - button "删除模型 1" + - textbox "显示名称 1": + - /placeholder: 显示名称 - text: 上下文窗口 - textbox "上下文窗口 1": - /placeholder: 1M diff --git a/apps/web/tests/expected/models-settings/image-only.expected.md b/apps/web/tests/expected/models-settings/image-only.expected.md index ea3046ce5b..ed9c82a0c4 100644 --- a/apps/web/tests/expected/models-settings/image-only.expected.md +++ b/apps/web/tests/expected/models-settings/image-only.expected.md @@ -44,10 +44,10 @@ - textbox "模型 ID 1": - /placeholder: 模型 ID - text: acme-large - - textbox "显示名称 1": - - /placeholder: 显示名称 - button "模型选项 1" [expanded] - button "删除模型 1" + - textbox "显示名称 1": + - /placeholder: 显示名称 - text: 上下文窗口 - textbox "上下文窗口 1": - /placeholder: 1M diff --git a/apps/web/tests/expected/models-settings/mobile-collapsed.expected.md b/apps/web/tests/expected/models-settings/mobile-collapsed.expected.md new file mode 100644 index 0000000000..46babcd11e --- /dev/null +++ b/apps/web/tests/expected/models-settings/mobile-collapsed.expected.md @@ -0,0 +1,64 @@ +- dialog "设置": + - text: 设置 + - navigation: + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "打开配置文件" + - button "关闭" + - heading "模型" [level=2] + - paragraph: 填入各提供商的 API 密钥即可使用其模型。 + - list: + - listitem: + - text: minimax-cn + - img "API 密钥已配置" + - button "编辑 minimax-cn": 编辑 + - button "删除 minimax-cn": 删除 + - listitem: + - text: Acme 网关 自定义 + - button "编辑 Acme 网关 (acme-gateway)": 编辑 + - button "删除 Acme 网关 (acme-gateway)": 删除 + - text: Acme 网关 acme-gateway API 密钥 + - textbox "API 密钥": + - /placeholder: 输入 API 密钥 + - group: + - text: 自定义设置 显示名称 + - textbox "显示名称": + - /placeholder: acme-gateway + - text: Acme 网关 + - text: API 地址 + - textbox "API 地址": + - /placeholder: https://gateway.acme.example/v1 + - text: https://gateway.acme.example/v1 + - text: API 协议 + - combobox "API 协议": + - option "OpenAI Chat Completions" + - option "OpenAI Responses" + - option "Anthropic Messages" [selected] + - region "模型目录": + - text: 模型目录 已自定义模型目录 + - button "恢复默认模型" + - button "获取可用模型" + - button "调整模型顺序 1" + - textbox "模型 ID 1": + - /placeholder: 模型 ID + - text: order-second + - button "模型选项 1" + - button "删除模型 1" + - button "调整模型顺序 2" + - textbox "模型 ID 2": + - /placeholder: 模型 ID + - text: order-third + - button "模型选项 2" + - button "删除模型 2" + - button "调整模型顺序 3" + - textbox "模型 ID 3": + - /placeholder: 模型 ID + - text: acme-large + - button "模型选项 3" + - button "删除模型 3" + - button "添加模型" + - button "取消" + - button "保存" + - button "添加模型提供商" diff --git a/apps/web/tests/expected/models-settings/mobile-expanded.expected.md b/apps/web/tests/expected/models-settings/mobile-expanded.expected.md new file mode 100644 index 0000000000..b1f780f9b3 --- /dev/null +++ b/apps/web/tests/expected/models-settings/mobile-expanded.expected.md @@ -0,0 +1,84 @@ +- dialog "设置": + - text: 设置 + - navigation: + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "打开配置文件" + - button "关闭" + - heading "模型" [level=2] + - paragraph: 填入各提供商的 API 密钥即可使用其模型。 + - list: + - listitem: + - text: minimax-cn + - img "API 密钥已配置" + - button "编辑 minimax-cn": 编辑 + - button "删除 minimax-cn": 删除 + - listitem: + - text: Acme 网关 自定义 + - button "编辑 Acme 网关 (acme-gateway)": 编辑 + - button "删除 Acme 网关 (acme-gateway)": 删除 + - text: Acme 网关 acme-gateway API 密钥 + - textbox "API 密钥": + - /placeholder: 输入 API 密钥 + - group: + - text: 自定义设置 显示名称 + - textbox "显示名称": + - /placeholder: acme-gateway + - text: Acme 网关 + - text: API 地址 + - textbox "API 地址": + - /placeholder: https://gateway.acme.example/v1 + - text: https://gateway.acme.example/v1 + - text: API 协议 + - combobox "API 协议": + - option "OpenAI Chat Completions" + - option "OpenAI Responses" + - option "Anthropic Messages" [selected] + - region "模型目录": + - text: 模型目录 已自定义模型目录 + - button "恢复默认模型" + - button "获取可用模型" + - button "调整模型顺序 1" + - textbox "模型 ID 1": + - /placeholder: 模型 ID + - text: order-second + - button "模型选项 1" [expanded] + - button "删除模型 1" + - text: 显示名称 + - textbox "显示名称 1": + - /placeholder: 显示名称 + - text: 手机上的完整显示名称 + - text: 上下文窗口 + - textbox "上下文窗口 1": + - /placeholder: 1M + - text: 最大输出 token 数 + - textbox "最大输出 token 数 1": + - /placeholder: 128K + - group "输入类型 1": + - text: 输入类型 + - checkbox "文本" [checked] [disabled] + - text: 文本 + - checkbox "图片" + - text: 图片 + - group "推理等级 1": + - text: 推理等级 + - button "推理等级说明": "?" + - switch "推理等级 1" + - button "调整模型顺序 2" + - textbox "模型 ID 2": + - /placeholder: 模型 ID + - text: order-third + - button "模型选项 2" + - button "删除模型 2" + - button "调整模型顺序 3" + - textbox "模型 ID 3": + - /placeholder: 模型 ID + - text: acme-large + - button "模型选项 3" + - button "删除模型 3" + - button "添加模型" + - button "取消" + - button "保存" + - button "添加模型提供商" diff --git a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md index bde82cec8f..03900f109b 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md @@ -29,11 +29,11 @@ - textbox "模型 ID 1": - /placeholder: 模型 ID - text: deepseek-flash + - button "模型选项 1" [expanded] + - button "删除模型 1" - textbox "显示名称 1": - /placeholder: 显示名称 - text: DeepSeek-V4.1-Flash - - button "模型选项 1" [expanded] - - button "删除模型 1" - text: 上下文窗口 - textbox "上下文窗口 1": - /placeholder: 1M @@ -51,11 +51,11 @@ - textbox "模型 ID 2": - /placeholder: 模型 ID - text: deepseek-v4-pro + - button "模型选项 2" + - button "删除模型 2" - textbox "显示名称 2": - /placeholder: 显示名称 - text: DeepSeek-V4-Pro - - button "模型选项 2" - - button "删除模型 2" - button "添加模型" - button "取消" - button "保存" diff --git a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md index 55526bebec..27ba737236 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md @@ -30,11 +30,11 @@ - textbox "模型 ID 1": - /placeholder: 模型 ID - text: private-preview + - button "模型选项 1" [expanded] + - button "删除模型 1" - textbox "显示名称 1": - /placeholder: 显示名称 - text: Private Preview - - button "模型选项 1" [expanded] - - button "删除模型 1" - text: 上下文窗口 - textbox "上下文窗口 1": - /placeholder: 1M diff --git a/apps/web/tests/expected/plugin-market/toolbar-responsive.expected.md b/apps/web/tests/expected/plugin-market/toolbar-responsive.expected.md new file mode 100644 index 0000000000..fb4759ef7f --- /dev/null +++ b/apps/web/tests/expected/plugin-market/toolbar-responsive.expected.md @@ -0,0 +1,8 @@ +# Discover toolbar responsive layout + +- 390px: wrapped rows; index, search, labels, sort, and refresh are contained, reachable, and separate; no horizontal overflow; search focus retained. +- 430px: wrapped rows; index, search, labels, sort, and refresh are contained, reachable, and separate; no horizontal overflow; search focus retained. +- 768px: single row; index, search, labels, sort, and refresh are contained, reachable, and separate; no horizontal overflow; search focus retained. +- 1280px: single row; index, search, labels, sort, and refresh are contained, reachable, and separate; no horizontal overflow; search focus retained. + +At 390px, restoring the previous no-wrap layout causes horizontal overflow and fails the same layout check. Removing that override restores the controls and preserves search focus. diff --git a/apps/web/tests/expected/shortcuts-panels/page-actions.expected.md b/apps/web/tests/expected/shortcuts-panels/page-actions.expected.md index 63ee48e589..d3e05295e9 100644 --- a/apps/web/tests/expected/shortcuts-panels/page-actions.expected.md +++ b/apps/web/tests/expected/shortcuts-panels/page-actions.expected.md @@ -18,6 +18,7 @@ - button "Split" [disabled] - text: {{cwd}}/workspace - button "Reload" -- list: - - listitem: - - button "t6-refresh.txt" +- complementary "Workspace files": + - list: + - listitem: + - button "t6-refresh.txt" diff --git a/apps/web/tests/expected/shortcuts-panels/panels.expected.md b/apps/web/tests/expected/shortcuts-panels/panels.expected.md index 7b6537870e..5cced821c6 100644 --- a/apps/web/tests/expected/shortcuts-panels/panels.expected.md +++ b/apps/web/tests/expected/shortcuts-panels/panels.expected.md @@ -21,5 +21,6 @@ - button "Split" [disabled] - text: {{cwd}}/workspace - button "Reload" -- list: - - listitem: Empty directory +- complementary "Workspace files": + - list: + - listitem: Empty directory diff --git a/apps/web/tests/expected/shortcuts-workspace/files-open.expected.md b/apps/web/tests/expected/shortcuts-workspace/files-open.expected.md index 9c7cf15731..8768d85619 100644 --- a/apps/web/tests/expected/shortcuts-workspace/files-open.expected.md +++ b/apps/web/tests/expected/shortcuts-workspace/files-open.expected.md @@ -2,5 +2,6 @@ - button "Reload" - button "Open in Finder" - button "More ways to open" -- list: - - listitem: Empty directory +- complementary "Workspace files": + - list: + - listitem: Empty directory diff --git a/apps/web/tests/file-browser-composition.e2e.ts b/apps/web/tests/file-browser-composition.e2e.ts new file mode 100644 index 0000000000..1232279de2 --- /dev/null +++ b/apps/web/tests/file-browser-composition.e2e.ts @@ -0,0 +1,63 @@ +/** + * The workspace file browser and the document preview each work without the + * other in the shipped Web composition: without the preview the files page + * lists the workspace and leaves files it cannot open unclickable; without the + * file browser a preview opens with no navigator or toggle beside it. + */ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import { expect, it, onTestFailed, onTestFinished } from 'vitest' +import { launchWebScaffold, seedSession, watchConsole, type WebScaffold } from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +/** A settled Session whose answer links `src/example.txt`, borrowed from the Markdown file links case. */ +const SEED = fileURLToPath(new URL('../../../snapshots/web/markdown-file-links/session.v3.jsonl', import.meta.url)) + +/** Boot the composition with one plugin disabled, a workspace holding `src/example.txt`, and the seeded Session open. */ +async function open(overlay: string, name: string) { + const scaffold: WebScaffold = await launchWebScaffold({ extraOverlayPath: fileURLToPath(new URL(overlay, import.meta.url)) }) + onTestFinished(() => scaffold.close()) + await mkdir(join(scaffold.workspaceCwd, 'src')) + await writeFile(join(scaffold.workspaceCwd, 'src/example.txt'), Array.from({ length: 40 }, (_, i) => `source line ${i + 1}\n`).join('')) + await seedSession(scaffold, await readFile(SEED, 'utf8'), name) + const browser = await chromium.launch() + onTestFinished(() => browser.close()) + const page = await newEnglishPage(browser) + onTestFailed(() => saveFailureShot(page, `web-e2e-${name}`)) + const errors = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.locator('[role="treeitem"]').first().click() + await page.locator('[role="treeitem"]').nth(1).click() + await page.getByRole('button', { name: 'src/example.txt:24–30', exact: true }).waitFor() + return { page, errors, column: page.locator('[data-rightbar-col]') } +} + +it('lists the workspace without the document preview, leaving files nothing can open unclickable', async () => { + const { page, errors, column } = await open('./no-sidebar-documentpreview.overlay.yml', 'composition-no-preview') + await page.locator('[data-sidebar-right-expand]').click() + await column.locator('[data-sidebar-right-guide-entry="files"]').click() + const navigator = column.locator('[data-files-navigator]') + await navigator.getByRole('button', { name: 'src', exact: true }).click() + const row = navigator.locator('[data-files-path$="/src/example.txt"]') + await row.waitFor() + expect(await row.locator('button').count()).toBe(0) + expect(await row.locator('[aria-disabled="true"]').getAttribute('title')).toBe('No viewer can open this file.') + const tabs = await column.locator('[data-dockkit-tab]').count() + await row.click() + expect(await column.locator('[data-dockkit-tab]').count()).toBe(tabs) + expect(await column.locator('[data-files-page]').count()).toBe(1) + expect(errors).toEqual({ warnings: [], pageErrors: [] }) +}, 120_000) + +it('previews a linked file without the file browser, with no navigator or toggle beside it', async () => { + const { page, errors, column } = await open('./no-sidebar-files.overlay.yml', 'composition-no-files') + await page.getByRole('button', { name: 'src/example.txt:24–30', exact: true }).click() + await expect.poll(() => column.locator('[data-textpreview-target="24"]').textContent()).toBe('source line 24\n') + expect(await column.locator('[data-files-navigator], [data-files-navigator-toggle]').count()).toBe(0) + expect(await column.locator('[data-textpreview-content] > *').evaluateAll(children => + children.map(child => child.hasAttribute('data-textpreview-body')))).toEqual([true]) + expect(await column.locator('[data-sidebar-right-guide-entry="files"]').count()).toBe(0) + expect(errors).toEqual({ warnings: [], pageErrors: [] }) +}, 120_000) diff --git a/apps/web/tests/file-browser.e2e.ts b/apps/web/tests/file-browser.e2e.ts new file mode 100644 index 0000000000..0032ed22ef --- /dev/null +++ b/apps/web/tests/file-browser.e2e.ts @@ -0,0 +1,539 @@ +/** + * The workspace file browser over the shipped Web composition, a real + * workspace and the real dock, across docked and floating panes and a + * phone-width viewport. The package README owns the behavior asserted here. + */ +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium, type Locator, type Page } from 'playwright' +import { expect, it, onTestFailed, onTestFinished } from 'vitest' +import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' +import { launchWebScaffold, watchConsole } from './scaffold.ts' +import { connectFreshWorkspace, saveFailureShot } from './support.ts' + +/** Press at the centre of `from`, travel in steps, and release at `to`. */ +async function drag(page: Page, from: Locator, to: { x: number; y: number }): Promise { + const box = await from.boundingBox() + if (box === null) throw new Error('drag source is not rendered') + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) + await page.mouse.down() + await page.mouse.move(to.x, to.y, { steps: 12 }) + await page.mouse.up() +} + +/** Resize through the hit-tested corner, rather than dispatching events through covering content. */ +async function resizeFloat(page: Page, panel: Locator, width: number, height: number): Promise { + const before = await panel.boundingBox() + const handle = panel.locator('[data-dockkit-float-resize]') + const grip = await handle.boundingBox() + if (before === null || grip === null) throw new Error('floating resize geometry is not rendered') + await expect.poll(() => handle.evaluate((node) => { + const box = node.getBoundingClientRect() + return node.contains(document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2)) + })).toBe(true) + await drag(page, handle, { + x: grip.x + grip.width / 2 + width - before.width, + y: grip.y + grip.height / 2 + height - before.height, + }) + await expect.poll(async () => { + const after = await panel.boundingBox() + return after === null ? Infinity : Math.max(Math.abs(after.width - width), Math.abs(after.height - height)) + }).toBeLessThanOrEqual(1) +} + +/** Boot the shipped Web composition into a settled Session whose workspace holds `src/{a,b,c}.txt`, `README.md`, and `focus.html`. */ +async function openWorkspace( + shot: string, + openInAppEnvironment?: LaunchEnvironmentSnapshot, +): Promise<{ page: Page; input: Locator; errors: ReturnType }> { + const scaffold = await launchWebScaffold({ + replayFixture: fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v3.jsonl', import.meta.url)), + paceMs: 5, compareReplaySession: false, ...openInAppEnvironment === undefined ? {} : { openInAppEnvironment }, + }) + onTestFinished(() => scaffold.close()) + const browser = await chromium.launch() + onTestFinished(() => browser.close()) + const page = await browser.newPage({ locale: 'en-US', viewport: { width: 1680, height: 1000 } }) + await page.addInitScript(() => { Object.defineProperty(navigator, 'platform', { value: 'Win32' }) }) + onTestFailed(() => saveFailureShot(page, shot)) + const errors = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await connectFreshWorkspace(page, scaffold.workspaceCwd, 'test workspace') + const settled = scaffold.whenTurnSettled() + const input = page.locator('[data-composer-input]').first() + await input.fill('Reply with the single word LIGHTHOUSE and stop.') + await input.press('Enter') + const session = await settled + const cwd = scaffold.ctx.agents.get(session)?.session.header.cwd + if (cwd === undefined) throw new Error('Session has no workspace') + await mkdir(join(cwd, 'src')) + const lines = (name: string): string => Array.from({ length: 300 }, (_, index) => `${name} line ${index}\n`).join('') + await writeFile(join(cwd, 'src', 'a.txt'), lines('a')) + await writeFile(join(cwd, 'src', 'b.txt'), lines('b')) + await writeFile(join(cwd, 'src', 'c.txt'), lines('c')) + await writeFile(join(cwd, 'README.md'), '# Readme\n') + await writeFile(join(cwd, 'focus.html'), '') + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor() + return { page, input, errors } +} + +it('browses workspace files in tabs that share one pane tree', async () => { + const { page, input, errors } = await openWorkspace('web-e2e-file-browser') + await page.locator('[data-sidebar-right-expand]').click() + const column = page.locator('[data-rightbar-col]') + const layout = await page.addStyleTag({ content: '[data-sidebar-right-panel] { width: 960px !important; }' }) + await column.locator('[data-sidebar-right-guide-entry="files"]').click() + const tabs = column.locator('[data-dockkit-tab]') + const navigator = column.locator('[data-files-navigator]:visible') + const toggle = column.locator('[data-files-navigator-toggle]:visible') + const body = column.locator('[data-textpreview-state]:visible [data-textpreview-body]') + const path = column.locator('[data-textpreview-path]:visible') + const current = navigator.locator('[aria-current="true"]') + await column.locator('[data-files-page]').waitFor() + // The page's tree fills the page and stays shown: no toggle, no resize edge, and Escape leaves it. + await navigator.waitFor() + expect(await toggle.count()).toBe(0) + expect(await navigator.getByRole('separator').count()).toBe(0) + const pageContent = await column.locator('[data-files-content]').boundingBox() + const pageTree = await navigator.boundingBox() + if (pageContent === null || pageTree === null) throw new Error('files page geometry is not rendered') + expect(Math.abs(pageTree.width - pageContent.width)).toBeLessThanOrEqual(1) + await navigator.getByRole('button', { name: 'src', exact: true }).focus() + await page.keyboard.press('Escape') + expect(await navigator.count()).toBe(1) + const pageTab = await tabs.last().getAttribute('data-dockkit-tab') + const tabCount = await tabs.count() + + // The page stays and opens the first file in a new tab after it in its pane, where the file's tree starts hidden. + await navigator.getByRole('button', { name: 'src', exact: true }).click() + await navigator.getByRole('button', { name: 'a.txt', exact: true }).click() + await expect.poll(() => path.textContent()).toContain('a.txt') + expect(await tabs.count()).toBe(tabCount + 1) + expect(await column.locator(`[data-dockkit-tab="${pageTab}"]`).count()).toBe(1) + expect(await column.locator('[data-dockkit-pane]').count()).toBe(1) + expect(await toggle.getAttribute('aria-expanded')).toBe('false') + // Picking the open file again from the page switches back to its tab, which is the visible change, so nothing glows. + const flash = column.locator('[data-sidebar-right-flash]') + await column.locator(`[data-dockkit-tab="${pageTab}"]`).click() + await navigator.getByRole('button', { name: 'a.txt', exact: true }).click() + await expect.poll(() => path.textContent()).toContain('a.txt') + expect(await flash.count()).toBe(0) + expect(await tabs.count()).toBe(tabCount + 1) + // The preview's toggle shows the pane's tree without changing the files page. + await toggle.click() + await expect.poll(() => toggle.getAttribute('aria-expanded')).toBe('true') + await expect.poll(() => current.textContent()).toBe('a.txt') + // Picking the open file again while its glow plays restarts the glow, which then lasts its whole 600ms animation. + const openFile = navigator.getByRole('button', { name: 'a.txt', exact: true }) + await openFile.click() + // A glow replaced before it starts is dropped without a cancellation, so the repeat waits until the first one plays. + await expect.poll(() => flash.evaluate(pane => pane.getAnimations({ subtree: true }) + .some(glow => Number(glow.currentTime) > 100))).toBe(true) + const first = await flash.getAttribute('data-sidebar-right-flash') + const repeat = await openFile.evaluate((button) => { + const pane = button.closest('[data-sidebar-right-flash]') + if (pane === null) throw new Error('the open file is outside the glowing pane') + const since = performance.now() + return new Promise<{ mark: string | undefined; lasted: number }>((resolve) => { + let mark: string | undefined + new MutationObserver((_, observer) => { + mark = pane.dataset.sidebarRightFlash ?? mark + if (pane.dataset.sidebarRightFlash !== undefined) return + observer.disconnect() + resolve({ mark, lasted: performance.now() - since }) + }).observe(pane, { attributes: true, attributeFilter: ['data-sidebar-right-flash'] }) + ;(button as HTMLElement).click() + }) + }) + expect(repeat.mark).toBe(first === 'odd' ? 'even' : 'odd') + expect(repeat.lasted).toBeGreaterThan(450) + + // In a wide row the tree is a card beside the preview: the body narrows by the card and its 8px margins, and still scrolls. + await column.locator('[data-files-navigator-mode="push"]:visible').waitFor() + const wideRow = await body.locator('..').boundingBox() + const wideBody = await body.boundingBox() + const wideTree = await navigator.boundingBox() + if (wideRow === null || wideBody === null || wideTree === null) throw new Error('preview row is not rendered') + expect(Math.abs(wideBody.width - (wideRow.width - wideTree.width - 16))).toBeLessThanOrEqual(1) + expect(wideTree.x).toBeGreaterThanOrEqual(wideBody.x + wideBody.width + 7) + await expect.poll(() => body.evaluate(node => node.scrollHeight > node.clientHeight)).toBe(true) + await page.mouse.move(wideBody.x + 40, wideBody.y + 80) + await page.mouse.wheel(0, 400) + await expect.poll(() => body.evaluate(node => node.scrollTop)).toBeGreaterThan(0) + // A press on the preview leaves the tree shown. + await page.mouse.click(wideBody.x + 40, wideBody.y + 80) + expect(await toggle.getAttribute('aria-expanded')).toBe('true') + + await toggle.focus() + // In dark mode the card takes the filled tree's background, with a soft white shadow. + const theme = await page.locator('body').getAttribute('data-ds-dark-theme') + try { + await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') }) + const cardPaint = await navigator.evaluate(node => ({ + background: getComputedStyle(node).backgroundColor, + shadow: getComputedStyle(node).boxShadow, + })) + expect(cardPaint.shadow).toContain('color(srgb 1 1 1 / 0.08)') + await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 360px !important; }' }) + await column.locator('[data-files-navigator-mode="fill"]:visible').waitFor() + expect(await navigator.evaluate(node => getComputedStyle(node).backgroundColor)).toBe(cardPaint.background) + await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 960px !important; }' }) + await navigator.getByRole('separator').waitFor() + } finally { + await page.evaluate((value) => { + if (value === null) document.body.removeAttribute('data-ds-dark-theme') + else document.body.setAttribute('data-ds-dark-theme', value) + }, theme) + } + + // The reader scrolls the preview and widens the tree. + await body.evaluate((node) => { node.scrollTop = 900 }) + const edge = navigator.getByRole('separator', { name: 'Resize file tree' }) + const grip = await edge.boundingBox() + if (grip === null) throw new Error('resize edge is not rendered') + await page.mouse.move(grip.x + grip.width / 2, grip.y + 120) + await page.mouse.down() + await page.mouse.move(grip.x + grip.width / 2 - 40, grip.y + 120, { steps: 4 }) + await page.mouse.up() + await expect.poll(() => edge.getAttribute('aria-valuenow')).toBe('300') + + // The files page appends another preview; it shares the pane's tree choice and width. + await column.locator(`[data-dockkit-tab="${pageTab}"]`).click() + await navigator.getByRole('button', { name: 'b.txt', exact: true }).click() + await column.locator(`[data-dockkit-tab="${pageTab}"]`).hover() + await column.locator(`[data-dockkit-tab-close="${pageTab}"]`).click() + await expect.poll(() => path.textContent()).toContain('b.txt') + expect(await tabs.count()).toBe(tabCount + 1) + await expect.poll(() => current.textContent()).toBe('b.txt') + expect(await navigator.getByRole('button', { name: 'src', exact: true }).getAttribute('aria-expanded')).toBe('true') + expect(await edge.getAttribute('aria-valuenow')).toBe('300') + + // Clicking an open file returns to its tab, where the reader left off. + await navigator.getByRole('button', { name: 'a.txt', exact: true }).click() + await expect.poll(() => path.textContent()).toContain('a.txt') + expect(await tabs.count()).toBe(tabCount + 1) + await expect.poll(() => body.evaluate(node => node.scrollTop)).toBe(900) + await expect.poll(() => current.textContent()).toBe('a.txt') + + // Workspace navigation has one open action; the row's shared context menu does not offer a second new-tab route. + await navigator.getByRole('button', { name: 'a.txt', exact: true }).click({ button: 'right' }) + const rowMenu = page.locator('[data-context-menu][role="menu"]') + await rowMenu.waitFor() + expect(await page.getByRole('menuitem', { name: 'Open in new tab', exact: true }).count()).toBe(0) + await page.keyboard.press('Escape') + await rowMenu.waitFor({ state: 'detached' }) + + // A narrower pane floats the tree over the preview below its header; the body keeps the row's width and still scrolls. + await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 460px !important; }' }) + await column.locator('[data-files-navigator-mode="overlay"]:visible').waitFor() + const header = await path.locator('..').boundingBox() + const tree = await navigator.boundingBox() + if (header === null || tree === null) throw new Error('browser geometry is not rendered') + expect(tree.y).toBeGreaterThanOrEqual(header.y + header.height - 1) + const middleRow = await body.locator('..').boundingBox() + const preview = await body.boundingBox() + if (middleRow === null || preview === null) throw new Error('preview body is not rendered') + expect(preview.width).toBeGreaterThan(middleRow.width - 1) + await body.evaluate((node) => { node.scrollTop = 0 }) + await page.mouse.move(preview.x + 20, preview.y + 80) + await page.mouse.wheel(0, 400) + await expect.poll(() => body.evaluate(node => node.scrollTop)).toBeGreaterThan(0) + await navigator.getByRole('button', { name: 'b.txt', exact: true }).click() + await expect.poll(() => current.textContent()).toBe('b.txt') + // The keyboard stays in the tree the revealed tab draws, so Escape reaches the row. + await expect.poll(() => current.evaluate(node => node === document.activeElement)).toBe(true) + await page.keyboard.press('Escape') + await expect.poll(() => toggle.getAttribute('aria-expanded')).toBe('false') + expect(await navigator.count()).toBe(0) + expect(await toggle.evaluate(node => node === document.activeElement)).toBe(true) + + // A row narrower than 400px is filled by the tree, without the resize edge; Escape still closes it. + await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 360px !important; }' }) + await toggle.click() + await column.locator('[data-files-navigator-mode="fill"]:visible').waitFor() + const narrowRow = await body.locator('..').boundingBox() + const narrowTree = await navigator.boundingBox() + if (narrowRow === null || narrowTree === null) throw new Error('narrow geometry is not rendered') + expect(Math.abs(narrowTree.width - narrowRow.width)).toBeLessThanOrEqual(1) + expect(await navigator.getByRole('separator').count()).toBe(0) + expect(await body.evaluate(node => getComputedStyle(node).visibility)).toBe('hidden') + await toggle.focus() + await page.keyboard.press('Tab') + expect(await body.evaluate(node => node.contains(document.activeElement))).toBe(false) + expect(await navigator.evaluate(node => node.contains(document.activeElement))).toBe(true) + await navigator.getByRole('button', { name: 'b.txt', exact: true }).focus() + await page.keyboard.press('Escape') + await expect.poll(() => toggle.getAttribute('aria-expanded')).toBe('false') + + // A phone-width viewport fills the row below the header with the tree, without the resize edge, and opening a file hides it. + // The resize edge holding the keyboard when the viewport narrows hands it to the tree. + await layout.evaluate((node) => { node.textContent = '' }) + await toggle.click() + const resizeEdge = navigator.getByRole('separator', { name: 'Resize file tree' }) + await resizeEdge.focus() + expect(await resizeEdge.evaluate(node => node === document.activeElement)).toBe(true) + await page.setViewportSize({ width: 390, height: 844 }) + await expect.poll(() => navigator.evaluate(node => node.contains(document.activeElement) + && !document.activeElement?.hasAttribute('data-files-navigator-resize'))).toBe(true) + // The panel slides into its fullscreen place, so the geometry is read once it settles. + await expect.poll(async () => { + const row = await body.locator('..').boundingBox() + const filled = await navigator.boundingBox() + return row === null || filled === null ? Number.POSITIVE_INFINITY : Math.abs(filled.width - row.width) + }).toBeLessThanOrEqual(1) + expect(await navigator.getByRole('separator').count()).toBe(0) + await navigator.getByRole('button', { name: 'a.txt', exact: true }).click() + await expect.poll(() => path.textContent()).toContain('a.txt') + await expect.poll(() => toggle.getAttribute('aria-expanded')).toBe('false') + expect(await navigator.count()).toBe(0) + await page.setViewportSize({ width: 1680, height: 1000 }) + await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 460px !important; }' }) + + // The workspace command shows the pane's tree, moves the keyboard into it, and keeps the file. + await input.focus() + await page.keyboard.press('Control+Alt+p') + await expect.poll(() => toggle.getAttribute('aria-expanded')).toBe('true') + await expect.poll(() => current.evaluate(node => node === document.activeElement)).toBe(true) + await expect.poll(() => path.textContent()).toContain('a.txt') + expect(await tabs.count()).toBe(tabCount + 1) + await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 960px !important; }' }) + + await column.locator('[data-dockkit-add-tab]').click() + await column.locator('[data-sidebar-right-guide-entry="files"]').click() + const htmlFilesTab = await tabs.filter({ hasText: 'Files' }).getAttribute('data-dockkit-tab') + await navigator.getByRole('button', { name: 'focus.html', exact: true }).click() + await column.locator(`[data-dockkit-tab="${htmlFilesTab}"]`).hover() + await column.locator(`[data-dockkit-tab-close="${htmlFilesTab}"]`).click() + await expect.poll(() => path.textContent()).toContain('focus.html') + const frame = body.locator('[data-html-preview]') + await frame.waitFor() + for (const narrow of ['row', 'viewport'] as const) { + await toggle.focus() + if (narrow === 'row') await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 360px !important; }' }) + else await page.setViewportSize({ width: 390, height: 844 }) + await expect.poll(() => body.evaluate(node => getComputedStyle(node).visibility)).toBe('hidden') + await page.keyboard.press('Tab') + expect(await navigator.evaluate(node => node.contains(document.activeElement))).toBe(true) + expect(await body.evaluate(node => node.contains(document.activeElement))).toBe(false) + await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 960px !important; }' }) + await page.setViewportSize({ width: 1680, height: 1000 }) + await expect.poll(() => body.evaluate(node => getComputedStyle(node).visibility)).toBe('visible') + await frame.focus() + if (narrow === 'row') await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 360px !important; }' }) + else await page.setViewportSize({ width: 390, height: 844 }) + await expect.poll(() => body.evaluate(node => getComputedStyle(node).visibility)).toBe('hidden') + expect(await toggle.getAttribute('aria-expanded')).toBe('true') + await expect.poll(() => navigator.evaluate(node => node.contains(document.activeElement))).toBe(true) + expect(await frame.evaluate(node => document.activeElement === node)).toBe(false) + await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 960px !important; }' }) + await page.setViewportSize({ width: 1680, height: 1000 }) + await expect.poll(() => body.evaluate(node => getComputedStyle(node).visibility)).toBe('visible') + } + + // A preview dragged out into a floating panel keeps where the reader was in it. + await tabs.filter({ hasText: 'b.txt' }).click() + await expect.poll(() => path.textContent()).toContain('b.txt') + await body.evaluate((node) => { node.scrollTop = 600 }) + await expect.poll(() => body.evaluate(node => node.scrollTop)).toBe(600) + const surface = await page.locator('[data-dockkit-surface]').first().boundingBox() + if (surface === null) throw new Error('surface is not rendered') + await drag(page, tabs.filter({ hasText: 'b.txt' }), { x: surface.x - 240, y: surface.y + surface.height / 2 }) + const float = page.locator('[data-sidebar-right-session]:not([hidden]) [data-dockkit-float]') + await expect.poll(() => float.count()).toBe(1) + const floatPath = float.locator('[data-textpreview-path]') + await floatPath.waitFor() + const floatBody = float.locator('[data-textpreview-state] [data-textpreview-body]') + await expect.poll(() => floatBody.evaluate(node => node.scrollTop)).toBe(600) + // The panel is a new pane, so its tree starts hidden; shown, it draws there too, and the panel keeps its resize handle. + const floatToggle = float.locator('[data-files-navigator-toggle]') + expect(await floatToggle.getAttribute('aria-expanded')).toBe('false') + expect(await float.locator('[data-files-navigator]').count()).toBe(0) + await floatToggle.click() + const before = await float.boundingBox() + if (before === null) throw new Error('float is not rendered') + const resize = float.locator('[data-dockkit-float-resize]') + await drag(page, resize, { x: before.x + Math.max(before.width + 80, 560), y: before.y + before.height + 60 }) + await expect.poll(async () => (await float.boundingBox())?.width ?? before.width).toBeGreaterThan(before.width) + // Wide enough that the tree does not fill the panel. + expect((await float.boundingBox())!.width).toBeGreaterThanOrEqual(440) + const floatTree = float.locator('[data-files-navigator]') + await floatTree.waitFor() + expect(await floatToggle.getAttribute('aria-expanded')).toBe('true') + // Resize while the tree is open, through both sides of its fill breakpoint. + const openBox = await float.boundingBox() + if (openBox === null) throw new Error('floating preview is not rendered') + await resizeFloat(page, float, 360, openBox.height - 20) + await float.locator('[data-files-navigator-mode="fill"]').waitFor() + await resizeFloat(page, float, 560, openBox.height) + expect(await floatTree.count()).toBe(1) + const floatPane = await float.getAttribute('data-dockkit-float') + // A file nothing shows replaces the preview in its panel, which keeps its pane and its tree. + await floatTree.getByRole('button', { name: 'README.md', exact: true }).click() + await expect.poll(() => floatPath.textContent()).toContain('README.md') + expect(await float.count()).toBe(1) + expect(await float.getAttribute('data-dockkit-float')).toBe(floatPane) + expect(await float.locator('[data-files-navigator] [aria-current="true"]').textContent()).toBe('README.md') + // Navigating to the floating preview itself exposes its file when its tree fills the row. + const previewHeight = (await float.boundingBox())!.height + await resizeFloat(page, float, 360, previewHeight) + await float.locator('[data-files-navigator-mode="fill"]').waitFor() + await floatTree.getByRole('button', { name: 'README.md', exact: true }).click() + await expect.poll(() => floatToggle.getAttribute('aria-expanded')).toBe('false') + await expect.poll(() => floatToggle.evaluate(node => node === document.activeElement)).toBe(true) + await floatToggle.click() + await floatTree.waitFor() + + // A file already shown is revealed in the column, which first shows another tab; the panel keeps its file. + // Revealing b.txt opened `src`. + const shown = column.locator('[data-dockkit-tab][aria-selected="true"]') + await column.locator('[data-dockkit-add-tab]').click() + await expect.poll(() => shown.textContent()).not.toContain('a.txt') + const guide = await shown.getAttribute('data-dockkit-tab') + const dockedTabs = await tabs.count() + await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 360px !important; }' }) + const sourceFile = floatTree.getByRole('button', { name: 'a.txt', exact: true }) + await sourceFile.click() + await expect.poll(() => shown.textContent()).toContain('a.txt') + const dockPreview = column.locator('[data-dockkit-pane] [data-textpreview-state]:visible') + await expect.poll(() => dockPreview.locator('[data-files-navigator-toggle]').getAttribute('aria-expanded')).toBe('false') + expect(await dockPreview.locator('[data-textpreview-body]').evaluate(node => getComputedStyle(node).visibility)).toBe('visible') + expect(await floatToggle.getAttribute('aria-expanded')).toBe('true') + expect(await sourceFile.evaluate(node => node === document.activeElement)).toBe(true) + await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 960px !important; }' }) + await resizeFloat(page, float, 560, previewHeight) + expect(await tabs.count()).toBe(dockedTabs) + expect(await floatPath.textContent()).toContain('README.md') + expect(await float.count()).toBe(1) + expect(await tabs.filter({ hasText: 'a.txt' }).count()).toBe(1) + // The column offers a new tab again only once its guide closes. + await column.locator(`[data-dockkit-tab="${guide}"]`).hover() + await column.locator(`[data-dockkit-tab-close="${guide}"]`).click() + await expect.poll(() => column.locator(`[data-dockkit-tab="${guide}"]`).count()).toBe(0) + + // A floating files page stays and opens a file nothing shows in the active docked pane. + await column.locator('[data-dockkit-add-tab]').click() + await column.locator('[data-sidebar-right-guide-entry="files"]').click() + await column.locator('[data-files-page]').waitFor() + await drag(page, tabs.filter({ hasText: 'Files' }), { x: surface.x - 240, y: surface.y + 120 }) + await expect.poll(() => float.count()).toBe(2) + const pageFloat = float.filter({ has: page.locator('[data-files-page]') }) + const filePageBox = await pageFloat.boundingBox() + if (filePageBox === null) throw new Error('floating files page is not rendered') + await resizeFloat(page, pageFloat, 360, filePageBox.height - 20) + await page.setViewportSize({ width: 390, height: 844 }) + const phoneBox = await pageFloat.boundingBox() + if (phoneBox === null) throw new Error('phone-width files page is not rendered') + await resizeFloat(page, pageFloat, 340, phoneBox.height - 20) + expect(await pageFloat.locator('[data-files-navigator]').count()).toBe(1) + await page.setViewportSize({ width: 1680, height: 1000 }) + const docked = await tabs.count() + await pageFloat.getByRole('button', { name: 'src', exact: true }).click() + await pageFloat.getByRole('button', { name: 'c.txt', exact: true }).click() + await column.locator('[data-textpreview-path]:visible', { hasText: 'c.txt' }).waitFor() + expect(await pageFloat.count()).toBe(1) + expect(await float.count()).toBe(2) + expect(await column.locator('[data-dockkit-pane]').count()).toBe(1) + expect(await tabs.count()).toBe(docked + 1) + // A file already shown is revealed in the column, and the floating page stays with its tree. + await pageFloat.getByRole('button', { name: 'a.txt', exact: true }).click() + await expect.poll(() => column.locator('[data-dockkit-pane-active] [data-dockkit-tab][aria-selected="true"]').textContent()).toContain('a.txt') + expect(await pageFloat.count()).toBe(1) + expect(await pageFloat.locator('[data-files-navigator]').count()).toBe(1) + expect(await float.count()).toBe(2) + expect(await tabs.count()).toBe(docked + 1) + + // With the docked area emptied and the column collapsed, a file from the floating page gets a docked pane in the expanded column. + const dockTabs = column.locator('[data-dockkit-pane] [data-dockkit-tab]') + while (await dockTabs.count() > 0) { + const id = await dockTabs.first().getAttribute('data-dockkit-tab') + await dockTabs.first().hover() + await column.locator(`[data-dockkit-tab-close="${id}"]`).click() + await expect.poll(() => column.locator(`[data-dockkit-tab="${id}"]`).count()).toBe(0) + } + const expand = page.locator('[data-sidebar-right-expand]') + await expand.waitFor() + expect(await pageFloat.count()).toBe(1) + await pageFloat.getByRole('button', { name: 'b.txt', exact: true }).click() + await column.locator('[data-textpreview-path]:visible', { hasText: 'b.txt' }).waitFor() + // Past the column's slide, it stays open with the file's tab alone: no default page beside it. + await expect.poll(() => column.evaluate(node => node.getAnimations({ subtree: true }) + .filter(animation => animation.playState === 'running').length)).toBe(0) + expect(await expand.count()).toBe(0) + expect(await column.locator('[data-dockkit-pane]').count()).toBe(1) + expect(await dockTabs.count()).toBe(1) + expect(await dockTabs.first().textContent()).toContain('b.txt') + expect(await column.locator('[data-sidebar-right-guide-entry]').count()).toBe(0) + expect((await column.boundingBox())?.width ?? 0).toBeGreaterThan(200) + expect(errors).toEqual({ warnings: [], pageErrors: [] }) +}) + +it('moves a file between the docked column and a floating panel from the file menu, keeping its reading position', async () => { + // The file menu's open actions belong to Open In, which ordinary scenarios leave out; remote launch facts suppress native probes. + const { page, errors } = await openWorkspace('web-e2e-file-browser-destination', + createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }])) + const column = page.locator('[data-rightbar-col]') + const expand = page.locator('[data-sidebar-right-expand]') + const float = page.locator('[data-sidebar-right-session]:not([hidden]) [data-dockkit-float]') + const docked = (name: string) => column.locator('[data-dockkit-pane] [data-dockkit-tab]', { hasText: name }) + const menu = page.locator('[data-context-menu][role="menu"]') + // The files page floats and the column collapses, so every later open starts from a floating source pane. + await expand.click() + await column.locator('[data-sidebar-right-guide-entry="files"]').click() + await column.locator('[data-files-page]').waitFor() + const surface = await page.locator('[data-dockkit-surface]').first().boundingBox() + if (surface === null) throw new Error('surface is not rendered') + await drag(page, column.locator('[data-dockkit-tab]', { hasText: 'Files' }), { x: surface.x - 240, y: surface.y + 120 }) + await expect.poll(() => float.count()).toBe(1) + await column.locator('[data-sidebar-right-toggle]').click() + await expand.waitFor() + const pageFloat = float.filter({ has: page.locator('[data-files-page]') }) + await pageFloat.getByRole('button', { name: 'src', exact: true }).click() + const row = pageFloat.getByRole('button', { name: 'a.txt', exact: true }) + const choose = async (item: string): Promise => { + await row.click({ button: 'right' }) + await menu.waitFor() + await menu.getByRole('menuitem', { name: item, exact: true }).click() + await menu.waitFor({ state: 'detached' }) + } + await row.click({ button: 'right' }) + await menu.getByRole('menuitem', { name: 'Open in floating panel', exact: true }).waitFor() + expect(await menu.getByRole('menuitem', { name: 'Open in sidebar', exact: true }).count()).toBe(1) + await page.keyboard.press('Escape') + await menu.waitFor({ state: 'detached' }) + + // A file nothing shows opens in its own floating panel; the collapsed column stays collapsed. + await choose('Open in floating panel') + await expect.poll(() => float.count()).toBe(2) + const fileFloat = float.filter({ has: page.locator('[data-textpreview-path]', { hasText: 'a.txt' }) }) + const floatBody = fileFloat.locator('[data-textpreview-state] [data-textpreview-body]') + await floatBody.waitFor() + expect(await expand.count()).toBe(1) + expect(await docked('a.txt').count()).toBe(0) + await expect.poll(() => floatBody.evaluate(node => node.scrollHeight > node.clientHeight)).toBe(true) + await floatBody.evaluate((node) => { node.scrollTop = 600 }) + await expect.poll(() => floatBody.evaluate(node => node.scrollTop)).toBe(600) + // Asking again reveals that panel rather than adding one. + await choose('Open in floating panel') + expect(await float.count()).toBe(2) + expect(await fileFloat.count()).toBe(1) + expect(await expand.count()).toBe(1) + + // The floating file moves into the expanded column, where the reader left off. + await choose('Open in sidebar') + await expect.poll(() => fileFloat.count()).toBe(0) + expect(await float.count()).toBe(1) + await expect.poll(() => expand.count()).toBe(0) + await expect.poll(() => docked('a.txt').count()).toBe(1) + const dockBody = column.locator('[data-dockkit-pane] [data-textpreview-state]:visible [data-textpreview-body]') + await expect.poll(() => dockBody.evaluate(node => node.scrollTop)).toBe(600) + + // And back out into a floating panel, leaving the column open and its strip without the file. + await choose('Open in floating panel') + await expect.poll(() => fileFloat.count()).toBe(1) + expect(await float.count()).toBe(2) + await expect.poll(() => docked('a.txt').count()).toBe(0) + expect(await expand.count()).toBe(0) + await expect.poll(() => floatBody.evaluate(node => node.scrollTop)).toBe(600) + expect(errors).toEqual({ warnings: [], pageErrors: [] }) +}) diff --git a/apps/web/tests/fixtures/plugin-market/toolbar.index.json b/apps/web/tests/fixtures/plugin-market/toolbar.index.json new file mode 100644 index 0000000000..b6b78db69e --- /dev/null +++ b/apps/web/tests/fixtures/plugin-market/toolbar.index.json @@ -0,0 +1,19 @@ +{ + "format": 1, + "title": { "en": "Awesome DSH Plugin", "zh": "Awesome DSH Plugin 插件精选" }, + "metadata": { + "categories": { "tools": { "title": { "en": "Tools", "zh": "工具与能力" } } }, + "labels": { "featured": { "title": { "en": "Featured", "zh": "精选" } } } + }, + "plugins": [ + { + "name": "dsh-toolbar-layout-fixture", + "title": { "en": "Toolbar layout fixture", "zh": "工具栏布局示例" }, + "description": { "en": "A local index for responsive toolbar checks.", "zh": "用于工具栏响应式检查的本地索引" }, + "metadata": { "category": "tools", "labels": ["featured"] }, + "versions": [ + { "version": "1.0.0", "publishTimestamp": 1793145600, "source": { "type": "npm" } } + ] + } + ] +} diff --git a/apps/web/tests/model-input-layout.ts b/apps/web/tests/model-input-layout.ts index 5cdacbfabd..5ce07ca4eb 100644 --- a/apps/web/tests/model-input-layout.ts +++ b/apps/web/tests/model-input-layout.ts @@ -1,37 +1,88 @@ -/** Model input types stay below both capacity fields across editor widths. */ +/** Model identity, capacity and input-type fields follow the model row's available width. */ import type { Locator, Page } from 'playwright' import { expect } from 'vitest' /** - * Verify the expanded row at the current viewport and a narrow viewport. + * Verify expanded identity fields, capacity fields and input types across viewport and row-width boundaries. * @param page - page with an explicit viewport, restored even when an assertion fails. - * @param editor - expanded model editor containing the capacity and input-type fields. - * @returns after checking both widths and restoring the caller's size. + * @param editor - expanded model editor containing the first model's identity, capacity and input-type fields. + * @returns after checking all widths and restoring the caller's size. */ export async function assertModelInputLayout(page: Page, editor: Locator): Promise { const original = page.viewportSize() if (original === null) throw new Error('Model input layout checks require an explicit viewport') + const drawerCount = await page.locator('[data-sidebar-drawer]').count() + const entry = editor.locator('[data-row-key]').first() + const originalStyle = await entry.getAttribute('style') + const check = async (): Promise => { + const id = await editor.getByLabel('模型 ID 1', { exact: true }).boundingBox() + const name = await editor.getByRole('textbox', { name: '显示名称 1', exact: true }).boundingBox() + const context = await editor.getByLabel('上下文窗口 1', { exact: true }).boundingBox() + const output = await editor.getByLabel('最大输出 token 数 1', { exact: true }).boundingBox() + const types = await editor.getByRole('group', { name: '输入类型 1' }).boundingBox() + if (id === null || name === null || context === null || output === null || types === null) { + throw new Error('Expanded model fields are not visible') + } + const geometry = await entry.evaluate((element) => { + const style = getComputedStyle(element) + const row = element.firstElementChild! + return { + contentWidth: element.getBoundingClientRect().width - Number.parseFloat(style.paddingLeft) + - Number.parseFloat(style.paddingRight) - Number.parseFloat(style.borderLeftWidth) - Number.parseFloat(style.borderRightWidth), + rowWidth: row.getBoundingClientRect().width, + gap: Number.parseFloat(getComputedStyle(row).columnGap), + buttons: [...row.querySelectorAll(':scope > button')].map(button => button.getBoundingClientRect().width), + } + }) + expect(Math.abs(context.y - output.y)).toBeLessThan(1) + expect(types.y).toBeGreaterThanOrEqual(Math.max(context.y + context.height, output.y + output.height)) + expect(types.width).toBeGreaterThan(context.width) + if (geometry.contentWidth <= 440) { + expect(name.y).toBeGreaterThanOrEqual(id.y + id.height) + expect(name.width).toBeGreaterThan(id.width) + expect(context.y).toBeGreaterThanOrEqual(name.y + name.height) + expect(geometry.buttons).toHaveLength(3) + const available = geometry.rowWidth - geometry.buttons.reduce((sum, width) => sum + width, 0) - 3 * geometry.gap + expect(Math.abs(id.width - available)).toBeLessThan(1) + } else { + expect(Math.abs(id.y - name.y)).toBeLessThan(1) + expect(name.x).toBeGreaterThanOrEqual(id.x + id.width) + } + const overflowing = await editor.getByRole('region', { name: '模型目录', exact: true }).evaluate((region) => { + const widths = [] + for (let element: Element | null = region; element !== null; element = element.parentElement) { + if (element === region || /auto|scroll/.test(getComputedStyle(element).overflowY)) { + widths.push({ width: element.clientWidth, scroll: element.scrollWidth }) + } + if (element.getAttribute('role') === 'dialog') break + } + return widths.filter(({ width, scroll }) => scroll > width) + }) + expect(overflowing).toEqual([]) + } try { - for (const viewport of [original, { width: 760, height: 1000 }]) { + for (const viewport of [original, { width: 760, height: 1000 }, { width: 390, height: 844 }]) { await page.setViewportSize(viewport) - const context = await editor.getByLabel('上下文窗口 1', { exact: true }).boundingBox() - const output = await editor.getByLabel('最大输出 token 数 1', { exact: true }).boundingBox() - const types = await editor.getByRole('group', { name: '输入类型 1' }).boundingBox() - expect(context).not.toBeNull() - expect(output).not.toBeNull() - expect(types).not.toBeNull() - if (context === null || output === null || types === null) throw new Error('Expanded model fields are not visible') - expect(Math.abs(context.y - output.y)).toBeLessThan(1) - expect(types.y).toBeGreaterThanOrEqual(Math.max(context.y + context.height, output.y + output.height)) - expect(types.width).toBeGreaterThan(context.width) + await check() + } + await page.setViewportSize({ width: 1680, height: 1000 }) + for (const width of [440, 441]) { + await entry.evaluate((element, width) => { + element.style.boxSizing = 'content-box' + element.style.width = `${String(width)}px` + }, width) + await check() } } finally { - await page.setViewportSize(original) - // The frame reports its own width to the layout store from a rAF-throttled - // ResizeObserver, so the restored viewport is only in force once the shell - // has left drawer mode; a capture taken in between reads the narrow frame. - if (await page.locator('[data-sidebar-drawer]').count() > 0) { - await expect.poll(async () => page.locator('[data-sidebar-drawer]').count(), { timeout: 5_000 }).toBe(0) + try { + await entry.evaluate((element, originalStyle) => { + if (originalStyle === null) element.removeAttribute('style') + else element.setAttribute('style', originalStyle) + }, originalStyle) + } finally { + await page.setViewportSize(original) + // The shell's ResizeObserver must restore its original drawer posture before the next capture. + await expect.poll(async () => page.locator('[data-sidebar-drawer]').count(), { timeout: 5_000 }).toBe(drawerCount) } } } diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index ac0de149af..d36008959d 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -26,7 +26,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { openSettings, ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' +import { expectTooltipOnTop, openSettings, ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/models-settings', import.meta.url)) const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md') @@ -576,6 +576,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { return { x: rect.left + 20, y: rect.bottom + 4 } }) await page.mouse.move(insertion.x, insertion.y, { steps: 3 }) + expect(await dialog.locator('[data-drop-target]').evaluate(element => getComputedStyle(element).zIndex)).toBe('1') await page.mouse.up() await page.locator('body > div[inert][aria-hidden="true"]:has(button[aria-label^="调整模型顺序 "])').waitFor({ state: 'detached' }) await expect.poll(() => dialog.getByLabel(`模型 ID ${save ? '3' : '2'}`, { exact: true }).inputValue()).toBe('acme-large') @@ -603,7 +604,21 @@ describe('web e2e: Models settings page configures a dormant provider', () => { const viewport = page.viewportSize() if (viewport === null) throw new Error('Model ordering checks require an explicit viewport') try { + const id = dialog.getByRole('textbox', { name: '模型 ID 1', exact: true }) + const name = dialog.getByRole('textbox', { name: '显示名称 1', exact: true }) + const toggle = dialog.getByRole('button', { name: '模型选项 1', exact: true }) + const desktopId = await id.boundingBox() + const desktopName = await name.boundingBox() + if (desktopId === null || desktopName === null) throw new Error('Wide model identity fields are not visible') + expect(Math.abs(desktopId.y - desktopName.y)).toBeLessThan(1) + await name.focus() await page.setViewportSize({ width: 390, height: 844 }) + expect(await name.evaluate(input => input.ownerDocument.activeElement === input)).toBe(true) + expect(await name.isVisible()).toBe(true) + await toggle.click() + await name.waitFor({ state: 'hidden' }) + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'mobile-collapsed.expected.md'), + await captureStableAria(page, '[data-shortcut-modal="settings"]', scaffold.workspaceCwd), MODE) for (const name of ['调整模型顺序 1', '模型选项 1', '删除模型 1']) { const box = await dialog.getByRole('button', { name, exact: true }).boundingBox() expect(box).not.toBeNull() @@ -611,6 +626,24 @@ describe('web e2e: Models settings page configures a dormant provider', () => { expect(box.x).toBeGreaterThanOrEqual(0) expect(box.x + box.width).toBeLessThanOrEqual(390) } + const compactId = await id.boundingBox() + if (compactId === null) throw new Error('Narrow model ID is not visible') + await toggle.click() + await name.waitFor({ state: 'visible' }) + await name.fill('手机上的完整显示名称') + await toggle.click() + await name.waitFor({ state: 'hidden' }) + expect(await id.inputValue()).toBe('order-second') + await toggle.click() + expect(await name.inputValue()).toBe('手机上的完整显示名称') + const expandedName = await name.boundingBox() + if (expandedName === null) throw new Error('Expanded model name is not visible') + expect(expandedName.y).toBeGreaterThanOrEqual(compactId.y + compactId.height) + expect(expandedName.width).toBeGreaterThan(compactId.width) + expect(await dialog.getByRole('region', { name: '模型目录', exact: true }) + .evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'mobile-expanded.expected.md'), + await captureStableAria(page, '[data-shortcut-modal="settings"]', scaffold.workspaceCwd), MODE) } finally { await page.setViewportSize(viewport) } @@ -618,6 +651,118 @@ describe('web e2e: Models settings page configures a dormant provider', () => { expect(tripwire.pageErrors).toEqual([]) }) + it.each([{ name: 'Chromium', engine: chromium }, { name: 'WebKit', engine: webkit }])( + 'keeps mobile model actions, focus and keyboard order in $name', async ({ name: engineName, engine }) => { + const provider = `review-${engineName.toLowerCase()}` + let mobileBrowser: Browser | undefined + try { + await scaffold.ctx.settings.mutate('llm-pi-ai', [{ + op: 'set', path: ['providers', provider], value: { + api: 'anthropic-messages', baseURL: 'https://layout.example', + models: [{ id: 'layout-model', name: '完整显示名称' }, { id: 'next-model', name: '下一模型' }], + }, + }]) + mobileBrowser = await engine.launch() + const mobilePage = await mobileBrowser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + const errors = watchConsole(mobilePage) + onTestFailed(() => saveFailureShot(mobilePage, `web-e2e-models-mobile-${engineName.toLowerCase()}`)) + await mobilePage.goto(scaffold.authenticatedUrl) + await openSettings(mobilePage, 'zh') + const dialog = mobilePage.getByRole('dialog', { name: '设置' }) + await dialog.getByRole('button', { name: '模型', exact: true }).click() + const edit = dialog.getByRole('button', { name: `编辑 ${provider}`, exact: true }) + await edit.click() + await dialog.getByText('自定义设置').click() + const id = dialog.getByRole('textbox', { name: '模型 ID 1', exact: true }) + const name = dialog.getByRole('textbox', { name: '显示名称 1', exact: true }) + const toggle = dialog.getByRole('button', { name: '模型选项 1', exact: true }) + const nextToggle = dialog.getByRole('button', { name: '模型选项 2', exact: true }) + await toggle.click() + await assertModelInputLayout(mobilePage, dialog) + await dialog.getByRole('button', { name: '调整模型顺序 1', exact: true }).hover() + const tooltip = mobilePage.getByRole('tooltip') + await tooltip.waitFor() + expect(await tooltip.evaluate(element => element.parentElement === element.ownerDocument.body)).toBe(true) + await expectTooltipOnTop(tooltip) + await id.hover() + await tooltip.waitFor({ state: 'hidden' }) + await toggle.click() + await name.fill('缩窄后继续编辑') + const input = await name.elementHandle() + if (input === null) throw new Error('Model name input is missing') + await mobilePage.setViewportSize({ width: 390, height: 844 }) + expect(await input.evaluate(element => element.isConnected && element.ownerDocument.activeElement === element)).toBe(true) + await nextToggle.click() + expect(await nextToggle.getAttribute('aria-expanded')).toBe('true') + expect(await toggle.getAttribute('aria-expanded')).toBe('true') + await nextToggle.click() + await name.fill('手机保存后的名称') + await dialog.getByRole('button', { name: '保存', exact: true }).click() + await id.waitFor({ state: 'detached' }) + expect(scaffold.ctx.settings.describe().find(row => row.ns === 'llm-pi-ai')?.value).toMatchObject({ + providers: { [provider]: { models: [{ id: 'layout-model', name: '手机保存后的名称' }, { id: 'next-model', name: '下一模型' }] } }, + }) + await dialog.getByText(`已保存 ${provider}。`, { exact: true }).waitFor() + await edit.click() + await dialog.getByText('自定义设置').click() + await name.waitFor({ state: 'hidden' }) + await toggle.focus() + await mobilePage.keyboard.press('Enter') + const tab = engine === webkit ? 'Alt+Tab' : 'Tab' + await mobilePage.keyboard.press(tab) + expect(await dialog.getByRole('button', { name: '删除模型 1', exact: true }) + .evaluate(button => button.ownerDocument.activeElement === button)).toBe(true) + await mobilePage.keyboard.press(tab) + expect(await name.evaluate(element => element.ownerDocument.activeElement === element)).toBe(true) + await name.fill('直接点击收起后保留') + await toggle.click() + await name.waitFor({ state: 'hidden' }) + expect(await toggle.getAttribute('aria-expanded')).toBe('false') + expect(await toggle.evaluate(button => button.ownerDocument.activeElement === button)).toBe(true) + await toggle.click() + expect(await name.inputValue()).toBe('直接点击收起后保留') + const region = dialog.getByRole('region', { name: '模型目录', exact: true }) + expect(await region.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + await dialog.getByRole('button', { name: '取消', exact: true }).click() + expect(errors.pageErrors).toEqual([]) + + const touchPage = await mobileBrowser.newPage({ + viewport: { width: 390, height: 844 }, locale: ZH_BROWSER_LOCALE, isMobile: true, hasTouch: true, + }) + const touchErrors = watchConsole(touchPage) + await touchPage.goto(scaffold.authenticatedUrl) + await openSettings(touchPage, 'zh') + const touchDialog = touchPage.getByRole('dialog', { name: '设置' }) + await touchDialog.getByRole('button', { name: '模型', exact: true }).tap() + await touchDialog.getByRole('button', { name: `编辑 ${provider}`, exact: true }).tap() + await touchDialog.getByText('自定义设置').tap() + const touchToggle = touchDialog.getByRole('button', { name: '模型选项 1', exact: true }) + const touchName = touchDialog.getByRole('textbox', { name: '显示名称 1', exact: true }) + await touchToggle.tap() + expect(await touchName.evaluate(element => element.ownerDocument.activeElement === element)).toBe(false) + await touchName.fill('触屏编辑后的名称') + await touchToggle.tap() + await touchName.waitFor({ state: 'hidden' }) + expect(await touchToggle.getAttribute('aria-expanded')).toBe('false') + await touchToggle.tap() + expect(await touchName.inputValue()).toBe('触屏编辑后的名称') + await touchName.fill('触屏保存后的名称') + const touchNext = touchDialog.getByRole('button', { name: '模型选项 2', exact: true }) + await touchNext.tap() + expect(await touchNext.getAttribute('aria-expanded')).toBe('true') + await touchDialog.getByRole('button', { name: '保存', exact: true }).tap() + await touchName.waitFor({ state: 'detached' }) + expect(scaffold.ctx.settings.describe().find(row => row.ns === 'llm-pi-ai')?.value).toMatchObject({ + providers: { [provider]: { models: [{ id: 'layout-model', name: '触屏保存后的名称' }, { id: 'next-model', name: '下一模型' }] } }, + }) + expect(touchErrors.pageErrors).toEqual([]) + } finally { + await mobileBrowser?.close() + await scaffold.ctx.settings.mutate('llm-pi-ai', [{ op: 'unset', path: ['providers', provider] }]) + } + }, 60_000, + ) + it.each([{ name: 'Chromium', engine: chromium }, { name: 'WebKit', engine: webkit }])( 'keeps keyboard reorder focus and visibility in $name', async ({ engine }) => { const keyboardBrowser = await engine.launch() @@ -704,6 +849,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { 'configured.expected.md', 'declared-edit.expected.md', 'declared.expected.md', 'delete.expected.md', 'empty.expected.md', 'model-picker.expected.md', 'native-delete.expected.md', 'native-keyless.expected.md', 'catalog-inputs.expected.md', + 'mobile-collapsed.expected.md', 'mobile-expanded.expected.md', 'refreshing.expected.md', 'image-only.expected.md', ]) }) diff --git a/apps/web/tests/no-sidebar-documentpreview.overlay.yml b/apps/web/tests/no-sidebar-documentpreview.overlay.yml new file mode 100644 index 0000000000..1c98598ecb --- /dev/null +++ b/apps/web/tests/no-sidebar-documentpreview.overlay.yml @@ -0,0 +1,3 @@ +# Exercise the workspace file browser without the document preview. +- id: ui-sidebar-documentpreview + disabled: true diff --git a/apps/web/tests/no-sidebar-files.overlay.yml b/apps/web/tests/no-sidebar-files.overlay.yml new file mode 100644 index 0000000000..4eebce9f6e --- /dev/null +++ b/apps/web/tests/no-sidebar-files.overlay.yml @@ -0,0 +1,3 @@ +# Exercise the document preview without the workspace file browser. +- id: ui-sidebar-files + disabled: true diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index fd606099de..333accd0aa 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -248,7 +248,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup const customModelId = model.getByLabel('模型 ID 1') await customModelId.fill('private-preview') await model.getByLabel('显示名称 1').fill('Private Preview') - await settings.getByRole('button', { name: '模型选项 1' }).click() + expect(await settings.getByRole('button', { name: '模型选项 1' }).getAttribute('aria-expanded')).toBe('true') await model.getByLabel('上下文窗口 1').fill('131072') await model.getByLabel('最大输出 token 数 1').fill('64K') expect(await settings.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(false) diff --git a/apps/web/tests/plugin-market-toolbar.e2e.ts b/apps/web/tests/plugin-market-toolbar.e2e.ts new file mode 100644 index 0000000000..39f3c8ae52 --- /dev/null +++ b/apps/web/tests/plugin-market-toolbar.e2e.ts @@ -0,0 +1,134 @@ +/** Keyless browser geometry for the Discover toolbar against a local plugin index. */ +import { once } from 'node:events' +import { readFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { fileURLToPath } from 'node:url' +import { chromium, type Locator } from 'playwright' +import { expect, it } from 'vitest' +import type {} from '@deepseek-ai/dsh-plugin-market' +import { compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode } from './scaffold.ts' +import { ZH_BROWSER_LOCALE } from './support.ts' + +const EXPECTED = fileURLToPath(new URL('./expected/plugin-market/toolbar-responsive.expected.md', import.meta.url)) +const INDEX = new URL('./fixtures/plugin-market/toolbar.index.json', import.meta.url) + +/** Read live control geometry, including the index button's intentional left outset. */ +async function measure(toolbar: Locator) { + return toolbar.evaluate((element) => { + const bounds = element.getBoundingClientRect() + const panel = element.closest('[data-plugin-panel]')!.getBoundingClientRect() + const controls = Array.from(element.querySelectorAll('button, input')) + const rects = controls.map(control => control.getBoundingClientRect()) + const contained = rects.every((rect, index) => { + // The index button uses an 8px left outset to align its text with the page content. + const left = index === 0 ? bounds.left - 8 : bounds.left + return rect.left >= left - 1 && rect.right <= bounds.right + 1 + && rect.left >= panel.left && rect.right <= panel.right + && rect.left >= 0 && rect.right <= innerWidth && rect.top >= 0 && rect.bottom <= innerHeight + }) + const reachable = controls.every((control, index) => { + const rect = rects[index]! + const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2) + return rect.width > 0 && rect.height > 0 && (hit === control || control.contains(hit)) + }) + const separated = rects.every((rect, index) => rects.slice(index + 1).every(other => + rect.right <= other.left || other.right <= rect.left || rect.bottom <= other.top || other.bottom <= rect.top)) + const centers = rects.map(rect => rect.top + rect.height / 2) + return { + controls: controls.length, + contained, + reachable, + separated, + overflow: element.scrollWidth > element.clientWidth, + pageOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth, + singleLine: Math.max(...centers) - Math.min(...centers) < 1, + focused: document.activeElement === element.querySelector('input'), + } + }) +} + +/** Every toolbar action remains visible, reachable, and separate without losing the search focus. */ +function validLayout(layout: Awaited>): boolean { + return layout.controls === 5 && layout.contained && layout.reachable && layout.separated + && !layout.overflow && !layout.pageOverflow && layout.focused +} + +it('keeps Discover controls reachable when the toolbar wraps and retains search focus across widths', async () => { + const body = await readFile(INDEX) + const source = createServer((request, response) => { + if (request.url !== '/index.json') { + response.writeHead(404).end() + return + } + response.writeHead(200, { 'content-type': 'application/json' }).end(body) + }) + source.listen(0, '127.0.0.1') + try { + await once(source, 'listening') + const address = source.address() + if (address === null || typeof address === 'string') throw new Error('The plugin index fixture has no TCP port') + const scaffold = await launchWebScaffold({ + extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), + profile: { packages: [], bundles: ['@deepseek-ai/dsh-experimental-plugin-market-bundle'] }, + }) + try { + await scaffold.ctx.pluginMarket.setIndexes([`http://127.0.0.1:${address.port}/index.json`]) + const browser = await chromium.launch() + try { + const page = await browser.newPage({ viewport: { width: 1280, height: 844 }, locale: ZH_BROWSER_LOCALE, reducedMotion: 'reduce' }) + const consoleWatch = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.getByRole('button', { name: '插件', exact: true }).click() + await page.getByRole('tab', { name: '发现', exact: true }).click() + await page.locator('[data-market-entry="dsh-toolbar-layout-fixture"]').waitFor() + await page.getByRole('button', { name: '收起侧边栏', exact: true }).click() + await page.evaluate(() => document.fonts.ready) + const toolbar = page.locator('[data-discover-toolbar]') + const search = toolbar.getByRole('searchbox', { name: '搜索插件' }) + await search.focus() + const facts: string[] = [] + for (const width of [390, 430, 768, 1280]) { + await page.setViewportSize({ width, height: 844 }) + await expect.poll(async () => { + const layout = await measure(toolbar) + return validLayout(layout) && layout.singleLine === (width >= 768) ? true : layout + }, { message: `Discover toolbar at ${width}px` }).toBe(true) + facts.push(`- ${width}px: ${width >= 768 ? 'single row' : 'wrapped rows'}; index, search, labels, sort, and refresh are contained, reachable, and separate; no horizontal overflow; search focus retained.`) + } + await page.setViewportSize({ width: 390, height: 844 }) + await expect.poll(async () => validLayout(await measure(toolbar))).toBe(true) + const previous = await toolbar.evaluate((element) => { + const previous = { value: element.style.getPropertyValue('flex-wrap'), priority: element.style.getPropertyPriority('flex-wrap') } + element.style.setProperty('flex-wrap', 'nowrap') + return previous + }) + try { + const noWrap = await measure(toolbar) + expect(noWrap.overflow).toBe(true) + expect(validLayout(noWrap)).toBe(false) + } finally { + await toolbar.evaluate((element, previous) => { + if (previous.value === '') element.style.removeProperty('flex-wrap') + else element.style.setProperty('flex-wrap', previous.value, previous.priority) + }, previous) + } + await expect.poll(async () => validLayout(await measure(toolbar))).toBe(true) + expect(consoleWatch.pageErrors).toEqual([]) + await compareOrRefreshGolden(EXPECTED, + `# Discover toolbar responsive layout\n\n${facts.join('\n')}\n\nAt 390px, restoring the previous no-wrap layout causes horizontal overflow and fails the same layout check. Removing that override restores the controls and preserves search focus.`, + webSnapshotMode()) + } finally { + await browser.close() + } + } finally { + await scaffold.close() + } + } finally { + await new Promise((resolve, reject) => { + source.close((error) => { + if (error === undefined) resolve() + else reject(error) + }) + }) + } +}) diff --git a/apps/web/tests/shortcuts-panels.e2e.ts b/apps/web/tests/shortcuts-panels.e2e.ts index 601221e96d..bdfe036343 100644 --- a/apps/web/tests/shortcuts-panels.e2e.ts +++ b/apps/web/tests/shortcuts-panels.e2e.ts @@ -184,6 +184,9 @@ describe.skipIf(process.platform === 'win32')('Web sidebar shortcuts', () => { await page.keyboard.press(`${primary}+Shift+,`) expect(await panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).count()).toBe(1) expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true) + // Once the reveal's glow has ended, the focused pane draws nothing of its own. file-browser.e2e.ts and the + // seat spec own the glow itself, so this waits for no marker rather than requiring the transient one. + await expect.poll(() => panel.locator('[data-dockkit-pane][data-sidebar-right-flash]').count()).toBe(0) expect(await paneAppearance()).toEqual([plainPane]) await bind(page, primary, 'Toggle panel fullscreen', 'Workspace files') diff --git a/apps/web/tests/shortcuts-workspace.e2e.ts b/apps/web/tests/shortcuts-workspace.e2e.ts index 410fa3ce84..d7c81823c2 100644 --- a/apps/web/tests/shortcuts-workspace.e2e.ts +++ b/apps/web/tests/shortcuts-workspace.e2e.ts @@ -178,8 +178,9 @@ describe.skipIf(mode === 'record')('web e2e: workspace shortcuts', () => { await expect.poll(() => launched).toEqual([{ app: 'vscode', path: workspace.path }]) await page.keyboard.press('Meta+Alt+P') - const files = page.locator('[data-files-state="tree"]') + const files = page.locator('[data-files-page]') await files.locator('[data-files-row="empty"]').waitFor() + // The directory opener sits in the files page's header, above the tree. const opener = files.locator('[data-open-target="directory"]') await opener.getByRole('button', { name: 'Open in VS Code', exact: true }).click() await expect.poll(() => launched).toEqual([ @@ -196,7 +197,7 @@ describe.skipIf(mode === 'record')('web e2e: workspace shortcuts', () => { ]) await expect.poll(() => opener.getAttribute('data-state')).toBe('idle') await compareOrRefreshGolden(join(root, 'files-open.expected.md'), - await captureStableAria(page, '[data-files-state="tree"]', scaffold.workspaceCwd), mode) + await captureStableAria(page, '[data-files-page]', scaffold.workspaceCwd), mode) await opener.getByRole('button', { name: 'More ways to open', exact: true }).click() await page.getByRole('menuitem', { name: 'Finder (default)', exact: true }).waitFor() await page.keyboard.press('Escape') diff --git a/apps/web/tests/sidebar-right.e2e.ts b/apps/web/tests/sidebar-right.e2e.ts index 4e5be429ce..064adbb620 100644 --- a/apps/web/tests/sidebar-right.e2e.ts +++ b/apps/web/tests/sidebar-right.e2e.ts @@ -1094,6 +1094,44 @@ describe('web e2e: shipped right Sidebar', () => { expect(tripwire.warnings).toEqual([]) }, 60_000) + it('expands the collapsed column when a floating panel\'s dock control returns its tab', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-dock-expands')) + const column = await resetSidebar(page) + const floats = page.locator('[data-sidebar-right-session]:not([hidden]) [data-dockkit-float]') + const open = column.locator('[data-sidebar-right-open]') + const selected = (): Promise => + column.locator('[role="tab"][aria-selected="true"] [data-dockkit-tab-title]').allTextContents() + await proseChip(page).click() + await expect.poll(async () => await tabTitles(paneAt(column, 0))).toEqual(['Files', SAMPLE_NAME]) + await floatByDrag(page, paneAt(column, 0).locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME })) + await expect.poll(async () => await floats.count()).toBe(1) + + // The docked column still holds Files while collapsed. + await column.locator('[data-sidebar-right-toggle]').click() + await expect.poll(async () => await open.count()).toBe(0) + await floats.first().locator('[data-dockkit-float-dock]').click() + await expect.poll(async () => await open.count()).toBe(1) + expect(await floats.count()).toBe(0) + await expect.poll(async () => await tabTitles(column)).toEqual(['Files', SAMPLE_NAME]) + await expect.poll(selected).toEqual([SAMPLE_NAME]) + + // Closing the column's only other tab collapses it empty; docking fills it with the tab alone. + await floatByDrag(page, column.locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME })) + await expect.poll(async () => await floats.count()).toBe(1) + const files = column.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }) + await files.hover() + await files.locator('[data-dockkit-tab-close]').click() + await expect.poll(async () => await open.count()).toBe(0) + await floats.first().locator('[data-dockkit-float-dock]').click() + await expect.poll(async () => await open.count()).toBe(1) + expect(await floats.count()).toBe(0) + await expect.poll(async () => await tabTitles(column)).toEqual([SAMPLE_NAME]) + await expect.poll(selected).toEqual([SAMPLE_NAME]) + + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + it('drops a pane whose last tab closes, and follows the last-tab rule on the surface', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-settle')) const column = await resetSidebar(page) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 75efc1547d..7d094253a5 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -89,6 +89,8 @@ "tests/details-session-lifecycle.e2e.ts", "tests/sessionless-header.e2e.ts", "tests/document-preview.e2e.ts", + "tests/file-browser.e2e.ts", + "tests/file-browser-composition.e2e.ts", "tests/excel-opc.e2e.ts", "tests/office-fixture.ts", "tests/sidebar-browser.e2e.ts", @@ -96,6 +98,7 @@ "tests/plugin-config.e2e.ts", "tests/input-plugin-extensions.e2e.ts", "tests/plugin-manager.e2e.ts", + "tests/plugin-market-toolbar.e2e.ts", "tests/plugin-sticky-motion.e2e.ts", "tests/entry-customization.e2e.ts", "tests/cua-optin.e2e.ts", diff --git a/benchmarks/conversation-fold/conversation-fold.bench.client.ts b/benchmarks/conversation-fold/conversation-fold.bench.client.ts index add5a34629..3ab528a616 100644 --- a/benchmarks/conversation-fold/conversation-fold.bench.client.ts +++ b/benchmarks/conversation-fold/conversation-fold.bench.client.ts @@ -1,4 +1,4 @@ -/** Required performance budgets for compiled Client history folding and tool preparation. */ +/** Required performance budgets for compiled Client history folding and live Assistant updates. */ import { join } from 'node:path' import { describe, expect, it } from 'vitest' @@ -10,8 +10,8 @@ import { ciTimeBudget, PERFORMANCE_BUDGET_HEADROOM, } from '../support/calibration.ts' -import { recordTimings, type BenchmarkCase } from '../support/scaling-report.ts' -import type { ConversationFoldWorkerReport, PreparingToolWorkerReport } from './conversation-fold.worker.client.ts' +import { recordPeakMemory, recordTimings, type BenchmarkCase } from '../support/scaling-report.ts' +import type { ConversationFoldWorkerReport, PreparingToolWorkerReport, StreamingAssistantWorkerReport } from './conversation-fold.worker.client.ts' /** Replies in the folded window; each carries one reasoning block and one text block. */ const TURNS = 200 @@ -54,6 +54,20 @@ const PREPARING_CASES = [ { tool: 'bash', characters: 128 * 1024, expectedMs: 150, expectedMb: 8 }, ] as const +const STREAMING_CHARACTERS = 256 * 1024 +const STREAMING_SAMPLES = 5 +/** Reference-machine expectations; shared CI scaling applies only to elapsed time. */ +const STREAMING_WORKLOADS = [ + { fragments: 4_096, expectedMs: 12 }, + { fragments: 16_384, expectedMs: 32 }, +] as const +const STREAMING_CASES = STREAMING_WORKLOADS.flatMap(workload => [ + { ...workload, blockType: 'text', pattern: 'ordinary' }, + { ...workload, blockType: 'reasoning', pattern: 'leading-whitespace' }, +] as const) +/** Incremental heap while the Assembler, live entries, and published Chat state remain reachable. */ +const EXPECTED_STREAMING_RETAINED_MB = 16 + const WORKER = join( import.meta.dirname, '..', @@ -127,3 +141,39 @@ describe('preparing tool arguments', () => { expect(retainedMb).toBeLessThanOrEqual(budgetMb) }) }) + +describe('live Assistant text publication', () => { + it.each(STREAMING_CASES)('publishes $fragments $blockType deltas with $pattern content within bounded CPU and heap costs', async (workload) => { + const samples: StreamingAssistantWorkerReport[] = [] + for (let index = 0; index < STREAMING_SAMPLES; index++) { + const run = await runBuiltBenchmarkWorker({ + worker: WORKER, + args: ['streaming', workload.blockType, workload.pattern, String(STREAMING_CHARACTERS), String(workload.fragments)], + exposeGc: true, timeoutMs: WORKER_TIMEOUT_MS, + }) + expect(run.timedOut, run.stderr).toBe(false) + expect(run.signal, run.stderr).toBeNull() + expect(run.exitCode, run.stderr).toBe(0) + const report = requireReport(run) + expect(report.characters).toBe(STREAMING_CHARACTERS) + expect(report.fragments).toBe(workload.fragments) + samples.push(report) + } + const median = samples.toSorted((a, b) => a.elapsedMs - b.elapsedMs)[Math.floor(STREAMING_SAMPLES / 2)]! + const retainedMb = samples.map(sample => sample.retainedMb).toSorted((a, b) => a - b)[Math.floor(STREAMING_SAMPLES / 2)]! + const budgetMs = ciTimeBudget(workload.expectedMs) + const budgetMb = EXPECTED_STREAMING_RETAINED_MB * PERFORMANCE_BUDGET_HEADROOM + const benchmarkCase: BenchmarkCase = { + id: `conversation-fold/streaming-${workload.blockType}-${workload.pattern}-${String(workload.fragments)}`, + measures: `Client append, final Chat publication, and complete text read for ${String(STREAMING_CHARACTERS / 1024)} KiB in ${String(workload.fragments)} ${workload.blockType} deltas with ${workload.pattern} content.`, + affects: 'Local processing of a long active model response; excludes model pacing, network transport, and browser rendering.', + } + console.log(JSON.stringify({ benchmark: benchmarkCase.id, samples, medianMs: median.elapsedMs, retainedMb, budgetMs, budgetMb })) + recordTimings(benchmarkCase, { + medianMs: { ms: median.elapsedMs, cpuMs: median.cpuUserMs + median.cpuSystemMs }, + }, { medianMs: budgetMs }) + recordPeakMemory(benchmarkCase, { peakRssMb: Math.max(...samples.map(sample => sample.maxRssMb)) }) + expect(median.elapsedMs).toBeLessThanOrEqual(budgetMs) + expect(retainedMb).toBeLessThanOrEqual(budgetMb) + }) +}) diff --git a/benchmarks/conversation-fold/conversation-fold.worker.client.ts b/benchmarks/conversation-fold/conversation-fold.worker.client.ts index 77297a131e..ee42c361a5 100644 --- a/benchmarks/conversation-fold/conversation-fold.worker.client.ts +++ b/benchmarks/conversation-fold/conversation-fold.worker.client.ts @@ -1,4 +1,4 @@ -/** Compiled worker for Client history folding and live tool preparation. */ +/** Compiled worker for Client history folding, live Assistant text, and tool preparation. */ import { performance } from 'node:perf_hooks' import { Context } from '@deepseek-ai/cordis' @@ -57,6 +57,20 @@ export interface PreparingToolWorkerReport { readonly detail: string | undefined } +/** One live Assistant block folded and read through the published Chat snapshot. */ +export interface StreamingAssistantWorkerReport { + readonly blockType: 'text' | 'reasoning' + readonly pattern: 'ordinary' | 'leading-whitespace' + readonly characters: number + readonly fragments: number + readonly checksum: number + readonly elapsedMs: number + readonly cpuUserMs: number + readonly cpuSystemMs: number + readonly retainedMb: number + readonly maxRssMb: number +} + function registerEventDefinitions(ctx: Context): ConversationEventRegistry { const events = new ConversationEventRegistry(ctx) // Only the service holder is an adapter; registrations and routing use production code. @@ -257,6 +271,74 @@ function preparingTool(tool: 'write' | 'bash', characters: number, definitions: } } +function streamingAssistant( + blockType: StreamingAssistantWorkerReport['blockType'], + pattern: StreamingAssistantWorkerReport['pattern'], + characters: number, + fragments: number, + definitions: ConversationEventRegistry, +): StreamingAssistantWorkerReport { + if (globalThis.gc === undefined) throw new Error('streaming benchmark requires --expose-gc') + if (characters % fragments !== 0) throw new Error('streaming characters must divide evenly into fragments') + const chunkSize = characters / fragments + const payload = pattern === 'ordinary' + ? 'abcdefghijklmno '.repeat(Math.ceil(characters / 16)).slice(0, characters) + : ' '.repeat(characters / 2) + 'abcdefghijklmno '.repeat(Math.ceil(characters / 32)).slice(0, characters / 2) + const attemptId = LlmAttemptId('streaming-benchmark') + const live = (index: number, chunk: StreamChunk): SessionEventLikeEntry => ({ + type: 'transient', + event: { + type: 'assistant/live-chunk', seq: 2 + index / (fragments + 1), time: TIME_ZERO + index, + data: { turn: 1, step: 1, attemptId, chunk }, + }, + }) + const chunks: SessionEventLikeEntry[] = [] + for (let index = 0; index < fragments; index++) { + chunks.push(live(index + 1, { + type: blockType === 'text' ? 'text-delta' : 'reasoning-delta', index: 0, + text: payload.slice(index * chunkSize, (index + 1) * chunkSize), + })) + } + const assembler = new ConversationNodeAssembler(definitions, new BenchViewDefinitions()) + assembler.replaceWindow([ + entry(0, 'turn/start', { turn: 1 }), + entry(1, 'step/start', { turn: 1, step: 1 }), + live(0, { type: 'block-start', index: 0, blockType }), + ], false) + assembler.activateTarget('chat') + globalThis.gc() + const before = process.memoryUsage().heapUsed + const start = performance.now() + const cpuStart = process.cpuUsage() + for (const chunk of chunks) assembler.append(chunk) + assembler.flush() + const text = streamingText(assembler, blockType) + // Consuming every character includes any deferred string materialization in the endpoint. + let checksum = 0 + for (let index = 0; index < text.length; index++) checksum += text.charCodeAt(index) + const cpu = process.cpuUsage(cpuStart) + const elapsedMs = performance.now() - start + if (text !== payload) throw new Error('streaming benchmark did not publish the complete Assistant text') + globalThis.gc() + const retainedMb = (process.memoryUsage().heapUsed - before) / 1048576 + // This read keeps the complete owner graph reachable across endpoint collection. + if (streamingText(assembler, blockType) !== text) throw new Error('streaming benchmark lost its published Assistant text') + return { + blockType, pattern, characters: text.length, fragments, checksum, + elapsedMs, cpuUserMs: cpu.user / 1000, cpuSystemMs: cpu.system / 1000, + retainedMb, maxRssMb: process.resourceUsage().maxRSS / 1024, + } +} + +function streamingText(assembler: ConversationNodeAssembler, blockType: StreamingAssistantWorkerReport['blockType']): string { + const node = assembler.get('chat')?.nodes.values().find((candidate): candidate is ChatNode<'assistant-step'> => candidate.kind === 'assistant-step') + const block = node?.data.blocks[0] + if (block?.kind !== blockType || (block.kind !== 'text' && block.kind !== 'reasoning')) { + throw new Error('streaming benchmark did not create its Assistant block') + } + return block.text +} + assertBuiltBenchmarkRuntime(import.meta.url, { '@deepseek-ai/dsh-brand': import.meta.resolve('@deepseek-ai/dsh-brand'), '@deepseek-ai/dsh-client-store': import.meta.resolve('@deepseek-ai/dsh-client-store'), @@ -273,6 +355,16 @@ try { if (tool !== 'write' && tool !== 'bash') throw new Error('preparing benchmark tool must be write or bash') const report = preparingTool(tool, positiveInteger(process.argv[4], 'characters'), definitions) process.stdout.write(`${JSON.stringify(report)}\n`) + } else if (process.argv[2] === 'streaming') { + const blockType = process.argv[3] + const pattern = process.argv[4] + if (blockType !== 'text' && blockType !== 'reasoning') throw new Error('streaming block type must be text or reasoning') + if (pattern !== 'ordinary' && pattern !== 'leading-whitespace') throw new Error('streaming pattern must be ordinary or leading-whitespace') + const report = streamingAssistant( + blockType, pattern, positiveInteger(process.argv[5], 'characters'), + positiveInteger(process.argv[6], 'fragments'), definitions, + ) + process.stdout.write(`${JSON.stringify(report)}\n`) } else { const [turnsValue, smallDeltasValue, largeDeltasValue, attemptsValue] = process.argv.slice(2) const turns = positiveInteger(turnsValue, 'turns') diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 7a97c18d6d..665d0b4249 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -55,6 +55,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | | `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:40`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | +| `llm-pi-ai/request-headers` | `waterfall` | [`packages/llm/llm-pi-ai/src/index.ts:114`](../packages/llm/llm-pi-ai/src/index.ts) | [`llm-pi-ai`](../packages/llm/llm-pi-ai) (`waterfall`) | - | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:24`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` | | `llm/models-updated` | `emit` | [`packages/llm/llm/src/types.ts:30`](../packages/llm/llm/src/types.ts) | [`llm-deepseek`](../packages/llm/llm-deepseek) (`emit`) | `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 6722d7dd94..151fd95d1c 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -57,6 +57,7 @@ | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | | `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:40`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | +| `llm-pi-ai/request-headers` | `waterfall` | [`packages/llm/llm-pi-ai/src/index.ts:114`](../packages/llm/llm-pi-ai/src/index.ts) | [`llm-pi-ai`](../packages/llm/llm-pi-ai) (`waterfall`) | - | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:24`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` | | `llm/models-updated` | `emit` | [`packages/llm/llm/src/types.ts:30`](../packages/llm/llm/src/types.ts) | [`llm-deepseek`](../packages/llm/llm-deepseek) (`emit`) | `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | diff --git a/docs/subsystems/client-modules.i18n.yaml b/docs/subsystems/client-modules.i18n.yaml index 6e62ff7821..58967468c2 100644 --- a/docs/subsystems/client-modules.i18n.yaml +++ b/docs/subsystems/client-modules.i18n.yaml @@ -9,8 +9,8 @@ en: 96b95cdb015c1af6 zh: 01e4144d91551c13 /client-modules/the-scan: - en: 811c0e8a3a370399 - zh: 4a49a3999d35503a + en: f4d641734c47b129 + zh: 670819fb119ae688 /client-modules/the-bundle-route-and-index-injection: en: 82c089fbd9ffd535 zh: 30ac52d01b4fc074 diff --git a/docs/subsystems/client-modules.md b/docs/subsystems/client-modules.md index c7f175b9e6..5b058a2508 100644 --- a/docs/subsystems/client-modules.md +++ b/docs/subsystems/client-modules.md @@ -93,7 +93,9 @@ Initial publication and HMR derive each row's `rev` from the entry's mtime, ctim A package joins the table by declaring `dsh.client` (`platform: 'web'`, optional `inject` edges, optional `immediately`) in its package.json and exporting its built bundle at `exports["./client"]`. Each live row resolves from its own Loader specifier and owning-tree `baseUrl`, through the same `loader.internal.resolveSync` implementation that imports its Host face when available. The nearest owning package manifest supplies the browser module id, so relative source and built overlays retain the package identity. Distinct active Loader sources resolving to one package name fail composition; after one source unloads, the surviving source supplies the row without a fiber restart. -Scanning is incremental per package; there is no full-rescan code path. Every cordis `internal/plugin` emission (fiber construction or disposal) marks the fiber's entry name dirty, and a microtask flush reconciles each dirty name against the live loader entries. The activation pass seeds the same dirty set with all current entries and flushes synchronously, so first scan and steady state share one implementation — with opposite failure postures. At activation, a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud `AggregateError` listing every broken package: the fiber FAILS and the boot's fail-loud sweep reports it. In steady state, a broken package logs a warning and must not poison the others. +Package reconciliation is incremental by Loader entry name. Every Cordis `internal/plugin` emission (fiber construction or disposal) marks the fiber's entry name dirty. Each nonempty flush enumerates all Loader entries once and indexes them by name at flush start; pending names use their indexed entries, including all same-name sources, and missing names use an empty list to remove unloaded sources. Package resolution must not synchronously add or remove Loader entries during reconciliation. Activation seeds all current names and flushes synchronously; later notifications queue a microtask flush. + +At activation, malformed declarations, missing bundles, and flush enumeration failures aggregate into a composition error: the fiber fails and boot reports it. In steady state, package failures log warnings without preventing other names from being reconciled. An enumeration failure consumes the failed batch, logs one warning, and leaves package state and the published graph unchanged; each affected name waits for another notification before retrying. Each reconciliation reads the package manifest and the bundle metadata again. A fiber restart from the same source that finds the same declaration and bundle metadata keeps its row and rev and reads no bundle bytes; a changed declaration, client export, or bundle is published by that reconciliation. A bundle rebuilt without an entry restart reaches the graph through `rebuilt()`. diff --git a/docs/subsystems/client-modules.zh.md b/docs/subsystems/client-modules.zh.md index b0a665fe9a..9b2af16fda 100644 --- a/docs/subsystems/client-modules.zh.md +++ b/docs/subsystems/client-modules.zh.md @@ -93,7 +93,9 @@ interface WebBootGraph { 包加入这张表的方式,是在自己的 package.json 中声明 `dsh.client`(`platform: 'web'`、可选的 `inject` 边、可选的 `immediately`),并在 `exports["./client"]` 导出构建好的 bundle。每个 live row 都从自己的 Loader specifier 与所属 tree `baseUrl` 解析;若 `loader.internal.resolveSync` 可用,则使用 Host face import 所用的同一个实现。最近归属的 package manifest 提供浏览器模块 id,因此相对 source 与 built overlay 仍保留包身份。若不同的 active Loader source 解析到同一包名,组合会失败;一个来源卸载后,仍存活的来源无需重启 fiber 即可提供该 row。 -扫描是单包增量的;不存在全量重扫代码路径。fiber 构造或 dispose(资源释放)时的每次 cordis `internal/plugin` 发射都把该 fiber 的 entry 名标脏,一次微任务 flush 把每个脏名与实时 loader entry 对账。激活趟以全部当前 entry 灌入同一个脏集合并同步 flush,因此初扫与稳态共享一条实现——但失败姿态相反。激活时,已加载 entry 中的畸形声明或缺失 bundle 会聚合为一个大声的 `AggregateError`,列出每个损坏的包:该 fiber 进入 FAILED,由启动的大声失败 sweep 上报。稳态下,损坏的包只记录一条警告,且不得殃及其他包。 +包按 Loader entry 名增量对账。fiber 构造或 dispose(资源释放)时的每次 Cordis `internal/plugin` 事件都会把该 fiber 的 entry 名标脏。每次非空 flush 在开始时枚举全部 Loader entries 一次并按名称建索引;待处理名称使用索引中的全部同名来源,缺失名称使用空列表移除已卸载的来源。包解析过程不得在对账期间同步添加或移除 Loader entries。激活阶段初始化全部当前名称并同步 flush;后续通知通过微任务执行 flush。 + +激活时,畸形声明、缺失 bundle 和 flush 枚举失败会聚合为组合错误:fiber 失败,由启动流程报告。稳态下,包失败只记录警告,不妨碍其他名称继续对账。枚举失败会消费失败批次、记录一条警告,并保持包状态和已发布的图不变;受影响的每个名称都要等另一次通知才会重试。 每次对账都会重新读取包 manifest 与 bundle 元数据。同一来源的 fiber 重启若读到相同的声明与 bundle 元数据,就保留其 row 与 rev,且不读取 bundle 字节;声明、client 导出或 bundle 发生变化时,由这次对账发布。没有伴随条目重启的 bundle 重建经 `rebuilt()` 到达图。 diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 918f4882bf..59d8d46494 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -44,6 +44,9 @@ /llm-streaming/official-deepseek-request-extensions: en: 42bc50394b070e96 zh: 007caa743ec3c790 +/llm-streaming/pi-ai-request-headers: + en: 8c140f9c5a799f01 + zh: f66b895594247e92 /llm-streaming/service-and-provider-contracts: en: 8dc79d4cc82824c2 zh: 3ebabd3a117ad717 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index cf8d4c8310..565da04ff2 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -860,6 +860,10 @@ interface LlmCallConfigAdapterDefaults { The [wire reference](../deepseek-llm-api-wire-extensions.md) defines the exact request headers, extension transaction, field versions, and receiver obligations. The shipped composition registers [`dsh_session_log`](../../packages/session/session-log-deepseek/README.md) as a lossless incremental canonical-log suffix and [`dsh_plugin_packages`](../../packages/llm/plugin-package-inventory-deepseek/README.md) as the complete active Loader-backed package set. These fields remain outside model messages and are absent from the pi-ai adapter path. +## Pi-ai request headers + +`PiAiRequestHeaderContext` contains the prepared call's provider, model, resolved model `baseURL`, and optional Session id and auxiliary purpose. It is immutable, contains no messages or authentication fields, and accompanies a request-owned `Headers` object in the synchronous `llm-pi-ai/request-headers` waterfall. The [adapter's request-header documentation](../../packages/llm/llm-pi-ai/README.md#request-headers) defines contribution ownership, attribution precedence, and failure behavior. This extension point is specific to pi-ai inference requests. + ## Service and provider contracts `LlmAdapter` is the provider contract: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerRetryPolicy()` is captured per route with normal defaults, while `providerInfo()` and asynchronous `listModels()` feed `LlmRuntime.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. One asynchronous `resolveModel()` query returns exact model identity plus optional correctness-sensitive context capacity, an adapter-configured `defaultMaxTokens`, and ordered model-owned reasoning ids with an optional deployment default; absent fields mean unavailable metadata or provider-owned behavior, not invalid catalog membership. The resolver receives optional cancellation and must settle promptly after abort. `LlmRuntime.resolveModelInfo()` validates and detaches the aggregate. At the final adapter boundary, `resolveCallConfig()` materializes the output default only when `maxTokens` is absent and validates and materializes reasoning, so direct calls cannot bypass either configured behavior; direct dispatch captures one registration before awaiting that resolution. The agent loop instead uses `prepareCall()` to keep the same registration across model resolution, durable header logging, and dispatch, retain detached context metadata from that exact lookup, and report which config fields the adapter defaulted. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. AgentLoop observes a request attempt once the outer waterfall returns a stream handle; that limited boundary does not prove a lazy terminal adapter was constructed or began provider I/O. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. [architecture.md](../architecture.md#turn-flow) shows where `ctx.llm.stream()` and the `llm/stream` waterfall sit in one turn. @@ -1248,4 +1252,33 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t ``` Source: [`packages/llm/llm/src/index.ts`](../../packages/llm/llm/src/index.ts) + + + +### `llm-pi-ai/*` events + + + +#### `llm-pi-ai/request-headers` — waterfall + +Prepare headers for one pi-ai inference call, excluding model discovery. Listeners run synchronously and must call next(); they may edit only the request-owned headers and must not retain them. Throws fail the call with REQUEST_HEADERS before inference I/O. Harness attribution wins afterward. Non-TypeError messages are user-visible and must exclude secrets. TypeError details and non-Error values are replaced with safe guidance. + +```ts cordis-catalog +/** + * Prepare headers for one pi-ai inference call, excluding model discovery. + * Listeners run synchronously and must call next(); they may edit only the + * request-owned headers and must not retain them. Throws fail the call with + * REQUEST_HEADERS before inference I/O. Harness attribution wins afterward. + * Non-TypeError messages are user-visible and must exclude secrets. TypeError + * details and non-Error values are replaced with safe guidance. + * @mode waterfall + * @param request - immutable routing metadata from the exact prepared call. + * @param headers - fresh profile headers, shared only within this dispatch. + * @param next - continue header preparation through the remaining listeners. + * @returns headers passed to pi-ai after Harness attribution is applied. + */ +'llm-pi-ai/request-headers'(request: PiAiRequestHeaderContext, headers: Headers, next: () => Headers): Headers +``` + +Source: [`packages/llm/llm-pi-ai/src/index.ts`](../../packages/llm/llm-pi-ai/src/index.ts) diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 0feb80a1a1..2f360b8f8f 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -866,6 +866,10 @@ interface LlmCallConfigAdapterDefaults { [协议参考](../deepseek-llm-api-wire-extensions.zh.md)定义确切的请求标头、扩展事务、字段版本和接收方义务。随附组合会将 [`dsh_session_log`](../../packages/session/session-log-deepseek/README.zh.md) 注册为无损增量权威日志后缀,并将 [`dsh_plugin_packages`](../../packages/llm/plugin-package-inventory-deepseek/README.zh.md) 注册为完整存活 Loader 包集合。这些字段仍位于模型消息之外,也不会进入 pi-ai 适配器路径。 +## Pi-ai 请求头 + +`PiAiRequestHeaderContext` 包含已准备调用的 provider、model、解析后的模型 `baseURL`,以及可选 Session id 和辅助用途。它不可变且不含消息或认证字段,与本次请求独有的 `Headers` 一起传入同步的 `llm-pi-ai/request-headers` waterfall。[适配器的请求头文档](../../packages/llm/llm-pi-ai/README.zh.md#request-headers)定义贡献的所有权、应用标识的优先级与失败行为。此扩展点仅适用于 pi-ai 推理请求。 + ## 服务与提供方约定 `LlmAdapter` 是提供方约定:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerRetryPolicy()` 会按路由捕获并填入 normal 默认值,`providerInfo()` 与异步 `listModels()` 方法则为 `LlmRuntime.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单次异步 `resolveModel()` 查询返回确切模型身份,以及可选的对正确性敏感的上下文容量、适配器配置的 `defaultMaxTokens`、由模型持有的有序推理强度 ID 和可选的部署默认值;字段缺失表示元数据不可用或保留提供方持有的行为,而不表示目录成员关系无效。解析器会接收可选的取消信号,并且必须在信号中止后迅速完成结算。`LlmRuntime.resolveModelInfo()` 会校验聚合结果并返回分离值。在最终适配器边界,`resolveCallConfig()` 仅在 `maxTokens` 缺失时填入输出默认值,并校验和填入推理强度,因此直接调用也无法绕过任何一项已配置行为;直接分派会在等待解析前捕获一项适配器注册。agent loop 则使用 `prepareCall()`,使模型解析、请求头持久记录和分派全程使用同一项注册,保留来自同一次查询的分离上下文元数据,并报告适配器填入的配置字段。适配器查找发生在 `llm/stream` waterfall 的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。AgentLoop 在外层 waterfall 返回流句柄时观察到一次请求尝试;这个有限边界不能证明惰性终端适配器已构造完成或开始提供方 I/O。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。`ctx.llm.stream()` 与 `llm/stream` waterfall 在一个轮次中的位置见 [architecture.md](../architecture.zh.md#turn-flow)。 @@ -1254,4 +1258,33 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t ``` Source: [`packages/llm/llm/src/index.ts`](../../packages/llm/llm/src/index.ts) + + + +### `llm-pi-ai/*` events + + + +#### `llm-pi-ai/request-headers` — waterfall + +Prepare headers for one pi-ai inference call, excluding model discovery. Listeners run synchronously and must call next(); they may edit only the request-owned headers and must not retain them. Throws fail the call with REQUEST_HEADERS before inference I/O. Harness attribution wins afterward. Non-TypeError messages are user-visible and must exclude secrets. TypeError details and non-Error values are replaced with safe guidance. + +```ts cordis-catalog +/** + * Prepare headers for one pi-ai inference call, excluding model discovery. + * Listeners run synchronously and must call next(); they may edit only the + * request-owned headers and must not retain them. Throws fail the call with + * REQUEST_HEADERS before inference I/O. Harness attribution wins afterward. + * Non-TypeError messages are user-visible and must exclude secrets. TypeError + * details and non-Error values are replaced with safe guidance. + * @mode waterfall + * @param request - immutable routing metadata from the exact prepared call. + * @param headers - fresh profile headers, shared only within this dispatch. + * @param next - continue header preparation through the remaining listeners. + * @returns headers passed to pi-ai after Harness attribution is applied. + */ +'llm-pi-ai/request-headers'(request: PiAiRequestHeaderContext, headers: Headers, next: () => Headers): Headers +``` + +Source: [`packages/llm/llm-pi-ai/src/index.ts`](../../packages/llm/llm-pi-ai/src/index.ts) diff --git a/docs/subsystems/sidebar-right.i18n.yaml b/docs/subsystems/sidebar-right.i18n.yaml index 01662fe377..482969fe6c 100644 --- a/docs/subsystems/sidebar-right.i18n.yaml +++ b/docs/subsystems/sidebar-right.i18n.yaml @@ -15,11 +15,11 @@ en: b7b8a7b411aee94d zh: 7298c20000945d97 /right-sidebar/navigation-ctx-sidebarright: - en: c695e9aca92271f2 - zh: 4b4d820c5a16bed1 + en: 2a2b3a33df9e0749 + zh: 629c791cd2ae50ba /right-sidebar/slots-and-owner-props: - en: f58dc6ff6b0027a7 - zh: 707722fcbd096efc + en: 6b5faaed58ab5fb1 + zh: 2118439d3403a735 /right-sidebar/document-renderers: en: 2bf47a890fce175e zh: 45f81c579b8939a8 @@ -30,8 +30,8 @@ en: 2b1b49dbba9cc9ee zh: 13d2a45224743b37 /right-sidebar/shipped-types: - en: 81cc0928abeff74b - zh: 370fcfd5bb350ed9 + en: f8f0e368701f6669 + zh: de364aaf3345d70c /right-sidebar/not-built: - en: 65e86c293eb99242 - zh: 852dfe7713848453 + en: 5cf5ab52c8007897 + zh: 346490f01e318d95 diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md index 78bff4d40c..00a0671bae 100644 --- a/docs/subsystems/sidebar-right.md +++ b/docs/subsystems/sidebar-right.md @@ -75,22 +75,23 @@ export function apply(ctx: Context): void { ## Navigation: `ctx.sidebarRight` -Two opens are the navigation controller, and every way into the column calls one of them: `openResource(address, options?)` for a `dsh-resource://` address — the conversation's file links, a tool row's line reference, a file tree's rows — and `openTab(kind, options?)` for a page — the strip's add control, a guide entry box, or an HTTP(S) link in Assistant Markdown. Both run four steps as one history entry — claim (the registry ranks the resource's types, or the named `kind`'s implementation in force answers); focus a tab already showing the same `(kind, address)`; otherwise seat a new tab; expand the column — and then record the navigation in the Tab domain ([service](../../packages/client/ui-sidebar-right/README.md#ctxsidebarright)). Content the user cannot see is not opened, so a collapsed column expands in the same step. `openResource` throws for an address outside `dsh-resource://` or one no type claims; `openTab` throws for a kind nothing registered: both are wiring mistakes, not user errors. +Two opens are the navigation controller, and every way into the column calls one of them: `openResource(address, options?)` for a `dsh-resource://` address — the conversation's file links, a tool row's line reference, a file tree's rows — and `openTab(kind, options?)` for a page — the strip's add control, a guide entry box, or an HTTP(S) link in Assistant Markdown. Both run four steps as one history entry — claim (the registry ranks the resource's types, or the named `kind`'s implementation in force answers); focus a tab already showing the same `(kind, address)`; otherwise seat a new tab; expand the column — and then record the navigation in the Tab domain ([service](../../packages/client/ui-sidebar-right/README.md#ctxsidebarright)). Content the user cannot see is not opened, so a collapsed column expands in the same step, unless the open replaces a floating tab or reveals a tab in a floating panel. A reveal of a tab already on screen makes the pane holding it glow once, for every tab type, and only while its Session is on screen; a reveal that switches tabs or expands the column does not glow. `openResource` throws for an address outside `dsh-resource://` or one no type claims; `openTab` throws for a kind nothing registered: both are wiring mistakes, not user errors. | Option | Meaning | |---|---| | `paneId` | Land a new tab in this pane; default is the active docked pane (the first docked pane while a floating pane is active). | -| `replaceTab` | Take this tab's pane and strip slot, closing it in the same step; a floating tab lends no place, so the new tab lands as if unplaced. | +| `replaceTab` | New content takes this tab's place and the tab closes in the same step: a docked tab's pane and strip slot, or a floating tab's panel, keeping its pane id and rectangle. A reveal keeps the tab open ([semantics](../../packages/client/ui-sidebar-right/README.md#ctxsidebarright)). | | `revealIfOpened` | Default `true`: a tab already showing the same `(kind, address)` is focused and handed `params`. `false` opens another tab regardless. | | `preferNewPane` | Prefer a new docked pane under the normal pane budget and room rule; fall back to the target pane when it cannot split. Ignored with `replaceTab`. | +| `destination` (`openResource` only) | `'sidebar'` or `'float'`: settle in the active docked pane (the first docked pane while a floating pane is active) or a new floating panel, ignoring the four options above. A copy already there is revealed; else a copy in the other host moves there with its tab id and body state; else a new tab opens. `'float'` leaves the column's expansion unchanged, for a new tab as for a move ([semantics](../../packages/client/ui-sidebar-right/README.md#ctxsidebarright)). | | `kind` (`openResource` only) | Name the opening type instead of ranking claims; its implementation in force opens the address, and its `canOpen` still applies. | | `params` | Navigation parameters for the body, delivered as `navigation.params`. `openResource` types them by resource type through the merge-extensible `SidebarRightResourceParamsMap` (the text preview declares `{ line?: number }`); `openTab` types them by kind through `SidebarRightTabParamsMap`, `undefined` for a kind that declares none; a body reads `SidebarRightNavigationParams`, the union of both. Values are JSON-shaped by convention and not validated at run time. | -Placement is the caller's option, never a type's property. The conversation calls `openResource(fileAddressFor(sessionId, cwd, path))` and, from a `read` tool row, adds `{ params: { line } }` from the call's 1-based `offset`; a guide entry box calls `tab.actions.openTab(entry.kind, { replaceTab: true })`; a file-tree row calls `tab.actions.openResource(address)`; the strip's add control calls `openTab('guide', { paneId, revealIfOpened: false })`. +Placement belongs to the caller, not the tab type. The [files navigator reference](../../packages/client/ui-sidebar-files/README.md#the-navigator) defines its host-owned opening callbacks; the [guide reference](../../packages/client/ui-sidebar-right/README.md#the-guide) defines guide replacement and explicit close. `close(tabId)` closes a tab; `hide(tabId)` closes a floating tab or collapses its docked column; `active()` returns the active pane's active tab; `isExpanded()` and `toggleExpanded()` read and flip the column, the flip recorded in the sequence. Reads answer for the no-Session case with `undefined` or `false`; writes need a mounted Session surface and throw without one rather than write into a surface nobody draws. -`focus(tabId)` makes a tab its pane's active tab; `split(paneId?)` splits the active docked pane, or the named one, and returns the new pane's id — or `undefined`, recording nothing, when the pane budget or the column's width forbids a split; `float(tabId, rect?)` lifts a tab into a floating pane; `dock(paneId)` returns a floating pane to the docked area. All four run the store's existing actions and record one history entry each; a target that does not exist or is already in the requested state is a no-op, and like `open` they throw without a mounted Session surface. `TabId`, `PaneId`, `TabRecord`, and `FloatRect` are re-exported from the package's `/client` entry so a caller needs no dockkit import. +`focus(tabId)` makes a tab its pane's active tab; `split(paneId?)` splits the active docked pane, or the named one, and returns the new pane's id — or `undefined`, recording nothing, when the pane budget or the column's width forbids a split; `float(tabId, rect?)` lifts a tab into a floating pane; `dock(paneId)` returns a floating pane to the docked area and expands the column. All four run the store's existing actions and record one history entry each; a target that does not exist or is already in the requested state is a no-op, and like `open` they throw without a mounted Session surface. `TabId`, `PaneId`, `TabRecord`, and `FloatRect` are re-exported from the package's `/client` entry so a caller needs no dockkit import. `focusedTarget(element?)` captures the visible page under live DOM focus, including an embedding iframe; outside or stale sidebar markup yields no target. `commandTarget(element?)` also permits the mounted Session's active dock pane for an open initiated outside the sidebar. A `SidebarRightTarget` captures Session, pane, host, tab occurrence, and navigation revision. `isTargetCurrent(target)` checks that identity before execution, so reopening a record or navigating it invalidates a captured action; it never retargets to another page. See the [controller methods](../../packages/client/ui-sidebar-right/src/client/service.ts) for close and placement outcomes. @@ -106,9 +107,9 @@ The Sidebar declares four extension slots; its document tab declares the additio | `sidebar.right.tab.menu.item` | list, Session scope | Content-level actions appended after the kit's own layout actions. An item that acts must call the owner's `dismiss()`. | | `sidebar.right.tab.document` | keyed by the document implementation's `id`, Session scope | The selected file renderer inside the document tab; the parent owns shared loading and toolbar controls. | -A body, title and guide replacement receive the framework-injected `useTabInfo()`. It returns `{ sidebar, panel, tab }`: `sidebar` holds `expanded` and `fullscreen`, `panel.id` names the containing pane, and `tab` contains its record fields plus `visible`, `navigation`, `signal`, and `actions`. All tabs require a foreground Session. Docked bodies also require expansion and selection; docked titles require expansion; foreground floats survive collapse. `signal` aborts when the record disappears or the plugin unloads, not on hiding or Session switching. `tab.actions` provides `openResource`, `openTab`, and `close`, bound to the tab's own Session. Open placement defaults to its current pane; `revealIfOpened` defaults to `true`, and `replaceTab: true` replaces this record in the same history entry. Menu entries retain plain `tab` and `dismiss` owner parameters. +A body, title and guide replacement receive the framework-injected `useTabInfo()`. It returns `{ sidebar, panel, tab }`: `sidebar` holds `expanded` and `fullscreen`, `panel.id` names the containing pane and the required `panel.isFloating: boolean` distinguishes a floating panel from a docked pane. Both update through the hook when a tab moves, while `tab.id` stays unchanged. `tab` also contains its other record fields plus `visible`, `navigation`, `signal`, and `actions`. All tabs require a foreground Session. Docked bodies also require expansion and selection; docked titles require expansion; foreground floats survive collapse. `signal` aborts when the record disappears or the plugin unloads, not on hiding or Session switching. `tab.actions` provides `openResource`, `openTab`, and `close`, bound to the tab's own Session. Opens return `void` and default to its current docked pane, or the active docked pane when this tab floats; `revealIfOpened` defaults to `true`, and `replaceTab: true` lets new content replace this record in the same history entry; an `openResource` with `destination` passes neither this tab's pane nor this record. Menu entries retain plain `tab` and `dismiss` owner parameters. -`navigation.revision` increments on every navigation to the tab whether or not `params` changed, so a body can act on "navigated again" alone; it is `1` for a tab opened by address and `0` for a record nobody opened by address — a seeded guide, or a tab restored by undo. The Tab domain holds one occurrence per open record: a record that appears is pinned in the resource model, so switching tabs unmounts a body without dropping its content; a record that vanishes is aborted and dropped; a record restored by undo is a new occurrence ([Tab domain](../../packages/client/ui-sidebar-right/README.md#the-tab-domain)). +`navigation.revision` increments on every navigation to the tab whether or not `params` changed, so a body can act on "navigated again" alone; it is `1` for a tab opened by address and `0` for a record nobody opened by address — a seeded guide, or a tab restored by reload or undo. The Tab domain holds one occurrence per open record: a record that appears is pinned in the resource model, so switching tabs unmounts a body without dropping its content; a record that vanishes is aborted and dropped; a record restored by undo is a new occurrence ([Tab domain](../../packages/client/ui-sidebar-right/README.md#the-tab-domain)). `tab.actions.bindCommands(commands)` contributes `SidebarRightTabCommands`, currently an optional `refresh` callback, for the mounted body. Omitted operations are unavailable; the returned disposer releases that registration without removing a newer body's commands. The tab lifetime also releases them. `tab.refreshShortcut` supplies the effective refresh catalog entry for page controls; it is optional when no entry is available. These fields are declared in the [slot owner types](../../packages/client/ui-sidebar-right/src/client/contract/slots.ts). @@ -141,15 +142,15 @@ The Host `ctx.workspaceFiles` service and generated `workspaceFiles` Remote name ## Shipped types - **`guide`** — `builtin`, opened as `openTab('guide')`. A muted compass sits above one capsule per contributed `guide` entry, in `order`; short lists show registered descriptions, and every missing icon uses the shipped placeholder. Picking a capsule opens the contributing type as a page in the guide tab's place. A pane holds at most one guide tab, and the strip's add control appears only while its pane has none. A new pane receives the registered default page: the sole guide entry directly, or the guide when the entry count is not one ([guide](../../packages/client/ui-sidebar-right/README.md#the-guide)). -- **`text`** — `fallback`, `dsh-resource://file/**`, claiming Session addresses only. Document Preview observes metadata through `useResource<'file'>`, loads content through Remote callbacks, and owns renderer selection, the toolbar, per-tab refresh, scroll, and source navigation; unknown extensions render as plain text ([README](../../packages/client/ui-sidebar-documentpreview/README.md)). -- **`files`** — `builtin`, opened as `openTab('files')`. The workspace directory tree, listed lazily through `list` and watched by expanded directory, opening a file with `tab.actions.openResource(fileAddressFor(sessionId, root, path))` into its own pane ([README](../../packages/client/ui-sidebar-files/README.md)). +- **`text`** — `fallback`, `dsh-resource://file/**`, claiming Session addresses only. Document Preview observes metadata through `useResource<'file'>`, loads content through Remote callbacks, and owns renderer selection, the toolbar, per-tab refresh, scroll, and source navigation; unknown extensions render as plain text. It directly renders the files plugin's `sidebar.files.navigator` and `sidebar.files.navigator.toggle` Factories in docked and floating panes; unregistered Factories render nothing ([README](../../packages/client/ui-sidebar-documentpreview/README.md)). +- **`files`** — `builtin`, opened as `openTab('files')`. The files page and navigator share one workspace tree per pane. [The package reference](../../packages/client/ui-sidebar-files/README.md) owns lazy listings, watches, opening callbacks, and navigation-driven visibility. - **`browser`** — multi-instance `builtin`, opened as `openTab('browser', { params: { url? } })`. Chat's [link preference](../../packages/client/ui-chat/README.md) controls whether Assistant Markdown opens HTTP(S) links here. It accepts public and loopback HTTP(S) targets under the default sandbox, rejects local files in favor of Document Preview, and uses application-known iframe history ([README](../../packages/client/ui-sidebar-browser/README.md)). - **`subagentchat`** — `builtin`, `dsh-resource://subagentchat/session/?parent=&mode=`. An explicitly addressed subagent Conversation retained by the resource provider and rendered through the shared Conversation Factory ([README](../../packages/client/ui-subagent/README.md)). ## Not built -- Persistence: layout state is memory-only; a reload starts every session collapsed, and no session's tabs are visible from another. +- Cross-device layout synchronization: layouts are browser-local, with separate saved state per Session ([state](../../packages/client/ui-sidebar-right/README.md#state)). - A read-only layout snapshot or subscription on `ctx.sidebarRight`: the service exposes operations only, and dockkit's `LayoutState`/`LayoutOp` are internal. - A capability-discovery array (`features`) on the service. - An `option` priority band for tab types: nothing lists a tab type without letting it claim. diff --git a/docs/subsystems/sidebar-right.zh.md b/docs/subsystems/sidebar-right.zh.md index a7ba493452..72c6db445f 100644 --- a/docs/subsystems/sidebar-right.zh.md +++ b/docs/subsystems/sidebar-right.zh.md @@ -75,22 +75,23 @@ export function apply(ctx: Context): void { ## 导航:`ctx.sidebarRight` -两种打开构成导航控制器,进入这一列的每条路都调用其一:`openResource(address, options?)` 打开 `dsh-resource://` 地址——会话区的文件链接、工具行的行号引用、文件树的行;`openTab(kind, options?)` 打开页面——tab 条的新增控件、引导页入口框或 Assistant Markdown 中的 HTTP(S) 链接。两者都以一条历史记录走完四步——认领(注册表为资源排候选,或点名 `kind` 的生效实现应答);聚焦已显示同一 `(kind, address)` 的 tab;否则落一个新 tab;展开这一列——然后把导航记入 Tab 域([服务](../../packages/client/ui-sidebar-right/README.zh.md#ctxsidebarright))。用户看不见的内容不算打开,所以折叠的列会在同一步展开。`openResource` 对 `dsh-resource://` 之外的地址或无人认领的地址抛错;`openTab` 对无人注册的 kind 抛错:二者都是接线错误,不是用户错误。 +两种打开构成导航控制器,进入这一列的每条路都调用其一:`openResource(address, options?)` 打开 `dsh-resource://` 地址——会话区的文件链接、工具行的行号引用、文件树的行;`openTab(kind, options?)` 打开页面——tab 条的新增控件、引导页入口框或 Assistant Markdown 中的 HTTP(S) 链接。两者都以一条历史记录走完四步——认领(注册表为资源排候选,或点名 `kind` 的生效实现应答);聚焦已显示同一 `(kind, address)` 的 tab;否则落一个新 tab;展开这一列——然后把导航记入 Tab 域([服务](../../packages/client/ui-sidebar-right/README.zh.md#ctxsidebarright))。用户看不见的内容不算打开,所以折叠的列会在同一步展开;替换浮动 tab、或揭示浮窗中的 tab 时除外。揭示一个已在屏幕上显示的 tab 时,存放它的格发光提示一次,对所有 tab 类型都一样,且只在其会话位于屏幕上时播放;需要切换 tab 或展开该列的揭示不发光。`openResource` 对 `dsh-resource://` 之外的地址或无人认领的地址抛错;`openTab` 对无人注册的 kind 抛错:二者都是接线错误,不是用户错误。 | 选项 | 含义 | |---|---| | `paneId` | 新 tab 落到这个 pane;缺省为活动的停靠 pane(活动的是浮窗时取第一个停靠 pane)。 | -| `replaceTab` | 占用这个 tab 的 pane 与条上位置,并在同一步关闭它;浮窗里的 tab 让不出位置,新 tab 按未指定位置落位。 | +| `replaceTab` | 新内容取代这个 tab 的位置,并在同一步关闭它:停靠 tab 让出 pane 与条上位置,浮动 tab 让出浮窗,新 tab 沿用其 pane id 与矩形。揭示时该 tab 保持打开([语义](../../packages/client/ui-sidebar-right/README.zh.md#ctxsidebarright))。 | | `revealIfOpened` | 缺省 `true`:已显示同一 `(kind, address)` 的 tab 被聚焦并收到 `params`。`false` 则无论如何再开一个。 | | `preferNewPane` | 在普通格数预算与空间规则下优先新建停靠格;不能分栏时回退到目标格。与 `replaceTab` 一起使用时忽略。 | +| `destination`(仅 `openResource`) | `'sidebar'` 或 `'float'`:落在活跃停靠格(活动格是浮窗时为第一个停靠格)或新浮窗,忽略上面四个选项。该处已有副本时揭示它;否则把另一宿主中的副本连同 tab id 与正文状态移过去;否则新开 tab。`'float'` 新开和移动都不改变该列的展开状态([语义](../../packages/client/ui-sidebar-right/README.zh.md#ctxsidebarright))。 | | `kind`(仅 `openResource`) | 点名打开类型而不排候选;该 kind 的生效实现打开地址,它的 `canOpen` 仍生效。 | | `params` | 给正文的导航参数,作为 `navigation.params` 送达。`openResource` 按资源类型经声明合并表 `SidebarRightResourceParamsMap` 定型(文本预览声明 `{ line?: number }`);`openTab` 按 kind 经 `SidebarRightTabParamsMap` 定型,未声明的 kind 为 `undefined`;正文读到的是二者联合 `SidebarRightNavigationParams`。值按约定为 JSON 形状,运行时不校验。 | -落位是调用方的选项,从不是类型的属性。会话区调 `openResource(fileAddressFor(sessionId, cwd, path))`,`read` 工具行另加 `{ params: { line } }`(来自调用的 1 起 `offset`);引导页入口框调 `tab.actions.openTab(entry.kind, { replaceTab: true })`;文件树的行调 `tab.actions.openResource(address)`;tab 条的新增控件调 `openTab('guide', { paneId, revealIfOpened: false })`。 +落位属于调用方,不是 tab 类型的属性。[文件导航栏参考](../../packages/client/ui-sidebar-files/README.zh.md#the-navigator)定义其宿主提供的打开回调;[引导页参考](../../packages/client/ui-sidebar-right/README.zh.md#the-guide)定义引导页替换与显式关闭。 `close(tabId)` 关闭一个 tab;`hide(tabId)` 关闭浮动 tab 或收起其停靠列;`active()` 返回活动 pane 的活动 tab;`isExpanded()` 与 `toggleExpanded()` 读取与翻转这一列,翻转记入序列。无会话时读操作返回 `undefined` 或 `false`;写操作需要已挂载的会话面,没有时抛错而不是写进没人绘制的面。 -`focus(tabId)` 让一个 tab 成为其 pane 的活动 tab;`split(paneId?)` 分割活动的停靠 pane 或点名的 pane,返回新 pane 的 id——pane 数预算或列宽不允许时返回 `undefined` 且不记账;`float(tabId, rect?)` 把一个 tab 浮出为浮窗 pane;`dock(paneId)` 把浮窗 pane 收回停靠区。四者都走 store 既有动作、各记一条历史;目标不存在或已处于目标状态时是空操作,与 `open` 一样在没有已挂载会话面时抛错。`TabId`、`PaneId`、`TabRecord`、`FloatRect` 自本包 `/client` 入口再导出,调用方无需引 dockkit。 +`focus(tabId)` 让一个 tab 成为其 pane 的活动 tab;`split(paneId?)` 分割活动的停靠 pane 或点名的 pane,返回新 pane 的 id——pane 数预算或列宽不允许时返回 `undefined` 且不记账;`float(tabId, rect?)` 把一个 tab 浮出为浮窗 pane;`dock(paneId)` 把浮窗 pane 收回停靠区并展开该列。四者都走 store 既有动作、各记一条历史;目标不存在或已处于目标状态时是空操作,与 `open` 一样在没有已挂载会话面时抛错。`TabId`、`PaneId`、`TabRecord`、`FloatRect` 自本包 `/client` 入口再导出,调用方无需引 dockkit。 `focusedTarget(element?)` 从实时 DOM 焦点(包括内嵌 iframe)捕获可见页面;侧栏外部或过期的侧栏标记不产生目标。`commandTarget(element?)` 还允许侧栏外部发起的打开操作使用已挂载 Session 的活动停靠窗格。`SidebarRightTarget` 捕获 Session、窗格、host、tab occurrence 和导航 revision。`isTargetCurrent(target)` 在执行前检查这些身份,因此重新打开记录或再次导航都会使已捕获的动作失效;它不会改为操作其他页面。关闭与落位结果见[控制器方法](../../packages/client/ui-sidebar-right/src/client/service.ts)。 @@ -106,9 +107,9 @@ Sidebar 声明四个扩展 slot;其文档 tab 另行声明下表中的 keyed | `sidebar.right.tab.menu.item` | list,会话作用域 | 追加在 kit 自身布局动作之后的内容级动作。执行了动作的条目必须调用 owner 的 `dismiss()`。 | | `sidebar.right.tab.document` | 按文档实现的 `id` keyed,会话作用域 | 文档 tab 内选中的文件渲染器;父组件拥有共享加载与工具栏控件。 | -正文、标题与引导页替换项接收框架注入的 `useTabInfo()`。它返回 `{ sidebar, panel, tab }`:`sidebar` 包含 `expanded` 与 `fullscreen`,`panel.id` 标识所属窗格,`tab` 包含记录字段以及 `visible`、`navigation`、`signal` 和 `actions`。所有 tab 都要求 Session 位于前台。停靠正文还要求展开且选中;停靠标题要求展开;前台浮窗不随整栏收起。`signal` 在记录消失或插件卸载时中止,不因隐藏或切换 Session 而中止。`tab.actions` 提供绑定到标签所属 Session 的 `openResource`、`openTab` 与 `close`。打开位置缺省为当前所属窗格;`revealIfOpened` 缺省为 `true`,`replaceTab: true` 在同一历史项中替换本记录。菜单项保留普通的 `tab` 与 `dismiss` owner 参数。 +正文、标题与引导页替换项接收框架注入的 `useTabInfo()`。它返回 `{ sidebar, panel, tab }`:`sidebar` 包含 `expanded` 与 `fullscreen`,`panel.id` 标识所属窗格,必填的 `panel.isFloating: boolean` 区分浮窗与停靠窗格。tab 移动时,两者通过 hook 更新,`tab.id` 保持不变。`tab` 还包含其他记录字段以及 `visible`、`navigation`、`signal` 和 `actions`。所有 tab 都要求 Session 位于前台。停靠正文还要求展开且选中;停靠标题要求展开;前台浮窗不随整栏收起。`signal` 在记录消失或插件卸载时中止,不因隐藏或切换 Session 而中止。`tab.actions` 提供绑定到标签所属 Session 的 `openResource`、`openTab` 与 `close`。打开操作返回 `void`,缺省落在当前所属停靠窗格,当前 tab 浮动时落在活动停靠窗格;`revealIfOpened` 缺省为 `true`,`replaceTab: true` 让新内容在同一历史项中替换本记录;带 `destination` 的 `openResource` 既不传本 tab 所在窗格,也不传本记录。菜单项保留普通的 `tab` 与 `dismiss` owner 参数。 -`navigation.revision` 在每次导航到该 tab 时递增,`params` 不变也递增,正文可仅凭「又被导航了」行动;按地址打开的 tab 为 `1`,没有人按地址打开的记录——种入的引导、撤销恢复的 tab——为 `0`。Tab 域为每条打开的记录保有一个 occurrence:记录出现即在资源模型里钉住,因此切换 tab 卸载正文也不丢内容;记录消失即中止并丢弃;撤销恢复的记录是新的 occurrence([Tab 域](../../packages/client/ui-sidebar-right/README.zh.md#the-tab-domain))。 +`navigation.revision` 在每次导航到该 tab 时递增,`params` 不变也递增,正文可仅凭「又被导航了」行动;按地址打开的 tab 为 `1`,没有人按地址打开的记录——种入的引导、刷新或撤销恢复的 tab——为 `0`。Tab 域为每条打开的记录保有一个 occurrence:记录出现即在资源模型里钉住,因此切换 tab 卸载正文也不丢内容;记录消失即中止并丢弃;撤销恢复的记录是新的 occurrence([Tab 域](../../packages/client/ui-sidebar-right/README.zh.md#the-tab-domain))。 `tab.actions.bindCommands(commands)` 为已挂载正文提供 `SidebarRightTabCommands`,目前包含可选的 `refresh` 回调。省略的操作不可用;返回的 disposer 释放本次注册,不会移除较新正文的命令。标签生命周期结束时也会释放这些命令。`tab.refreshShortcut` 为页面控件提供生效的刷新目录项;没有可用条目时该字段可省略。这些字段声明在 [slot owner 类型](../../packages/client/ui-sidebar-right/src/client/contract/slots.ts)中。 @@ -141,15 +142,15 @@ Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名 ## 内置类型 - **`guide`**——`builtin`,以 `openTab('guide')` 打开。一枚弱化的罗盘位于各类型按 `order` 贡献的入口胶囊上方;入口较少时显示已注册的描述,未提供图标的入口统一使用内置占位符。点选胶囊即在引导 tab 的位置把贡献它的类型作为页面打开。每个 pane 最多一个引导 tab,tab 条的新增控件只在本 pane 没有引导时出现。新 pane 使用已注册的默认页:只有一个引导入口时直接使用该入口,否则使用引导页([引导](../../packages/client/ui-sidebar-right/README.zh.md#the-guide))。 -- **`text`**——`fallback`,`dsh-resource://file/**`,只认领 Session 地址。Document Preview 通过 `useResource<'file'>` 观察元数据,经 Remote 回调加载内容,并拥有渲染器选择、工具栏、逐 tab 刷新、滚动与源码定位;未知扩展名按纯文本渲染([README](../../packages/client/ui-sidebar-documentpreview/README.zh.md))。 -- **`files`**——`builtin`,以 `openTab('files')` 打开。工作区目录树,经 `list` 懒加载,按展开目录建立监听,用 `tab.actions.openResource(fileAddressFor(sessionId, root, path))` 在自己所在 pane 打开文件([README](../../packages/client/ui-sidebar-files/README.zh.md))。 +- **`text`**——`fallback`,`dsh-resource://file/**`,只认领 Session 地址。Document Preview 通过 `useResource<'file'>` 观察元数据,经 Remote 回调加载内容,并拥有渲染器选择、工具栏、逐 tab 刷新、滚动与源码定位;未知扩展名按纯文本渲染。它在停靠与浮动窗格中直接渲染 files 插件的 `sidebar.files.navigator` 与 `sidebar.files.navigator.toggle` 两个 Factory;未注册时不绘制内容([README](../../packages/client/ui-sidebar-documentpreview/README.zh.md))。 +- **`files`**——`builtin`,以 `openTab('files')` 打开。文件页与导航栏共享每个分栏的一棵工作区目录树。[包参考](../../packages/client/ui-sidebar-files/README.zh.md)定义懒加载目录列表、监听、打开回调及导航驱动的可见性。 - **`browser`**——可多开的 `builtin`,以 `openTab('browser', { params: { url? } })` 打开。Chat 的[链接偏好](../../packages/client/ui-chat/README.zh.md)决定 Assistant Markdown 是否在此打开 HTTP(S) 链接。它在默认 sandbox 下接受公共与 loopback HTTP(S) 目标,本地文件改用 Document Preview,并使用应用已知的 iframe history([README](../../packages/client/ui-sidebar-browser/README.zh.md))。 - **`subagentchat`**——`builtin`,`dsh-resource://subagentchat/session/?parent=&mode=`。资源提供方保留一个显式寻址的 subagent Conversation,并通过共享 Conversation Factory 渲染([README](../../packages/client/ui-subagent/README.zh.md))。 ## 不做 -- 持久化:布局状态只在内存里;刷新后每个会话从折叠开始,任何会话的 tab 都不会出现在另一个会话里。 +- 跨设备布局同步:布局保存在浏览器本地,各 Session 独立保存状态([状态](../../packages/client/ui-sidebar-right/README.zh.md#state))。 - `ctx.sidebarRight` 上的只读布局快照或订阅:服务只暴露操作,dockkit 的 `LayoutState`/`LayoutOp` 是内部的。 - 服务上的能力探测数组(`features`)。 - tab 类型的 `option` 优先级档:没有「只列出、不许认领」的 tab 类型。 diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 4ec2000650..301bb6ffba 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1eb35b015a6c423f zh: a8a3dceee2c9e943 /client-web-gui-browser-half/packages: - en: faf349da96142e7c - zh: 2565c9785b010940 + en: d988c73b3bf44a97 + zh: ba5bab5652214c66 /client-web-gui-browser-half/related-documentation: en: d1d506e68fab7333 zh: 1a90fe6f3f8f24a9 diff --git a/packages/client/README.md b/packages/client/README.md index 22e971cd88..cfdd7f44ed 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -49,7 +49,7 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`ui-sidebar-documentpreview/`](ui-sidebar-documentpreview/README.md) | Displays documents in right Sidebar tabs | `ctx.documentPreviews` | | [`ui-sidebar-browser/`](ui-sidebar-browser/README.md) | Browses sandboxed HTTP(S) pages, including loopback services, in right-Sidebar tabs | — | | [`resources/`](resources/README.md) | Unified resource model: protocol providers behind the `useResource` session standard hook | `ctx.resources` | -| [`ui-sidebar-files/`](ui-sidebar-files/README.md) | Right-Sidebar workspace file tree tab type | — | +| [`ui-sidebar-files/`](ui-sidebar-files/README.md) | The workspace files page and one workspace tree per pane beside docked and floating previews | — | | [`ui-brand-official/`](ui-brand-official/README.md) | Fills the generic browser-brand slots with the official name and marks | — | | [`ui-workspace/`](ui-workspace/README.md) | Provides workspace selection and creation surfaces | — | | [`ui-conversation/`](ui-conversation/README.md) | Presents the active conversation and its input surface | — | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 92fcb266a5..9228b50257 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -49,7 +49,7 @@ kind: "package-group" | [`ui-sidebar-documentpreview/`](ui-sidebar-documentpreview/README.zh.md) | 在右侧 Sidebar 的 tab 中显示文档 | `ctx.documentPreviews` | | [`ui-sidebar-browser/`](ui-sidebar-browser/README.zh.md) | 在右侧 Sidebar tab 中浏览 sandboxed HTTP(S) 页面,包括 loopback 服务 | — | | [`resources/`](resources/README.zh.md) | 统一资源模型:`useResource` 会话标准钩子背后的协议提供方 | `ctx.resources` | -| [`ui-sidebar-files/`](ui-sidebar-files/README.zh.md) | 右侧 Sidebar 的工作区文件树 tab 类型 | — | +| [`ui-sidebar-files/`](ui-sidebar-files/README.zh.md) | 工作区文件页,以及停靠与浮动文件预览旁每个分栏一棵的工作区目录树 | — | | [`ui-brand-official/`](ui-brand-official/README.zh.md) | 用官方名称与标记填充通用浏览器品牌 slot | — | | [`ui-workspace/`](ui-workspace/README.zh.md) | 提供工作区选择与创建界面 | — | | [`ui-conversation/`](ui-conversation/README.zh.md) | 展示当前对话及其输入界面 | — | diff --git a/packages/client/modules/README.i18n.yaml b/packages/client/modules/README.i18n.yaml index eb264da002..8f9a9d597f 100644 --- a/packages/client/modules/README.i18n.yaml +++ b/packages/client/modules/README.i18n.yaml @@ -48,8 +48,8 @@ en: 6d16b74acb333e3b zh: fcb3a20cc8c39e4a /deepseek-ai-dsh-client-modules/understand-the-implementation/incremental-composition: - en: d2c42ee89ca84f7b - zh: 70ee9dd5e6aa5327 + en: 99483432e6390ca4 + zh: f92c018273aa07e4 /deepseek-ai-dsh-client-modules/understand-the-implementation/boot-manifest-injection: en: 253d9f265cd83ed0 zh: 9d0f8df84d9d3ac9 diff --git a/packages/client/modules/README.md b/packages/client/modules/README.md index 6048a73939..a27f4271fd 100644 --- a/packages/client/modules/README.md +++ b/packages/client/modules/README.md @@ -78,7 +78,9 @@ Executing a plugin bundle only registers its factory; every module-body side eff ### Incremental composition -The Node half scans incrementally per package — no full-rescan path. Every `internal/plugin` emission marks the fiber's entry name dirty; a microtask flush reconciles each dirty name against the live loader entries, and the activation pass seeds the same dirty set and flushes synchronously, so first scan and steady state share one implementation. Each reconciliation reads the package manifest and the bundle metadata again, so an entry that restarts after its package was reinstalled, upgraded, or moved publishes its current `dsh.client` declaration and client export; a reconciliation that finds the same manifest and bundle metadata publishes nothing and reads no bundle bytes, and an unchanged bundle keeps its revision, so the browser keeps the loaded plugin. The resolved manifest package name identifies the browser module. Distinct active Loader sources resolving to one package name are rejected; removing the conflict promotes the remaining source without requiring its fiber to restart. A bundle rebuilt without an entry restart reaches the graph through `rebuilt()` (the HMR hook). +The Node half reconciles packages by Loader entry name. Every `internal/plugin` emission marks the fiber's entry name dirty. Each nonempty flush, including the synchronous activation flush, traverses all Loader entries once to build a temporary index by entry name, then looks up the pending names for reconciliation. Package resolution must not synchronously add or remove Loader entries during that reconciliation. A pending name absent from the index is reconciled with an empty entry list to remove unloaded sources; all same-name entries remain available for source deduplication and conflict checks. The activation pass seeds the same pending names and flushes synchronously, so initial activation and steady state share one implementation. Each reconciliation reads the package manifest and the bundle metadata again, so an entry that restarts after its package was reinstalled, upgraded, or moved publishes its current `dsh.client` declaration and client export; a reconciliation that finds the same manifest and bundle metadata publishes nothing and reads no bundle bytes, and an unchanged bundle keeps its revision, so the browser keeps the loaded plugin. The resolved manifest package name identifies the browser module. Distinct active Loader sources resolving to one package name are rejected; removing the conflict promotes the remaining source without requiring its fiber to restart. A bundle rebuilt without an entry restart reaches the graph through `rebuilt()` (the HMR hook). + +If Loader enumeration fails during a flush, the registry consumes the failed batch without changing package state or the published graph. Activation reports the failure through its composition error; steady state logs a warning. Another entry notification is required to retry that name. The Node half snapshots each `client.js` entry before publication and creates combo descriptors without building response bodies. It groups resources into combo route keys (`/plugins/??...&rev=...`), with one bootstrap combo for the modules row and one or more application combos for the other rows; each phase is partitioned before a URL exceeds 3 KiB. The graph and batch descriptors carry the app-directory-relative form of the same key (`plugins/??...&rev=...`), which a browser document resolves under its own mount, while the response table stays keyed by the absolute route; the source-map trailer is resolved against the combo script's own directory. A script body is combined once on its first `GET` and ends with its map reference. The corresponding map files are read, validated, and combined separately on the first map `GET`; `HEAD` materializes neither body. The Host does not scan or preload sibling chunks: an exact `/plugins//client..js?rev=` request reads that script through the asynchronous file API, so a filesystem that materializes files on first asynchronous read can supply it, and caches it; its map remains uncomputed until the map URL is requested. The chunk reference the browser derives from its owner row is document-relative as well (`plugins//client..js?rev=`), and a chunk script's map trailer is the bare `client..js.map?rev=` name, which resolves against the script's own directory. Every combo or chunk map is Indexed Source Map v3 and uses an authored section when available or an identity section for the packaged bundle. Initial publication and HMR derive per-plugin revisions from the entry's `mtimeMs`, `ctimeMs`, and size, without hashing artifact contents. Status-change time distinguishes rewrites that preserve mtime and size. Unchanged artifacts therefore retain their revisions across Host restarts, so SSE reconnection does not replace their browser plugins. The shared preset stamps `client.js` after every package output is written, so a chunk-only rebuild changes the owner revision without a Host-side chunk scan. Combo revisions derive from the ordered row revisions. Advertised combo responses and requested chunk responses remain immutable across unrelated graph recomposition, and an unknown resource or revision returns 404. diff --git a/packages/client/modules/README.zh.md b/packages/client/modules/README.zh.md index 1d97be97de..ab6fe609b3 100644 --- a/packages/client/modules/README.zh.md +++ b/packages/client/modules/README.zh.md @@ -79,7 +79,9 @@ application combo 脚本只携带每个插件的 `client.js` 入口,并在启 ### 增量组合 -Node 半侧逐包增量扫描——没有全量重扫路径。每次发出 `internal/plugin` 事件时,系统都会把该 fiber 的 entry 名标脏;微任务 flush 会把每个脏名与当前 loader 条目对账,激活 pass 会初始化同一个脏集合并同步 flush,因此首次扫描与稳态共用同一实现。每次对账都会重新读取包 manifest 与 bundle 元数据,因此包在重新安装、升级或移动之后重启的条目会发布其当前的 `dsh.client` 声明与客户端导出;读到相同 manifest 与 bundle 元数据的对账不发布任何内容,也不读取 bundle 字节;未变化的 bundle 保留其 revision,因此浏览器会保留已加载的插件。解析出的 manifest 包名作为浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;移除冲突来源后,剩余来源无需重启 fiber 即可接替。没有伴随条目重启的 bundle 重建经 `rebuilt()`(HMR 钩子)进入图。 +Node 半侧按 Loader entry 名对包进行对账。每次发出 `internal/plugin` 事件时,系统都会把该 fiber 的 entry 名标脏。每次非空 flush(包括同步执行的激活 flush)遍历全部 Loader entries 一次,按 entry 名构建临时索引,再查询待处理名称进行对账。包解析过程不得在对账期间同步添加或移除 Loader entries。待处理名称若不在索引中,则以空 entry 列表对账,移除已卸载的来源;所有同名 entry 都保留,用于来源去重与冲突检查。激活 pass 初始化同一组待处理名称并同步 flush,因此首次激活与稳态共用同一实现。每次对账都会重新读取包 manifest 与 bundle 元数据,因此包在重新安装、升级或移动之后重启的条目会发布其当前的 `dsh.client` 声明与客户端导出;读到相同 manifest 与 bundle 元数据的对账不发布任何内容,也不读取 bundle 字节;未变化的 bundle 保留其 revision,因此浏览器会保留已加载的插件。解析出的 manifest 包名作为浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;移除冲突来源后,剩余来源无需重启 fiber 即可接替。没有伴随条目重启的 bundle 重建经 `rebuilt()`(HMR 钩子)进入图。 + +如果 flush 期间 Loader 枚举失败,注册表会消费失败批次,但不修改包状态或已发布的图。激活阶段通过组合错误报告失败;稳态下记录警告。只有收到该名称的另一次条目通知后,才会重试。 Node 半侧会在发布前快照每个 `client.js` 入口,并在不构建响应 body 的情况下创建 combo descriptor。它把资源分组为 combo 路由键(`/plugins/??...&rev=...`):modules row 使用一个 bootstrap combo,其余 row 使用一个或多个 application combo;每个阶段都会在 URL 超过 3 KiB 之前分区。启动图与批次描述符携带同一路由键的应用目录相对形式(`plugins/??...&rev=...`),浏览器文档会按其自身挂载解析它,而响应表仍以绝对路由为键;source-map trailer 则相对该 combo 脚本自身目录解析。脚本 body 在首次 `GET` 时只组合一次,并以对应的 map 引用结尾;map 文件则在首次 map `GET` 时单独读取、校验并组合,`HEAD` 不会物化任一 body。Host 不扫描也不预加载同级 chunk:精确的 `/plugins//client..js?rev=` 请求会通过异步文件 API 读取该脚本并缓存,因此首次异步读取时才物化文件的文件系统也能提供它;其 map 仍会等到 map URL 被请求后才计算。浏览器从所属 row 派生出该 chunk 引用,因此它同样是文档相对形式(`plugins//client..js?rev=`);chunk 脚本携带的 map trailer 则是裸文件名 `client..js.map?rev=`,由该脚本自身目录解析。每个 combo 或 chunk map 都是 Indexed Source Map v3,并在可用时使用作者提供的 section,否则为已打包 bundle 生成 identity section。首次发布与 HMR 都从入口的 `mtimeMs`、`ctimeMs` 和大小派生逐插件 revision,不对产物内容求哈希。状态变更时间用于区分保留 mtime 和大小的重写。未变化的产物因此会跨 Host 重启保持 revision,SSE 重连不会替换其浏览器插件。共享预设会在该包所有输出写完后标记 `client.js`,因此仅 chunk 发生重建也会更换 owner revision,无需 Host 扫描 chunk。combo revision 从有序 row revision 派生。已公告的 combo 响应与已请求的 chunk 响应会跨无关图重组保持不可变;未知资源或 revision 返回 404。 diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts index c87f28d4b8..2bfda74d5f 100644 --- a/packages/client/modules/src/index.ts +++ b/packages/client/modules/src/index.ts @@ -10,17 +10,19 @@ * `clientModules` service (the HMR node half's registration/notification * face). * - * Scanning is incremental per package — there is no full-rescan code path. + * Package reconciliation is incremental by Loader entry name. * Every cordis `internal/plugin` emission (fiber construction/disposal) marks * the fiber's entry name dirty; a microtask flush reconciles each dirty name - * against the live loader entries. The activation pass seeds the same dirty - * set with all current entries and flushes synchronously, so first scan and - * steady state share one implementation. Each reconciliation reads the - * package manifest and the bundle metadata again, so an entry that restarts - * after its package was reinstalled or moved publishes them as they are now; - * an unchanged result publishes nothing and does not read the bundle. The - * manifest package name identifies the browser module; distinct active Loader - * sources for that package are a composition error. Bundle rebuilds outside + * against the Loader entries indexed at the start of that flush. Synchronous + * package resolution must not add or remove Loader entries during reconciliation. + * The activation pass seeds the same pending names and flushes synchronously, + * so initial activation and steady state share one implementation. Each + * reconciliation reads the package manifest and the bundle metadata again, + * so an entry that restarts after its package was reinstalled or moved + * publishes them as they are now; an unchanged result publishes nothing and + * does not read the bundle. The manifest package name identifies the browser + * module; distinct active Loader sources for that package are a composition + * error. Bundle rebuilds outside * an entry restart reach the graph through {@link ClientModuleRegistry.rebuilt}. * @module @deepseek-ai/dsh-client-modules */ @@ -1031,11 +1033,17 @@ export class ClientModuleRegistry extends Service { } } - /** Reconcile one entry name against the live Loader sources. @returns whether the table changed. */ - private processOne(entryName: string, onError: (err: Error) => void): boolean { + /** + * Reconcile one entry name against its sources at flush start. + * @param entryName - Loader entry name being reconciled. + * @param entries - same-name entries collected at flush start. + * @param onError - receives package reconciliation failures. + * @returns whether the table changed. + */ + private processOne(entryName: string, entries: readonly Entry[], onError: (err: Error) => void): boolean { const nextSources = new Map() - for (const entry of this.ctx.loader.entries()) { - if (entry.options.name !== entryName || entry.fiber === undefined || entry.disabled) continue + for (const entry of entries) { + if (entry.fiber === undefined || entry.disabled) continue const source = this.resolveSource(entry) if (source !== undefined) nextSources.set(source.sourceKey, source) } @@ -1108,11 +1116,26 @@ export class ClientModuleRegistry extends Service { } private flush(onError: (err: Error) => void): void { + if (this.dirty.size === 0) return + const entriesByName = new Map() + try { + for (const entry of this.ctx.loader.entries()) { + const name = entry.options.name + const entries = entriesByName.get(name) + if (entries) entries.push(entry) + else entriesByName.set(name, [entry]) + } + } catch (error) { + // No package state changed; consume this failed batch and retain the published graph. + this.dirty.clear() + onError(error instanceof Error ? error : new Error(String(error))) + return + } let changed = false for (const entryName of [...this.dirty]) { this.dirty.delete(entryName) try { - if (this.processOne(entryName, onError)) changed = true + if (this.processOne(entryName, entriesByName.get(entryName) ?? [], onError)) changed = true } catch (error) { // Steady state: one broken package must not poison the others; the // activation pass aggregates these into a loud throw instead. diff --git a/packages/client/modules/tests/node-half.client.spec.ts b/packages/client/modules/tests/node-half.client.spec.ts index 283383149c..8ef1d6c89a 100644 --- a/packages/client/modules/tests/node-half.client.spec.ts +++ b/packages/client/modules/tests/node-half.client.spec.ts @@ -164,6 +164,7 @@ function constructWithRoute( contextBaseUrl?: string entryBaseUrl?: string internal?: NonNullable + onEntries?: () => void } = {}, ): { context: Context; service: ClientModuleRegistry; route: Promise } { const ctx = new Context() @@ -173,6 +174,7 @@ function constructWithRoute( ctx.provide('loader', { internal: options.internal, *entries() { + options.onEntries?.() for (const packageName of packageNames) { yield { options: { name: packageName }, @@ -329,6 +331,148 @@ describe('HTML bootstrap facade', () => { }) describe('client bundle activation', () => { + it('enumerates Loader entries once per dirty-name batch and skips empty batches', async () => { + const names = ['@fixture/scan-first', '@fixture/scan-second', '@fixture/scan-retained'] + for (const name of names) writeBuiltPackage(name, {}) + const scans = vi.fn() + const { context, service } = constructWithRoute(names, { onEntries: scans }) + + expect(scans).toHaveBeenCalledTimes(2) + expect(service.graph().entries).toHaveLength(3) + scans.mockClear() + + const removed = names.splice(0, 2) + for (const name of [...removed, removed[0]!]) emitLoaderEntryChange(context, name) + await Promise.resolve() + + expect(scans).toHaveBeenCalledTimes(1) + expect(service.graph().entries.map(entry => entry.id)).toEqual(names) + for (const name of removed) expect(service.clientPath(name)).toBeUndefined() + + scans.mockClear() + context.emit('internal/plugin', context.fiber) + await Promise.resolve() + expect(scans).not.toHaveBeenCalled() + + const emptyScans = vi.fn() + const empty = constructWithRoute([], { onEntries: emptyScans }) + expect(empty.service.graph().entries).toEqual([]) + expect(emptyScans).toHaveBeenCalledTimes(1) + }) + + it('keeps a shared source until its last same-name Loader entry unloads', async () => { + const name = '@fixture/shared-source-owners' + writeBuiltPackage(name, {}) + const names = [name, name] + const { context, service } = constructWithRoute(names) + const initial = service.graph() + + names.pop() + emitLoaderEntryChange(context, name) + await Promise.resolve() + expect(service.graph()).toBe(initial) + expect(service.clientPath(name)).toBeDefined() + + names.pop() + emitLoaderEntryChange(context, name) + await Promise.resolve() + expect(service.graph().entries).toEqual([]) + expect(service.clientPath(name)).toBeUndefined() + }) + + it('reports an activation flush enumeration failure as a composition error', () => { + const name = '@fixture/failed-activation-scan' + writeBuiltPackage(name, {}) + const scans = vi.fn() + .mockImplementationOnce(() => undefined) + .mockImplementationOnce(() => { throw new Error('Loader enumeration failed') }) + expect(() => constructWithRoute([name], { onEntries: scans })).toThrow(AggregateError) + }) + + it.each([new Error('Loader enumeration failed'), 'Loader enumeration failed'])( + 'retains the graph and consumes the failed batch when Loader enumeration throws %s', + async (failure) => { + const names = ['@fixture/failed-scan-first', '@fixture/failed-scan-second', '@fixture/failed-scan-retained'] + for (const name of names) writeBuiltPackage(name, {}) + const scans = vi.fn() + const { context, service, route } = constructWithRoute(names, { onEntries: scans }) + await route + const initial = service.graph() + const warning = vi.spyOn(context.logger, 'warn').mockImplementation(() => undefined) + const removed = names.splice(0, 2) + scans.mockImplementationOnce(() => { throw failure }) + const callbacks: VoidFunction[] = [] + const enqueue = vi.spyOn(globalThis, 'queueMicrotask').mockImplementation(callback => callbacks.push(callback)) + try { + for (const name of removed) emitLoaderEntryChange(context, name) + expect(callbacks).toHaveLength(1) + expect(callbacks[0]!).not.toThrow() + } finally { + enqueue.mockRestore() + } + expect(warning).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ message: 'Loader enumeration failed' })) + expect(service.graph()).toBe(initial) + for (const name of removed) expect(service.clientPath(name)).toBeDefined() + + emitLoaderEntryChange(context, names[0]!) + await Promise.resolve() + expect(service.graph()).toBe(initial) + + for (const name of removed) emitLoaderEntryChange(context, name) + await Promise.resolve() + expect(service.graph().entries.map(entry => entry.id)).toEqual(names) + for (const name of removed) expect(service.clientPath(name)).toBeUndefined() + }, + ) + + it.each(['processing', 'processed'] as const)('consumes pending names once when a %s name is marked again', async (timing) => { + const firstName = '@fixture/reentrant-first' + const secondName = '@fixture/reentrant-second' + const thirdName = '@fixture/reentrant-third' + const names = [firstName, secondName, thirdName] + const hostUrls = new Map() + for (const name of names) { + writeBuiltPackage(name, {}) + const hostPath = join(root!, 'node_modules', ...name.split('/'), 'lib', 'index.js') + writeFileSync(hostPath, 'export default {}\n') + hostUrls.set(name, pathToFileURL(hostPath).href) + } + const resolved: string[] = [] + let duringResolve = (_name: string) => {} + const internal = { + version: 'v2' as const, + resolveSync: (_baseUrl: string, { specifier }: { specifier: string }) => { + resolved.push(specifier) + duringResolve(specifier) + return { format: 'module' as const, url: hostUrls.get(specifier)! } + }, + } + const { context, service } = constructWithRoute(names, { + internal: internal as NonNullable, + }) + const initial = service.graph() + resolved.length = 0 + duringResolve = (name) => { + if (name === firstName) { + emitLoaderEntryChange(context, secondName) + emitLoaderEntryChange(context, thirdName) + if (timing === 'processing') emitLoaderEntryChange(context, firstName) + } else if (name === secondName) { + if (timing === 'processed') emitLoaderEntryChange(context, firstName) + duringResolve = () => {} + } + } + + emitLoaderEntryChange(context, firstName) + emitLoaderEntryChange(context, secondName) + await Promise.resolve() + expect(resolved.splice(0)).toEqual([firstName, secondName]) + + await Promise.resolve() + expect(resolved).toEqual([thirdName, firstName]) + expect(service.graph()).toBe(initial) + }) + it.each(['v1', 'v2'] as const)( 'resolves %s package metadata from the owning entry tree', (version) => { diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index ea9a48c09a..5b652c5425 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -18,8 +18,8 @@ en: 9386791a9e5efa00 zh: 7cb9f3bbc42319d6 /deepseek-ai-dsh-client-ui-chat/hidden-chat-rows: - en: cbf1f0a2205046a5 - zh: 2e59d0815351349f + en: b0c4e6711d69d2f8 + zh: 09add0ea1a243922 /deepseek-ai-dsh-client-ui-chat/command-and-failure-rows: en: be90c24392fb021b zh: 992b8745b385f9ab diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 99b01c5566..56b30d271b 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -58,6 +58,8 @@ Chat omits system-prompt, ordinary Context injection, and `permission` command r When an Assistant attempt retires without a visible message, Chat hides its already-published Node instead of removing its key. A retry in the same Step reuses that key when visible content returns. This also applies when the loaded window lacks the Step start. +Text and reasoning contribute to Assistant visibility only when they contain a non-whitespace character. Streaming appends reuse each block's visibility; block replacement and finalized messages recalculate it while preserving the original text. + ## Command and failure rows diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 35231f8e5e..1cc14f917a 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -58,6 +58,8 @@ Chat 在所有工作步骤展示模式下都不显示系统提示词行、普通 Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node,不移除其 key。同一 Step 的重试再次产生可见内容时,复用该 key。已加载窗口缺少 Step 起点时也遵循此规则。 +只有包含非空白字符的文本和推理块才计入 Assistant 的可见内容。流式追加复用每个块的可见状态;块替换和最终消息重新计算可见性,同时保留原始文本。 + ## 指令与失败行 diff --git a/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts b/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts index b7658a1bf5..6afb0efde8 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts @@ -30,6 +30,8 @@ interface AssistantState { readonly turn: number readonly step: number readonly blocks: readonly (AssistantBlock | undefined)[] + /** Missing indices are invisible; same-kind appends preserve already-visible blocks. */ + readonly visibleByIndex: readonly boolean[] readonly visibleBlocks: number readonly firstVisibleSeq: number | undefined readonly firstVisibleTime: number | undefined @@ -43,6 +45,7 @@ function initialState(turn: number, step: number): AssistantState { turn, step, blocks: [], + visibleByIndex: [], visibleBlocks: 0, firstVisibleSeq: undefined, firstVisibleTime: undefined, @@ -62,12 +65,6 @@ function blockIsVisible(block: AssistantBlock | undefined): boolean { return true } -function countVisibleBlocks(blocks: readonly AssistantBlock[]): number { - let count = 0 - for (const block of blocks) if (blockIsVisible(block)) count++ - return count -} - function hasVisibleContent(blocks: readonly AssistantBlock[]): boolean { return blocks.some(blockIsVisible) } @@ -101,30 +98,34 @@ function updateChunk( const blocks = [...state.blocks] let changedIndex = -1 let previousVisible = false + let nextVisible = false switch (chunk.type) { case 'block-start': changedIndex = chunk.index - previousVisible = blockIsVisible(blocks[chunk.index]) + previousVisible = state.visibleByIndex[chunk.index] === true blocks[chunk.index] = emptyAssistantBlock(chunk.blockType) + nextVisible = blockIsVisible(blocks[chunk.index]) break case 'text-delta': { const previous = blocks[chunk.index] changedIndex = chunk.index - previousVisible = blockIsVisible(previous) + previousVisible = state.visibleByIndex[chunk.index] === true blocks[chunk.index] = { kind: 'text', text: (previous?.kind === 'text' ? previous.text : '') + chunk.text } + nextVisible = (previous?.kind === 'text' && previousVisible) || chunk.text.trim() !== '' break } case 'reasoning-delta': { const previous = blocks[chunk.index] changedIndex = chunk.index - previousVisible = blockIsVisible(previous) + previousVisible = state.visibleByIndex[chunk.index] === true blocks[chunk.index] = { kind: 'reasoning', text: (previous?.kind === 'reasoning' ? previous.text : '') + chunk.text } + nextVisible = (previous?.kind === 'reasoning' && previousVisible) || chunk.text.trim() !== '' break } case 'tool-call-delta': { const previous = blocks[chunk.index] changedIndex = chunk.index - previousVisible = blockIsVisible(previous) + previousVisible = state.visibleByIndex[chunk.index] === true // Tool Nodes own streamed arguments; Assistant protocol blocks retain identity until full settlement. blocks[chunk.index] = { kind: 'tool-call', @@ -136,8 +137,9 @@ function updateChunk( } case 'block-end': changedIndex = chunk.index - previousVisible = blockIsVisible(blocks[chunk.index]) + previousVisible = state.visibleByIndex[chunk.index] === true blocks[chunk.index] = toAssistantBlock(chunk.block) + nextVisible = blockIsVisible(blocks[chunk.index]) break case 'usage': return { ...state, usage: chunk.usage } @@ -146,11 +148,18 @@ function updateChunk( } const visibleBlocks = state.visibleBlocks - Number(previousVisible) - + Number(blockIsVisible(blocks[changedIndex])) + + Number(nextVisible) + let visibleByIndex = state.visibleByIndex + if (previousVisible !== nextVisible) { + const next = [...visibleByIndex] + next[changedIndex] = nextVisible + visibleByIndex = next + } const firstToken = isTokenDelta(chunk) return { ...state, blocks, + visibleByIndex, visibleBlocks, ...visibleBlocks > 0 && state.firstVisibleSeq === undefined ? { firstVisibleSeq: seq, firstVisibleTime: time } @@ -167,10 +176,12 @@ function settleMessage( event: SessionEvent<'assistant/message'>, ): AssistantState { const blocks = toAssistantBlocks(event.data.message.content) + const visibleByIndex = blocks.map(blockIsVisible) return { ...state, blocks, - visibleBlocks: countVisibleBlocks(blocks), + visibleByIndex, + visibleBlocks: visibleByIndex.reduce((count, visible) => count + Number(visible), 0), final: match, usage: event.data.usage, } diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index e8bf15bddc..6a2871d0fb 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -13,7 +13,7 @@ import { import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import { inspectSystemPrompt } from '../../ui-conversation/src/client/contract/system-prompt.ts' import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm/assistant-stream' -import { LlmAttemptId } from '@deepseek-ai/dsh-llm/brand' +import { LlmAttemptId, ToolCallId } from '@deepseek-ai/dsh-llm/brand' import type { StreamChunk } from '@deepseek-ai/dsh-llm' import { hasAssistantReplyContent } from '../src/client/contract/assistant-content.ts' import { assistantDefinition } from '../src/client/conversation-nodes/assistant.ts' @@ -169,6 +169,17 @@ function node(value: ChatSnapshot, kind: string): ChatConversationViewNode | und return value.nodes.values().find(candidate => candidate.kind === kind) } +function appendAssistantChunk(value: ConversationNodeAssembler, seq: number, chunk: StreamChunk): void { + value.append({ + type: 'transient', + event: { + type: 'assistant/live-chunk', seq, time: 1_700_000_000_000 + seq, + data: { attemptId: LlmAttemptId('assistant-visibility'), turn: 1, step: 1, chunk }, + }, + }) + value.flush() +} + function promptNodes(value: ChatSnapshot): ChatConversationViewNode[] { const prompts = value.nodes.values().filter(candidate => candidate.kind === 'system-prompt') for (const prompt of prompts) expect(value.order).not.toContain(prompt.key) @@ -1128,6 +1139,94 @@ describe('built-in conversation node Definitions', () => { .toMatchObject({ answerAnchorSeq: null, answerStep: null }) }) + it.each(['text', 'reasoning'] as const)('retains %s whitespace and anchors the first visible delta', (kind) => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + ]) + const whitespace = '\t\n\v\f\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007' + + '\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff' + appendAssistantChunk(value, 3, { type: 'block-start', index: 0, blockType: kind }) + appendAssistantChunk(value, 4, { type: `${kind}-delta`, index: 0, text: whitespace }) + expect(node(snapshot(value), 'assistant-step')).toBeUndefined() + + appendAssistantChunk(value, 5, { type: `${kind}-delta`, index: 0, text: '\u200b' }) + const visible = node(snapshot(value), 'assistant-step') + expect(visible).toMatchObject({ anchorSeq: 5, visibility: 'visible', data: { time: 1_700_000_000_005 } }) + appendAssistantChunk(value, 6, { type: `${kind}-delta`, index: 0, text: `${whitespace}answer` }) + const content = `${whitespace}\u200b${whitespace}answer` + expect(node(snapshot(value), 'assistant-step')).toMatchObject({ + key: visible?.key, anchorSeq: 5, visibility: 'visible', + data: { blocks: [{ kind, text: content }] }, + }) + + value.append(at(7, 'assistant/message', { + turn: 1, step: 1, + message: { ...assistantMessage('whitespace-answer', ''), content: [{ type: kind, text: content }] }, + }, { surfaceOp: 'append' })) + value.flush() + const settled = node(snapshot(value), 'assistant-step')?.data as AssistantChatData + expect(settled.finalNode?.timing?.firstTokenTime).toBe(1_700_000_000_004) + expect(settled.blocks).toEqual([{ kind, text: content }]) + }) + + it.each(['text', 'reasoning'] as const)('updates visibility when a sparse Assistant block becomes %s', (kind) => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + ]) + appendAssistantChunk(value, 3, { type: 'text-delta', index: 0, text: 'answer' }) + const first = node(snapshot(value), 'assistant-step') + const previousKind = kind === 'text' ? 'reasoning' : 'text' + appendAssistantChunk(value, 4, { type: `${previousKind}-delta`, index: 2, text: 'thinking' }) + appendAssistantChunk(value, 5, { type: 'block-start', index: 0, blockType: 'text' }) + expect(node(snapshot(value), 'assistant-step')?.visibility).toBe('visible') + appendAssistantChunk(value, 6, { type: `${previousKind}-delta`, index: 2, text: ' ' }) + expect(node(snapshot(value), 'assistant-step')?.visibility).toBe('visible') + appendAssistantChunk(value, 7, { type: `${kind}-delta`, index: 2, text: ' ' }) + expect(node(snapshot(value), 'assistant-step')).toMatchObject({ + key: first?.key, anchorSeq: 3, visibility: 'hidden', + data: { blocks: [{ kind: 'text', text: '' }, { kind, text: ' ' }] }, + }) + appendAssistantChunk(value, 8, { type: 'block-end', index: 0, block: { type: 'text', text: 'replacement' } }) + expect(node(snapshot(value), 'assistant-step')?.visibility).toBe('visible') + appendAssistantChunk(value, 9, { type: 'block-end', index: 0, block: { type: 'text', text: ' ' } }) + expect(node(snapshot(value), 'assistant-step')?.visibility).toBe('hidden') + appendAssistantChunk(value, 10, { type: 'block-start', index: 0, blockType: 'image' }) + expect(node(snapshot(value), 'assistant-step')?.visibility).toBe('visible') + appendAssistantChunk(value, 11, { + type: 'tool-call-delta', index: 0, id: ToolCallId('visibility-tool'), name: 'read', argumentsDelta: '', + }) + expect(node(snapshot(value), 'assistant-step')?.visibility).toBe('hidden') + appendAssistantChunk(value, 12, { type: 'block-start', index: 0, blockType: 'image' }) + expect(node(snapshot(value), 'assistant-step')?.visibility).toBe('visible') + appendAssistantChunk(value, 13, { type: 'block-start', index: 0, blockType: 'tool-call' }) + expect(node(snapshot(value), 'assistant-step')).toMatchObject({ + key: first?.key, anchorSeq: 3, visibility: 'hidden', + }) + }) + + it('waits for visible content again after an Assistant retry', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + ]) + appendAssistantChunk(value, 3, { type: 'text-delta', index: 0, text: 'first attempt' }) + const first = node(snapshot(value), 'assistant-step') + value.append(at(4, 'llm/retry', { + retryId: 'visibility-retry', turn: 1, step: 1, provider: 'fake', mode: 'normal', + policyKey: 'fake-normal', retry: 1, maxRetries: 2, delayMs: 10, + failure: { code: 'TRANSPORT', message: 'temporary' }, + })) + appendAssistantChunk(value, 5, { type: 'reasoning-delta', index: 0, text: '\u00a0 ' }) + expect(node(snapshot(value), 'assistant-step')?.visibility).toBe('hidden') + appendAssistantChunk(value, 6, { type: 'reasoning-delta', index: 0, text: 'replacement' }) + expect(node(snapshot(value), 'assistant-step')).toMatchObject({ + key: first?.key, anchorSeq: 6, visibility: 'visible', + data: { time: 1_700_000_000_006, blocks: [{ kind: 'reasoning', text: '\u00a0 replacement' }] }, + }) + }) + it('keeps one keyed Assistant node while streaming settles and materializes interruption from Location', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), diff --git a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx index 3036b0e4b6..2fab3733b5 100644 --- a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx @@ -172,7 +172,7 @@ describe('ReviewTab', () => { const runtime = { renderSlot: renderFileActions, useTabInfo: () => ({ - sidebar: { expanded: true, fullscreen: false }, panel: { id: 'pane-1' }, + sidebar: { expanded: true, fullscreen: false }, panel: { id: 'pane-1', isFloating: false }, tab: { id: TAB, kind: 'changes-review', contentId: options.address ?? ADDRESS, title: 'Review', visible: true, navigation, signal: aborter.signal, actions: tabActions }, }), sessionId: SESSION, diff --git a/packages/client/ui-open-in-app/README.i18n.yaml b/packages/client/ui-open-in-app/README.i18n.yaml index 028691d184..16367c590b 100644 --- a/packages/client/ui-open-in-app/README.i18n.yaml +++ b/packages/client/ui-open-in-app/README.i18n.yaml @@ -18,8 +18,8 @@ en: 056e2bd9e3c78dd8 zh: be35d2ba53be0442 /deepseek-ai-dsh-client-ui-open-in-app/use-this-package/what-to-expect: - en: 6f3934925cfda953 - zh: 9e3126bce73cf344 + en: 51e6019f011f394e + zh: 818391262086ae10 /deepseek-ai-dsh-client-ui-open-in-app/understand-the-implementation: en: ec7a7c56aaeafce6 zh: 4cee0cd2cb7531df diff --git a/packages/client/ui-open-in-app/README.md b/packages/client/ui-open-in-app/README.md index 95d7be4a70..c2fb730478 100644 --- a/packages/client/ui-open-in-app/README.md +++ b/packages/client/ui-open-in-app/README.md @@ -45,6 +45,8 @@ Context menus contribute file preview, native application opening, original-file File menus copy absolute paths without line numbers; URL copying preserves query and fragment. Image files also expose image copy and original download, alongside the existing file actions. Successful downloads and saves show the shared Toast; picker cancellation is silent. +A file menu offers “Open in sidebar” and “Open in floating panel”, which call `ctx.sidebarRight.openResourceIn` with `destination: 'sidebar'` or `'float'` and the target's line, never the pane the menu was raised in. A file already open in the requested place is revealed; one open in the other place moves there with its reading position, so the menu can move a preview between the docked column and a floating panel. Left-click opens files as before. + ----- diff --git a/packages/client/ui-open-in-app/README.zh.md b/packages/client/ui-open-in-app/README.zh.md index 775efac9d8..04aa5edc1e 100644 --- a/packages/client/ui-open-in-app/README.zh.md +++ b/packages/client/ui-open-in-app/README.zh.md @@ -45,6 +45,8 @@ kind: "package-reference" 文件菜单复制不含行号的绝对路径;网址复制保留查询参数与锚点。图片文件在既有文件操作之外,还提供复制图片与下载原图。下载和保存成功后显示共享 Toast,取消选择器时不提示。 +文件菜单提供“在侧边栏打开”与“在浮窗中打开”,以 `destination: 'sidebar'` 或 `'float'` 和目标的行号调用 `ctx.sidebarRight.openResourceIn`,从不传菜单所在的分栏。文件已在请求的位置打开时揭示它;在另一位置打开时连同阅读位置移过去,因此菜单可以把预览在停靠列和浮窗之间移动。左键打开文件的行为不变。 + ----- diff --git a/packages/client/ui-open-in-app/src/client/context-menus.tsx b/packages/client/ui-open-in-app/src/client/context-menus.tsx index efaac77594..2f6fb14295 100644 --- a/packages/client/ui-open-in-app/src/client/context-menus.tsx +++ b/packages/client/ui-open-in-app/src/client/context-menus.tsx @@ -78,7 +78,7 @@ export function registerContextMenus( ctx.effect(() => ctx.contextMenus.register({ id: 'open-in-app.file', target: 'file', order: 100, resolve: async (target, signal) => { - const { sessionId, path, line, paneId } = target + const { sessionId, path, line } = target await paths.load() const absolutePath = await resolvePath(sessionId, path, signal) const available = absolutePath !== undefined @@ -86,14 +86,16 @@ export function registerContextMenus( const apps = desktop ? await paths.applications(absolutePath, signal) : [] signal.throwIfAborted() const application = apps?.find(app => app.default) + // The menu names a host, not the pane it was raised in: a floating pane is never a docked landing place. + const openIn = (destination: 'sidebar' | 'float') => () => { + const cwd = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd + ctx.sidebarRight.openResourceIn(sessionId, fileAddressFor(sessionId, cwd, path), { + destination, ...(line === undefined ? {} : { params: { line } }), + }) + } return [ - { id: 'sidebar', icon: { key: 'open-in-app.sidebar' }, label: t('menu.sidebar'), disabled: !available, - run: () => { - const cwd = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd - ctx.sidebarRight.openResourceIn(sessionId, fileAddressFor(sessionId, cwd, path), { - ...(paneId === undefined ? {} : { paneId }), ...(line === undefined ? {} : { params: { line } }), - }) - } }, + { id: 'sidebar', icon: { key: 'open-in-app.sidebar' }, label: t('menu.openInSidebar'), disabled: !available, run: openIn('sidebar') }, + { id: 'float', icon: { key: 'open-in-app.float' }, label: t('menu.openInFloat'), disabled: !available, run: openIn('float') }, { id: 'reveal', icon: { key: 'open-in-app.reveal' }, label: fileManager(), disabled: !desktop, ...absolutePath === undefined ? {} : { run: () => open(absolutePath, 'reveal') } }, { id: 'open', label: application ? t('open.title', { app: application.name }) : t('menu.defaultApp'), @@ -112,7 +114,7 @@ export function registerContextMenus( ctx.effect(() => ctx.contextMenus.register({ id: 'open-in-app.url', target: 'url', order: 100, resolve: ({ sessionId, url }) => [ - { id: 'sidebar', icon: { key: 'open-in-app.sidebar' }, label: t('menu.sidebar'), disabled: ctx.sidebarRightTabs.get('browser') === undefined, + { id: 'sidebar', icon: { key: 'open-in-app.sidebar' }, label: t('menu.openInSidebar'), disabled: ctx.sidebarRightTabs.get('browser') === undefined, run: () => { ctx.sidebarRight.openTabIn(sessionId, 'browser', { params: { url } }) } }, { id: 'external', label: t('menu.external'), run: () => { window.open(url, '_blank', 'noopener,noreferrer') } }, diff --git a/packages/client/ui-open-in-app/src/client/index.ts b/packages/client/ui-open-in-app/src/client/index.ts index 05ac1202ed..49e5fe25e4 100644 --- a/packages/client/ui-open-in-app/src/client/index.ts +++ b/packages/client/ui-open-in-app/src/client/index.ts @@ -1,4 +1,4 @@ -import { IconFolderOpenOutlineRegular, IconCopyOutlineRegular, IconPanelLeftOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconFolderOpenOutlineRegular, IconCopyOutlineRegular, IconPanelLeftOutlineRegular, IconRightUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import { ContextMenuApplicationIcon, registerContextMenus } from './context-menus.tsx' import type {} from '@deepseek-ai/dsh-client-ui-context-menu/client' /** @@ -65,7 +65,7 @@ export function apply(ctx: ClientContext): void { ctx.slots.inject('contextMenu.icon', function* () { for (const [key, Icon] of Object.entries({ 'open-in-app.reveal': IconFolderOpenOutlineRegular, 'open-in-app.copy': IconCopyOutlineRegular, - 'open-in-app.sidebar': IconPanelLeftOutlineRegular, + 'open-in-app.sidebar': IconPanelLeftOutlineRegular, 'open-in-app.float': IconRightUpOutlineRegular, })) yield ctx.slots.register({ name: 'contextMenu.icon', key }, Icon) yield ctx.slots.register({ name: 'contextMenu.icon', key: 'open-in-app.application' }, ContextMenuApplicationIcon) }) diff --git a/packages/client/ui-open-in-app/src/client/locales.ts b/packages/client/ui-open-in-app/src/client/locales.ts index 039e8cbbb5..bd87413a4e 100644 --- a/packages/client/ui-open-in-app/src/client/locales.ts +++ b/packages/client/ui-open-in-app/src/client/locales.ts @@ -39,7 +39,8 @@ const PRODUCT_NAMES = { /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'menu.sidebar': '在侧边栏打开', + 'menu.openInSidebar': '在侧边栏打开', + 'menu.openInFloat': '在浮窗中打开', 'menu.finder': '在 Finder 中显示', 'menu.reveal': '在文件夹显示', 'menu.defaultApp': '使用默认应用打开', @@ -72,7 +73,8 @@ export const zh = { /** English dictionary, key-identical to the Chinese source of truth. */ export const en: Record = { - 'menu.sidebar': 'Open in sidebar', + 'menu.openInSidebar': 'Open in sidebar', + 'menu.openInFloat': 'Open in floating panel', 'menu.finder': 'Show in Finder', 'menu.reveal': 'Show in folder', 'menu.defaultApp': 'Open in default application', @@ -108,7 +110,8 @@ export type OpenInAppKey = keyof typeof zh /** Japanese copy, checked against the English keys. */ export const ja = { - 'menu.sidebar': 'サイドバーで開く', + 'menu.openInSidebar': 'サイドバーで開く', + 'menu.openInFloat': 'フローティングパネルで開く', 'menu.finder': 'Finder に表示', 'menu.reveal': 'フォルダーに表示', 'menu.defaultApp': '既定のアプリで開く', @@ -141,7 +144,8 @@ export const ja = { /** Spanish dictionary, checked against the English keys. */ export const es = { - 'menu.sidebar': 'Abrir en la barra lateral', + 'menu.openInSidebar': 'Abrir en la barra lateral', + 'menu.openInFloat': 'Abrir en un panel flotante', 'menu.finder': 'Mostrar en Finder', 'menu.reveal': 'Mostrar en la carpeta', 'menu.defaultApp': 'Abrir en la aplicación predeterminada', @@ -173,7 +177,8 @@ export const es = { /** French dictionary, checked against the English keys. */ export const fr = { - 'menu.sidebar': 'Ouvrir dans la barre latérale', + 'menu.openInSidebar': 'Ouvrir dans la barre latérale', + 'menu.openInFloat': 'Ouvrir dans un panneau flottant', 'menu.finder': 'Afficher dans le Finder', 'menu.reveal': 'Afficher dans le dossier', 'menu.defaultApp': 'Ouvrir dans l’application par défaut', @@ -205,7 +210,8 @@ export const fr = { /** German dictionary, checked against the English keys. */ export const de = { - 'menu.sidebar': 'In der Seitenleiste öffnen', + 'menu.openInSidebar': 'In der Seitenleiste öffnen', + 'menu.openInFloat': 'In schwebendem Panel öffnen', 'menu.finder': 'Im Finder anzeigen', 'menu.reveal': 'Im Ordner anzeigen', 'menu.defaultApp': 'In der Standardanwendung öffnen', @@ -237,7 +243,8 @@ export const de = { /** Russian dictionary, checked against the English keys. */ export const ru = { - 'menu.sidebar': 'Открыть на боковой панели', + 'menu.openInSidebar': 'Открыть на боковой панели', + 'menu.openInFloat': 'Открыть на плавающей панели', 'menu.finder': 'Показать в Finder', 'menu.reveal': 'Показать в папке', 'menu.defaultApp': 'Открыть в приложении по умолчанию', @@ -269,7 +276,8 @@ export const ru = { /** Portuguese dictionary, checked against the English keys. */ export const pt = { - 'menu.sidebar': 'Abrir na barra lateral', + 'menu.openInSidebar': 'Abrir na barra lateral', + 'menu.openInFloat': 'Abrir em painel flutuante', 'menu.finder': 'Mostrar no Finder', 'menu.reveal': 'Mostrar na pasta', 'menu.defaultApp': 'Abrir no aplicativo padrão', diff --git a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts index f0a9c00b90..e0457da73f 100644 --- a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts @@ -138,7 +138,7 @@ describe('open-in-app browser half', () => { const toast = toastSource(ctx) try { expect(ctx.slots.entries('contextMenu.icon').map(entry => entry.options.key)).toEqual([ - 'open-in-app.reveal', 'open-in-app.copy', 'open-in-app.sidebar', 'open-in-app.application', + 'open-in-app.reveal', 'open-in-app.copy', 'open-in-app.sidebar', 'open-in-app.float', 'open-in-app.application', ]) expect(ctx.slots.entries('sidebar.workspaces.session.menu.item')).toHaveLength(0) await vi.waitFor(() => { expect(menu.source.getSnapshot()[0]).toMatchObject({ label: 'Show in Finder', disabled: false }) }) diff --git a/packages/client/ui-open-in-app/tests/context-menu-actions.client.spec.ts b/packages/client/ui-open-in-app/tests/context-menu-actions.client.spec.ts index 2f7c64a44d..10407f9f54 100644 --- a/packages/client/ui-open-in-app/tests/context-menu-actions.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/context-menu-actions.client.spec.ts @@ -78,12 +78,24 @@ async function boot() { sidebar, browserTab, notify, failure, writeText, report } } -it('routes file actions through the captured pane, line, native association and original bytes', async () => { +it('routes file actions through an explicit host, the line, native association and original bytes', async () => { const h = await boot() - const file = { kind: 'file', value: { sessionId, path: 'file.svg', paneId: 'pane' as never, line: 24 } } as const + // A menu raised inside a floating panel names that panel; the open names a host instead, never a pane. + const file = { kind: 'file', value: { sessionId, path: 'file.svg', paneId: 'float7' as never, line: 24 } } as const + const items = (await h.resolve(file)).source.getSnapshot().filter(item => item.id.startsWith('open-in-app.file/')) + expect(items.map(item => [item.id, item.label, item.icon?.key])).toEqual([ + ['open-in-app.file/sidebar', 'menu.openInSidebar', 'open-in-app.sidebar'], + ['open-in-app.file/float', 'menu.openInFloat', 'open-in-app.float'], + ['open-in-app.file/reveal', 'menu.reveal', 'open-in-app.reveal'], + ['open-in-app.file/open', 'open.title', 'open-in-app.application'], + ['open-in-app.file/copyPath', 'menu.copyPath', 'open-in-app.copy'], + ]) await h.choose(file, 'open-in-app.file/sidebar') - expect(h.sidebar.openResourceIn).toHaveBeenCalledWith(sessionId, expect.stringContaining('file.svg'), - { paneId: 'pane', params: { line: 24 } }) + expect(h.sidebar.openResourceIn).toHaveBeenLastCalledWith(sessionId, expect.stringContaining('file.svg'), + { destination: 'sidebar', params: { line: 24 } }) + await h.choose(file, 'open-in-app.file/float') + expect(h.sidebar.openResourceIn).toHaveBeenLastCalledWith(sessionId, expect.stringContaining('file.svg'), + { destination: 'float', params: { line: 24 } }) const opened = await h.choose(file, 'open-in-app.file/open') expect(opened?.icon).toEqual({ key: 'open-in-app.application', src: 'data:image/png;base64,a' }) expect(h.remote.session.openWorkspacePath).toHaveBeenCalledWith({ path: '/workspace/file.svg', application: 'preview' }) @@ -107,7 +119,11 @@ it('routes file actions through the captured pane, line, native association and expect(menu.source.getSnapshot().filter(item => item.id.startsWith('file-actions.')).map(item => item.id)) .toEqual(['file-actions.file/saveAs']) menu.select('open-in-app.file/sidebar') - expect(h.sidebar.openResourceIn).toHaveBeenLastCalledWith(SessionId('absent'), expect.any(String), {}) + expect(h.sidebar.openResourceIn).toHaveBeenLastCalledWith(SessionId('absent'), expect.any(String), { destination: 'sidebar' }) + h.remote.session.workspacePathApplications.mockResolvedValueOnce({ ok: true, value: [] }) + const floating = await h.resolve(simple) + floating.select('open-in-app.file/float') + expect(h.sidebar.openResourceIn).toHaveBeenLastCalledWith(SessionId('absent'), expect.any(String), { destination: 'float' }) }) it('keeps copy-path available on failed metadata and reports native action failures', async () => { @@ -136,6 +152,7 @@ it('keeps copy-path available on failed metadata and reports native action failu it('opens links in the registered browser, externally, and on the clipboard', async () => { const h = await boot() const target = { kind: 'url', value: { sessionId, url: 'https://example.com/page' } } as const + expect((await h.resolve(target)).source.getSnapshot().find(item => item.id === 'open-in-app.url/sidebar')?.label).toBe('menu.openInSidebar') await h.choose(target, 'open-in-app.url/sidebar') expect(h.sidebar.openTabIn).toHaveBeenCalledWith(sessionId, 'browser', { params: { url: target.value.url } }) const external = vi.spyOn(window, 'open').mockReturnValue(null) diff --git a/packages/client/ui-open-in-app/tests/context-menus.client.spec.ts b/packages/client/ui-open-in-app/tests/context-menus.client.spec.ts index b973ff0c81..30fff9b342 100644 --- a/packages/client/ui-open-in-app/tests/context-menus.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/context-menus.client.spec.ts @@ -48,7 +48,7 @@ it('offers image actions on file targets, copies absolute paths, and keeps save const copying = registry.resolve(file) await copying.ready expect(copying.source.getSnapshot().map(action => action.id)).toEqual([ - 'open-in-app.file/sidebar', 'open-in-app.file/reveal', 'open-in-app.file/open', + 'open-in-app.file/sidebar', 'open-in-app.file/float', 'open-in-app.file/reveal', 'open-in-app.file/open', 'open-in-app.file/copyPath', 'file-actions.file/copyImage', 'file-actions.file/download', 'file-actions.file/saveAs', ]) copying.select('open-in-app.file/copyPath') @@ -66,5 +66,10 @@ it('offers image actions on file targets, copies absolute paths, and keeps save expect(image.source.getSnapshot().find(action => action.id.endsWith('/reveal'))?.disabled).toBe(true) image.dispose() expect(failed).not.toHaveBeenCalled() + await fiber.dispose() + const unloaded = registry.resolve(file) + await unloaded.ready + expect(unloaded.source.getSnapshot().filter(action => action.id.startsWith('open-in-app.'))).toEqual([]) + unloaded.dispose() } finally { registry.dispose(); await fiber.dispose() } }) diff --git a/packages/client/ui-plugin-market/README.i18n.yaml b/packages/client/ui-plugin-market/README.i18n.yaml index 1fa39181d9..f7f071560f 100644 --- a/packages/client/ui-plugin-market/README.i18n.yaml +++ b/packages/client/ui-plugin-market/README.i18n.yaml @@ -15,8 +15,8 @@ en: 76368b6b1da79c80 zh: 52384aee991ac542 /deepseek-ai-dsh-client-ui-plugin-market/use-this-package: - en: 868b100459e30aad - zh: 8a3a01199dc3c14c + en: 4e582bcd8434b513 + zh: 39334bf8ae435e09 /deepseek-ai-dsh-client-ui-plugin-market/understand-the-implementation: en: e1a480caaff8efea zh: 0b725fa5bc929f22 diff --git a/packages/client/ui-plugin-market/README.md b/packages/client/ui-plugin-market/README.md index 283992a8c6..ccffb81311 100644 --- a/packages/client/ui-plugin-market/README.md +++ b/packages/client/ui-plugin-market/README.md @@ -26,7 +26,7 @@ Discover is the second tab of the **Plugins** page, after **Installed**. It list Open **Plugins** and select **Discover**. With no index source, the tab shows an empty state whose **Add index source** button opens the **Index sources** dialog. The dialog lists each source by index title, or by host before the first read, with its URL, the error of its last failed read, and a remove button, and keeps a notice that indexes come from third parties. A refused URL keeps the draft and says why: an unsupported scheme, credentials, or fragment; a URL already listed; or a deployment that cannot save sources. A newly added source becomes the selected index. -The toolbar holds the index menu, which switches indexes and ends with **Manage index sources…**; the search field; a **Labels** menu with multi-select checkmarks when the index declares labels; the **Sort** menu; and a refresh button. Category pills, with **All** first, follow when the index declares categories. +The toolbar holds the index menu, which switches indexes and ends with **Manage index sources…**; the search field; a **Labels** menu with multi-select checkmarks when the index declares labels; the **Sort** menu; and a refresh button. Controls wrap onto additional rows when space is limited, keeping search, filtering, sorting, and refresh available on mobile. Category pills, with **All** first, follow when the index declares categories. Each card shows the icon, localized title, label tags, a one-line description, and the default version, author, and publish date, with one action: **Install** for an installable entry; a disabled **Install** whose tooltip says no version is compatible with the running DSH when the plugin has no default version and its highest version is neither compatible nor exempted; or **Installed**, **Update available**, or **Provided by DSH**, which switches to the Installed tab and opens that bundle's page when that tab lists it. Selecting a card opens the entry's page with its full description, compatibility notice, and, under the source heading, the default version's facts: author; homepage and source code repository, as links that open in a new tab, with the package directory beside the repository; the npm package and registry, or the Git repository, full commit, and directory; publish date; category; labels; and index title. The page shows the long description first, without a heading of its own, then **Versions**, then the source facts. The **Versions** list is in descending SemVer order; each row tags the recommended, prerelease, installed, incompatible, and exempted versions and offers **Install** for any other version installation accepts, except for a bundle DSH provides. The page's back link returns to the list with its search, filters, and sort intact. diff --git a/packages/client/ui-plugin-market/README.zh.md b/packages/client/ui-plugin-market/README.zh.md index 276c1a5556..db4e749056 100644 --- a/packages/client/ui-plugin-market/README.zh.md +++ b/packages/client/ui-plugin-market/README.zh.md @@ -26,7 +26,7 @@ kind: "package-reference" 打开**插件**并选择**发现**。没有索引源时,该标签页显示空状态,其中的**添加索引源**按钮打开**索引源**对话框。对话框按插件索引标题列出每个索引源(首次读取前按主机名),并显示其 URL、最近一次读取失败的错误和删除按钮,同时保留一条提示,说明索引由第三方提供。URL 被拒绝时保留草稿并说明原因:不支持的协议、包含凭据或片段、URL 已在列表中,或当前部署无法保存索引源。新添加的索引源成为所选插件索引。 -工具栏包含:插件索引菜单,用于切换插件索引,最后一项为**管理索引源…**;搜索框;插件索引声明了标签时显示的**标签**菜单,以勾选标记支持多选;**排序**菜单;以及刷新按钮。插件索引声明了分类时,其后显示分类按钮,**全部**排在第一位。 +工具栏包含:插件索引菜单,用于切换插件索引,最后一项为**管理索引源…**;搜索框;插件索引声明了标签时显示的**标签**菜单,以勾选标记支持多选;**排序**菜单;以及刷新按钮。空间不足时,控件自动换行,确保手机端仍可使用搜索、筛选、排序和刷新。插件索引声明了分类时,其后显示分类按钮,**全部**排在第一位。 每张卡片显示图标、本地化标题、标签、一行描述,以及默认版本、作者和发布日期,并只有一个操作:可安装的条目显示**安装**;没有默认版本、且最高版本既不兼容也未豁免的条目显示禁用的**安装**,其提示框说明没有与当前运行的 DSH 兼容的版本;其余条目显示**已安装**、**有新版本**或**随 DSH 提供**,点击后切换到「已安装」标签页,并在该标签页列出该组合包时打开其页面。选择卡片会打开条目页面,显示其完整描述、兼容性提示,并在来源标题下显示默认版本的信息:作者;主页和源代码仓库(在新标签页中打开的链接,仓库旁显示包所在目录);npm 包和注册表,或 Git 仓库、完整提交和目录;发布日期;分类;标签;以及插件索引标题。页面先显示详细介绍,不带单独的标题,然后是**版本**,然后是来源信息。**版本**列表按 SemVer 降序排列;每行标出推荐、预发布、已安装、不兼容和已豁免的版本,并为安装检查接受的其他版本提供**安装**,DSH 随附的组合包除外。页面的返回链接回到列表,搜索、筛选和排序保持不变。 diff --git a/packages/client/ui-plugin-market/src/client/DiscoverSection.module.css b/packages/client/ui-plugin-market/src/client/DiscoverSection.module.css index e370480d97..6f874795f9 100644 --- a/packages/client/ui-plugin-market/src/client/DiscoverSection.module.css +++ b/packages/client/ui-plugin-market/src/client/DiscoverSection.module.css @@ -9,6 +9,7 @@ .toolbar { display: flex; + flex-wrap: wrap; align-items: center; gap: 8px; } diff --git a/packages/client/ui-plugin-market/src/client/DiscoverSection.tsx b/packages/client/ui-plugin-market/src/client/DiscoverSection.tsx index 26fe9bfa9a..cad12593bf 100644 --- a/packages/client/ui-plugin-market/src/client/DiscoverSection.tsx +++ b/packages/client/ui-plugin-market/src/client/DiscoverSection.tsx @@ -460,7 +460,7 @@ function Browser({ state, listing, t, face, hooks, language, onInstall, openPack const busy = listing.status === 'loading' && value !== null return ( <> -
+
{ setMenu(null) }} portal autoFocus selectedId={listing.url} items={state.indexes.map(index => ({ id: index.url, label: indexLabel(index, face.resolveText) }))} footer={[{ id: 'manage', label: t('manageSources'), icon: }]} diff --git a/packages/client/ui-schedule/tests/schedule-task-tab.client.spec.tsx b/packages/client/ui-schedule/tests/schedule-task-tab.client.spec.tsx index 4e801ade0c..c4d4c8dacc 100644 --- a/packages/client/ui-schedule/tests/schedule-task-tab.client.spec.tsx +++ b/packages/client/ui-schedule/tests/schedule-task-tab.client.spec.tsx @@ -65,7 +65,7 @@ function tabInfo(params: unknown, revision = 1): SidebarRightTabInfo { } return { sidebar: { expanded: true, fullscreen: false }, - panel: { id: 'pane-1' as PaneId }, + panel: { id: 'pane-1' as PaneId, isFloating: false }, tab: { ...TAB, title: '', diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 53f1ab1e77..02b3bd6729 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -21,8 +21,8 @@ en: f4817eb2e3537ed9 zh: e589c193fa85473f /deepseek-ai-dsh-client-ui-settings-models/use-this-package/editing-a-provider: - en: a4ac56de00a7e59b - zh: 9b3f914dc43393c7 + en: ce52da7e6065ccc4 + zh: c63690cb5de5ea85 /deepseek-ai-dsh-client-ui-settings-models/use-this-package/adding-and-deleting-providers: en: c0b04b2b11af60d3 zh: d195eec281e445cb diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 0a65e5a7d2..8db24c5170 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -59,7 +59,7 @@ The DeepSeek card edits the `llm-deepseek` endpoint, credentials, and model cata Drag a model’s reorder handle to change its position within the provider, or focus the handle and use the up and down arrow keys. Apply saves the order; Cancel discards it. Each model keeps its settings, expanded options, and unfinished capacity text when moved. The full-row drag preview and reduced-motion-aware row animations share primitives with workspace reordering; before/after insertion markers follow the same style. -Expand **Customized settings → Model options** to edit each model. Both provider families share the same row layout, labels, and icons: context window and max output tokens occupy two columns, and **Input types** occupies a separate row with **Text** and **Image** checkboxes. A row without an input declaration displays the installed model’s input types, then the provider default, then Text. Known pi-ai providers load their installed catalog without endpoint I/O; opening a row does not write an override. Explicit input selections take precedence, including text-only overrides of vision models. Checkbox edits save the selected types, with at least one type required. DeepSeek writes `inputModalities`; pi-ai writes `input`. Unchecking Image for DeepSeek also removes `imagePixelBudget` and `imageMaxBytes`, because the adapter rejects those limits without image input. Clearing the input field in `cordis.patch.yml` restores adapter inheritance; **Restore default models** resets the entire catalog override. Declare only input types the upstream model can actually process. +Expand **Customized settings → Model options** to edit each model. When the model row is narrow, the main row shows only the model ID; expand the row to view or edit its display name. Wider rows show both fields side by side. Focusing a display name expands its row and keeps it open until explicitly collapsed. Tab reaches the option and delete buttons before the display name at every width. Both provider families share the same row layout, labels, and icons: context window and max output tokens occupy two columns, and **Input types** occupies a separate row with **Text** and **Image** checkboxes. A row without an input declaration displays the installed model’s input types, then the provider default, then Text. Known pi-ai providers load their installed catalog without endpoint I/O; opening a row does not write an override. Explicit input selections take precedence, including text-only overrides of vision models. Checkbox edits save the selected types, with at least one type required. DeepSeek writes `inputModalities`; pi-ai writes `input`. Unchecking Image for DeepSeek also removes `imagePixelBudget` and `imageMaxBytes`, because the adapter rejects those limits without image input. Clearing the input field in `cordis.patch.yml` restores adapter inheritance; **Restore default models** resets the entire catalog override. Declare only input types the upstream model can actually process. A model row on a hand-declared pi-ai route also edits its **Reasoning levels**, the `reasoningEfforts` the adapter already reads from `cordis.patch.yml`; the **?** beside the label explains the two columns. With the switch off the field is absent, so the model does not reason and the model picker offers no level; a stored `false` is kept until the switch is used. Turning it on always starts from rows for off, low, medium, and high; it does not restore levels removed earlier, and **Cancel** discards the edit instead. Each row pairs a level select — the keys `llm-pi-ai`'s configuration schema accepts in `reasoningEfforts`, read from the namespace schema like the protocol choices and today `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`, each usable once — with the **API value** sent for it, which starts as the level name; a field cleared while typing stores the level name and shows it once the field is left. Off only turns reasoning off, so its value field is disabled; a value already written for it in `cordis.patch.yml` is shown and kept. Changing a row's level resets a value still spelled as the old level; a custom value carries over only between two thinking levels, so changing a row to or from Off resets it. A stored key the schema does not list keeps its row and stays selectable there. Rows can be added and deleted and are saved in row order, while the model picker lists the levels in escalation order. The section refuses what the adapter refuses: no level besides Off — including a `reasoningEfforts:` left without a value, which shows the switch on with no rows — or a value that is missing on a level besides Off or empty on any level. A refused value marks its field invalid, and leaving that field fills in the level name. The rows keep no state of their own, so a model row reused after a deletion, a reset, or a fold shows only its own model. Routes served from the installed catalog and the DeepSeek card keep the adapter's levels and show no reasoning control. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index a99154c6db..23763f168c 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -59,7 +59,7 @@ API 密钥输入框初始为空,并通过 `autocomplete="new-password"` 请求 拖动模型的排序手柄可调整它在当前提供商内的位置,也可以聚焦手柄后使用上下方向键。保存会保留顺序,取消会放弃修改。移动时,每个模型的参数、已展开选项和未完成的容量输入都会随模型保留。整行拖拽预览及遵循减少动态效果设置的行动画,与工作区排序共用基础组件;前后插入标记沿用相同样式。 -展开**自定义设置 → 模型选项**编辑模型。两个提供商家族共用相同的模型行布局、标签和图标:上下文窗口与最大输出 token 数分为两列,**输入类型**独占下一行,提供**文本**和**图片**复选框。未声明输入类型的模型行优先显示已安装模型的输入类型,其次是提供商默认值,最后回退为文本。已知 pi-ai 提供商会加载已安装目录,不向端点发送请求;打开模型行不会写入覆盖值。显式输入选择具有优先权,包括为视觉模型设置的仅文本覆盖。修改复选框会保存所选类型,且至少保留一种。DeepSeek 写入 `inputModalities`,pi-ai 写入 `input`。DeepSeek 取消勾选图片时,还会移除 `imagePixelBudget` 和 `imageMaxBytes`,因为适配器在没有图片输入时拒绝这些限制。在 `cordis.patch.yml` 中清除输入字段可恢复适配器继承;**恢复默认模型**会重置整个模型目录覆盖。仅声明上游模型实际能够处理的输入类型。 +展开**自定义设置 → 模型选项**编辑模型。模型行较窄时,主行仅显示模型 ID,展开模型行后可查看或编辑显示名称;宽度充足时,两个输入框并排显示。显示名称获得焦点时会展开该行,并保持展开,直到显式收起。所有宽度下,Tab 都先经过模型选项和删除按钮,再到显示名称。两个提供商家族共用相同的模型行布局、标签和图标:上下文窗口与最大输出 token 数分为两列,**输入类型**独占下一行,提供**文本**和**图片**复选框。未声明输入类型的模型行优先显示已安装模型的输入类型,其次是提供商默认值,最后回退为文本。已知 pi-ai 提供商会加载已安装目录,不向端点发送请求;打开模型行不会写入覆盖值。显式输入选择具有优先权,包括为视觉模型设置的仅文本覆盖。修改复选框会保存所选类型,且至少保留一种。DeepSeek 写入 `inputModalities`,pi-ai 写入 `input`。DeepSeek 取消勾选图片时,还会移除 `imagePixelBudget` 和 `imageMaxBytes`,因为适配器在没有图片输入时拒绝这些限制。在 `cordis.patch.yml` 中清除输入字段可恢复适配器继承;**恢复默认模型**会重置整个模型目录覆盖。仅声明上游模型实际能够处理的输入类型。 手工声明的 pi-ai 路由上,模型行还会编辑**推理等级**,即适配器本来就从 `cordis.patch.yml` 读取的 `reasoningEfforts`;标签旁的 **?** 说明两列的含义。开关关闭时该字段不存在,模型不推理,模型选择器不提供任何档位;已存储的 `false` 在开关被操作前保持不变。打开开关总是从 off、low、medium 和 high 四行开始,不会恢复之前删掉的档位;要放弃修改请点**取消**。每行把一个档位选择框——可选值是 `llm-pi-ai` 配置 schema 允许作为 `reasoningEfforts` 键的档位,与协议选项一样从命名空间 schema 读取,目前为 `off`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`,每个档位只能用一次——与发送给它的 **API 参数值**配成一对,参数值初始为档位名;输入时清空的字段会存为档位名,离开字段后显示出来。Off 只表示关闭思考,因此它的参数值输入框是禁用的;`cordis.patch.yml` 中已为它写好的值会显示并保留。修改某行的档位时,仍是旧档位名的参数值会随之重置;自定义值只在两个思考档位之间保留,切到或切离 Off 都会重置。schema 未列出的已存键会保留自己的行,并可在该行中继续选择。行可以增删,并按行的顺序保存,模型选择器则按升级顺序列出档位。本节拒绝适配器会拒绝的配置:没有 Off 以外的档位(包括未写值的 `reasoningEfforts:`,此时开关显示为打开且没有任何行),或者 Off 以外的档位缺少参数值、任意档位的参数值为空字符串。被拒绝的值会把对应输入框标为无效,离开该输入框时会填入档位名。各行不保留自己的状态,因此删除、恢复默认或折叠之后被复用的模型行只显示它自己的模型。走已安装目录的路由和 DeepSeek 卡片沿用适配器的档位,不显示推理等级控件。 diff --git a/packages/client/ui-settings-models/src/client/ModelRow.tsx b/packages/client/ui-settings-models/src/client/ModelRow.tsx index 55cbf6f617..456023ae80 100644 --- a/packages/client/ui-settings-models/src/client/ModelRow.tsx +++ b/packages/client/ui-settings-models/src/client/ModelRow.tsx @@ -49,13 +49,34 @@ function rowHalf(event: DragEvent): 'before' | 'after' { } /** - * Render consistent model identity, capacity, and input-type controls. + * Render model fields with the display name in the expanded area of narrow rows. * @param props - drafted fields and their owning editor's actions. * @returns one expandable model entry. */ export function ModelRow(props: ModelRowProps): ReactNode { const { model, position, t, disabled } = props const preview = useRowDragPreview(styles['modelDragPreview'] as string) + const modelField = (field: 'id' | 'name') => ( + + ) return (
-
- +
+ - {(['id', 'name'] as const).map(field => ( - { - const value = event.target.value - props.onFieldChange(field, field === 'name' && value === '' ? undefined : value) - }} - onBlur={field === 'id' ? event => props.onIdBlur?.(event.target.value) : undefined} - /> - ))} + {modelField('id')} @@ -141,40 +147,41 @@ export function ModelRow(props: ModelRowProps): ReactNode { > + {modelField('name')} + {props.expanded + ? ( +
+ {(['contextWindow', 'maxTokens'] as const).map(field => ( + + ))} + + {props.reasoningLevels !== undefined && props.reasoningLevels.length > 0 + ? ( + + ) + : null} +
+ ) + : null}
- {props.expanded - ? ( -
- {(['contextWindow', 'maxTokens'] as const).map(field => ( - - ))} - - {props.reasoningLevels !== undefined && props.reasoningLevels.length > 0 - ? ( - - ) - : null} -
- ) - : null}
) } diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.module.css b/packages/client/ui-settings-models/src/client/ModelsSection.module.css index 51995fc1ae..053db0c4a3 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-settings-models/src/client/ModelsSection.module.css @@ -507,6 +507,7 @@ } .modelEntry { + container: model-row / inline-size; position: relative; border: 0.5px solid var(--dsw-alias-border-l4); border-radius: var(--dsw-radius-lg); @@ -520,6 +521,36 @@ gap: 6px; } +.modelName { + grid-column: 3; + grid-row: 1; +} + +.modelNameLabel { + display: none; +} + +@container model-row (max-width: 440px) { + .modelRow { + grid-template-columns: auto minmax(0, 1fr) auto auto; + } + + .modelRow .modelName { + display: none; + grid-column: 1 / -1; + grid-row: 2; + padding: 2px 4px 0; + } + + .modelRow[data-expanded] .modelName { + display: flex; + } + + .modelNameLabel { + display: block; + } +} + .modelDragHandle:not(:disabled) { cursor: grab; } @@ -528,6 +559,10 @@ opacity: 0.5; } +.modelEntry[data-drop-target] { + z-index: 1; +} + .modelDragPreview { background: var(--dsw-alias-bg-module-platform); } @@ -618,9 +653,10 @@ .modelAdvanced { display: grid; + grid-column: 1 / -1; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; - padding: 8px 4px 2px; + padding: 2px 4px; } .modelField { diff --git a/packages/client/ui-settings-models/tests/model-order.client.spec.tsx b/packages/client/ui-settings-models/tests/model-order.client.spec.tsx index 3f2bceb6fe..46884f5f3e 100644 --- a/packages/client/ui-settings-models/tests/model-order.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/model-order.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom /** Provider-local ordering preserves row instances and unfinished model edits. */ import { useState } from 'react' -import { cleanup, createEvent, fireEvent, render, screen, within } from '@testing-library/react' +import { act, cleanup, createEvent, fireEvent, render, screen, within } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { DeepSeekModelsEditor } from '../src/client/DeepSeekModelsEditor.tsx' import type { DeepSeekModelDraft } from '../src/client/DeepSeekModelsEditor.tsx' @@ -62,6 +62,21 @@ function leave(target: HTMLElement, relatedTarget: Node | null = null) { } describe.each(['deepseek', 'pi-ai'] as const)('%s model ordering', (family) => { + it('keeps name editing expanded after blur and restores disclosure focus on collapse', () => { + render() + const name = screen.getByLabelText(`${en.modelName} 1`) + const toggle = screen.getByRole('button', { name: `${en.modelAdvanced} 1` }) + act(() => { name.focus() }) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + act(() => { handle(1).focus() }) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + act(() => { name.focus() }) + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(document.activeElement).toBe(toggle) + expect(screen.getByLabelText(`${en.modelName} 1`)).toBe(name) + }) + it('moves a complete model and its expanded capacity draft to the drop position', () => { const first = { id: 'first', name: 'First', contextWindow: 4096, input: ['image'], hidden: { preserved: true } } const second = { id: 'second', maxTokens: 1024 } diff --git a/packages/client/ui-sidebar-browser/tests/browser-body.client.spec.tsx b/packages/client/ui-sidebar-browser/tests/browser-body.client.spec.tsx index b5662a17f8..aa36ee6384 100644 --- a/packages/client/ui-sidebar-browser/tests/browser-body.client.spec.tsx +++ b/packages/client/ui-sidebar-browser/tests/browser-body.client.spec.tsx @@ -51,7 +51,7 @@ function mountBrowser(navigation?: { readonly url?: string }, & Omit = { sessionId: SESSION, useTabInfo: () => ({ - sidebar: { expanded: true, fullscreen: false }, panel: { id: 'pane' as PaneId }, + sidebar: { expanded: true, fullscreen: false }, panel: { id: 'pane' as PaneId, isFloating: false }, tab: { id: TAB, kind: 'browser', title: 'Browser', contentId: 'sidebar://browser/1', visible: true, navigation: { address: 'sidebar://browser/1', params: navigation, revision: 0 }, diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 3a1355ec2f..4b31f74375 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -15,14 +15,14 @@ en: 0dd8c3a22e28aacf zh: c27c11bf4b5cceba /deepseek-ai-dsh-client-ui-sidebar-documentpreview/what-it-registers: - en: a02e6fd89e332967 - zh: b5c61cb75458d1a2 + en: 5686f49d98437f49 + zh: 40c154f8cc7b8d14 /deepseek-ai-dsh-client-ui-sidebar-documentpreview/addresses: en: 57942270e23d3a8e zh: 6617124e652b3f67 /deepseek-ai-dsh-client-ui-sidebar-documentpreview/how-it-reads: - en: e197303166b5a86f - zh: 8d151e49321d67aa + en: e3ca76083e667293 + zh: 4b52ffa2848d9f87 /deepseek-ai-dsh-client-ui-sidebar-documentpreview/excel-preview: en: 595499992ceea693 zh: 83c6147c46a80dca @@ -30,8 +30,8 @@ en: 372d8ce6492e6896 zh: 912f613bc9221aa3 /deepseek-ai-dsh-client-ui-sidebar-documentpreview/navigation: - en: 7313ef5591ef162d - zh: fbc3948329ac5bab + en: 33215200b3e4a8ed + zh: 499e4b834f2db95a /deepseek-ai-dsh-client-ui-sidebar-documentpreview/model-experience: en: ca1deb99d0e44755 zh: f285fc56cee669ec @@ -39,8 +39,8 @@ en: 7edb4c0bb5f13847 zh: ae5d0bbebe4aa563 /deepseek-ai-dsh-client-ui-sidebar-documentpreview/known-limitations-and-deferred-work: - en: 7be2ad2e198131f8 - zh: 21b35eae72a49ad2 + en: ad44f28dc14beb4a + zh: e9f096bd9e63e72e /deepseek-ai-dsh-client-ui-sidebar-documentpreview/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: e88aeb617ffb74ea diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 37eec512d6..4e01d19f63 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -31,10 +31,10 @@ Preview files in the right Sidebar and choose among registered renderers. Markdo Image drag actions arrive through the image entry’s inject face from [ui-file-drag](../ui-file-drag/README.md). - **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, a whole-address file-resource pattern, band `fallback`. `canOpen` accepts only Session addresses, whose paths may be relative or absolute; bare `absolute` addresses are not claimed. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; other supported files land here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title, and the keyed `sidebar.right.pane.tab.title` seat places its extension-specific `FileTypeIcon` before that title. -- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; the shared [`PathLabel`](../ui-primitives/README.md#component-catalog) subdues directories, preserves a clipped path’s trailing characters with a left-edge fade, and exposes the full value on hover. A dropdown appears when multiple supported renderers are available. Plain text is offered only for text-compatible sources; a single renderer shows no viewer control. A known binary container suffix without a registered renderer shows the file-type icon and an unsupported-preview message under the path header, without issuing a read. Two Session-scoped list child slots hand the previewed file to other plugins once its metadata reports a Host path, both with `absolutePath` as the owner prop: `sidebar.right.tab.document.actions` renders after the header's own controls in every state that shows the header, and `sidebar.right.tab.document.unpreviewable` renders in the unsupported empty state and in the empty state of a readable file the preview cannot render (`not-text`, `too-large`), where Retry would otherwise stand; a carrier or unclassified read failure keeps Retry, and a missing or non-regular path offers only its explanation. The shipped occupant of both is [`ui-open-in-app`](../ui-open-in-app/README.md). A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. Automatic refresh defaults to enabled; its separate toggle is hidden, with state, labels, styles, and toggle logic retained. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the Files tab's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge. +- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; the shared [`PathLabel`](../ui-primitives/README.md#component-catalog) subdues directories, preserves a clipped path’s trailing characters with a left-edge fade, and exposes the full value on hover. A dropdown appears when multiple supported renderers are available. Plain text is offered only for text-compatible sources; a single renderer shows no viewer control. A known binary container suffix without a registered renderer shows the file-type icon and an unsupported-preview message under the path header, without issuing a read. Two Session-scoped list child slots hand the previewed file to other plugins once its metadata reports a Host path, both with `absolutePath` as the owner prop: `sidebar.right.tab.document.actions` renders after the header's own controls and before the navigator toggle, at the same position in every state that shows the header so it is not remounted when the state changes, and `sidebar.right.tab.document.unpreviewable` renders in the unsupported empty state and in the empty state of a readable file the preview cannot render (`not-text`, `too-large`), where Retry would otherwise stand; a carrier or unclassified read failure keeps Retry, and a missing or non-regular path offers only its explanation. The shipped occupant of both is [`ui-open-in-app`](../ui-open-in-app/README.md). The preview directly renders [`ui-sidebar-files`](../ui-sidebar-files/README.md#the-navigator)'s Session-scoped Factories: `sidebar.files.navigator` after the body in its content row with `{ useTabInfo, page: false, onOpen }`, where `onOpen(address)` calls `tab.actions.openResource(address, { replaceTab: true })`, and `sidebar.files.navigator.toggle` at the header's end with `{ useTabInfo }`. Both stay mounted from loading to loaded in docked and floating panes; unregistered Factories render nothing. The navigator owns its width within the row's positioning box and stacking context, narrowing or overlaying the body without covering the floating pane's resize handle. A renderer layer handling Escape calls `preventDefault()` to keep the navigator from also closing. A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. Automatic refresh defaults to enabled; its separate toggle is hidden, with state, labels, styles, and toggle logic retained. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the file tree's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge. - **Shared loading and view state**, session-scoped and bucketed by tab id. The store holds accumulated pages or complete bytes, read and observed versions, pending Resource changes, automatic-refresh state, loading/failure state, renderer choice, scroll offset, wrap, and the answered navigation revision. The ordinary inject face calls Remote readers and writes through declared store actions. Reloads and loading-mode changes retire older requests; the tab's abort signal forgets its state. -Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. `binaryExtensions` lists suffixes in `extensions` that cannot be read as text and omit the plain-text option. Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, `content`, `wrap`, `scrollportRef`, `addResource`, `setResources`, and the standard `useTabInfo`/`useResource` hooks. Dependency membership belongs to a tab-owned `ResourceGroup`; replacing dependencies always retains the root Resource. An inner scrolling element attaches `scrollportRef`; unmounting restores the shared body as scroll owner. The registry retains all matching alternatives: `extension` (the default) ranks above `builtin`, then longer suffixes rank first, then registration order. The dropdown preserves a selected implementation while it remains available. HTML, SVG, and unmatched extensions retain the plain-text fallback independently of loading mode. +Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap?, sourceLines? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. `binaryExtensions` lists suffixes in `extensions` that cannot be read as text and omit the plain-text option. `sourceLines` declares that a `text-pages` body exposes source-line anchors for [navigation](#navigation). Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, `content`, `wrap`, `scrollportRef`, `addResource`, `setResources`, and the standard `useTabInfo`/`useResource` hooks. Dependency membership belongs to a tab-owned `ResourceGroup`; replacing dependencies always retains the root Resource. An inner scrolling element attaches `scrollportRef`; unmounting restores the shared body as scroll owner. The registry retains all matching alternatives: `extension` (the default) ranks above `builtin`, then longer suffixes rank first, then registration order. The dropdown preserves a selected implementation while it remains available. HTML, SVG, and unmatched extensions retain the plain-text fallback independently of loading mode. `loading: 'text-pages'` and `'bytes-complete'` use the shared file reader. With `'renderer'`, the selected body mounts before any bytes are read and receives `content: { kind: 'renderer', revision, loaded, failed, reload }`. Its injected face owns content loading, errors, store updates, and cancellation. `loaded(version)` records the displayed source version and ends loading. `failed()` ends loading without recording a successful version, allowing a later file change to retry; reports from replaced revisions are ignored. `reload()` increments the revision, which the body observes to cancel and replace its request. The body also cancels on unmount and tab closure, retains settled content in its declared tab store, and releases that state when the tab ends. [Office previews](#office-preview) use this mode without putting converted bytes or font metadata in the shared file store. @@ -57,7 +57,7 @@ The body reads its record, navigation and lifetime through `useTabInfo().tab`. ` - The resource snapshot contains only `status`, `value`, and `failure`; `value` is `WorkspaceFileStat` metadata. Content reads do not wait for the first metadata frame once the provider is available. Observation failures take precedence over Preview's change notice; loaded content remains while metadata is unavailable. - **Text pages** — plain text, Markdown, and code read through an inject callback to `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`. The first mount reads page one; scrolling to the body end or **Load more** requests the next page until `eof`. The owner delivers the accumulated prefix as `{ kind: 'text', text, pages, eof }`, including source offsets and line counts. Markdown and code render that prefix incrementally; they do not render each page as a separate document. A newer-version page past page one restarts from the beginning rather than mixing versions. A failure before any content fills the body with the file-type icon, the explanation, and the recourse the failure names (Retry, the unpreviewable child slot, or nothing); a later failure retains loaded content and adds the retry below it. - **Complete bytes** — PDF, HTML, common images, and spreadsheets call `remote.workspaceFiles.readBytes(sessionId, path, {}, signal)`. The binary Remote returns `data: Uint8Array` directly for `{ kind: 'bytes', data }`. The Host's `maxFileBytes` cap rejects oversized files rather than truncating them. PDF and spreadsheet renderers copy retained bytes before Worker transfer, keeping the Preview buffer usable. Bytes stay in transient view state, never persisted layouts or Session JSONL. Loading-mode changes retire previous results. -- **Reload** — manual reload rereads only the current Preview tab through its Remote callbacks, preserving its scroll preference and retiring older requests. Automatic refresh uses the same callbacks after a `ResourceGroup` member changes. Each member's first metadata establishes a baseline without triggering reload or first-read version reconciliation; later changes received during a read remain pending for another refresh. Reads neither refresh shared metadata nor clear another tab's notice. +- **Reload** — manual reload rereads only the current Preview tab through its Remote callbacks, preserving its scroll preference and retiring older requests. Automatic refresh uses the same callbacks after a `ResourceGroup` member changes. Each tab remembers its scroll offset; new content moves the view down to that offset when it is below the current one, and text previews read further pages until the offset is reachable or the file ends. A scroll event at the end of content shorter than the remembered offset is a layout clamp and leaves the offset unchanged, so a file that shrinks and grows back returns to it. The offset is a pixel distance; it does not follow a paragraph that moves when earlier content changes. Each member's first metadata establishes a baseline without triggering reload or first-read version reconciliation; later changes received during a read remain pending for another refresh. Reads neither refresh shared metadata nor clear another tab's notice. When Coding Tools is enabled, HTML fills the body edge to edge in a Blob iframe with exactly `sandbox="allow-scripts"`, without `allow-same-origin`; scripts cannot access the parent application's origin or file reader. The renderer loads directly declared relative `.js` classic scripts and `.css` stylesheets through its injected Remote callback, with fixed safety limits of 4 MiB per asset, 32 MiB total, and 64 distinct assets. Host code resolves the related path, and `readBytes` with `baseFile` returns native bytes. The dependency Resource is added after the read returns, using its `absolutePath` or a string `error.details.path` on failure; without a Host path, the Client does not guess one. Inside the renderer, base64 is used only to embed the iframe bootstrap payload in script text. A `` leaves dependency resolution to the browser, as do HTTPS resources. Local module imports, CSS `url()`/`@import`, and dynamic `fetch` do not use Host file access. Read failures, invalid UTF-8, or exceeded limits fail the preview rather than publishing a partial asset package. Replacing or unmounting the document releases its Blob URL. @@ -125,7 +125,7 @@ The page refresh shortcut reuses the selected preview’s reload operation, incl ## Navigation -`ctx.sidebarRight.openResource(address, { params: { line } })` carries a 1-based source line through the `file` parameters. In `text-pages` mode, the owner loads sequential pages until that line or EOF. Plain-text and code renderers expose source-line anchors; Markdown does not. A navigation remains pending while its selected renderer has no anchor and runs if the user switches to plain text or code. Code navigation scrolls the inner source viewport directly. The landed line receives a band that holds for 0.6 s and then fades out over 1.4 s; toggling wrap or resizing the preview removes it early; restoring a remounted tab's scroll position adds no band. Byte-mode renderers do not consume source-line navigation. Each completed navigation revision is answered once. Opening the same file without `revealIfOpened: false` focuses its existing tab and delivers a new revision. +`ctx.sidebarRight.openResource(address, { params: { line } })` carries a 1-based source line through the `file` parameters. In `text-pages` mode, the owner loads sequential pages until that line or EOF. Plain-text and code renderers declare `sourceLines`; Markdown does not. A renderer without `sourceLines` answers the navigation without moving the body, and a later switch to plain text or code does not replay it. A navigation stays pending while a `sourceLines` renderer has not drawn the line. Code navigation scrolls the inner source viewport directly. The landed line receives a band that holds for 0.6 s and then fades out over 1.4 s; toggling wrap or resizing the preview removes it early; restoring a remounted tab's scroll position adds no band. Byte-mode renderers do not consume source-line navigation. Each completed navigation revision is answered once. Opening the same file without `revealIfOpened: false` focuses its existing tab and delivers a new revision. Document path labels expose a file [context menu](../ui-context-menu/README.md), retaining the selected line and current pane. @@ -147,7 +147,7 @@ No direct effect; what the user reads here never enters a model request. - **PDF raster allocation is bounded.** Each page bitmap is capped at 16,777,216 pixels; very large pages or high zoom on high-density displays can still render below device resolution. - **Byte-view scroll state is not restored.** PDF, HTML, and images can return to the top when their renderer remounts or reloads; fixed PDF and image zoom can overflow horizontally, and HTML iframe scrolling belongs to its opaque browsing context. - **Finite local HTML dependencies.** Only direct classic `.js` and stylesheet `.css` references are packed. Browser-resolved resources retain browser origin and network restrictions; no runtime file-read bridge is exposed to the iframe. -- **Scroll writes are unthrottled.** Every scroll event records its offset in the store; the line blocks are memoized so the resulting re-render hands React the same elements back. +- **Scroll writes are unthrottled.** Every scroll event except a layout clamp records its offset in the store; the line blocks are memoized so the resulting re-render hands React the same elements back. - **A failed PDF chunk load requires a page reload.** React caches a rejected lazy import for the page lifetime; ordinary PDF open or render failures remain retryable inside the loaded body. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index ad372c6e44..c60028e5fa 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -31,10 +31,10 @@ kind: "package-reference" 图片拖放回调通过图片入口的注入接口从 [ui-file-drag](../ui-file-drag/README.zh.md) 获取。 - **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`(这个实现在 tab 系统中的身份,也是其正文注册所用的键),kind `text`,匹配完整文件资源地址的 pattern,档位 `fallback`。`canOpen` 只接受 Session 地址,其中路径可为相对或绝对路径;不认领裸 `absolute` 地址。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其他受支持文件落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题,keyed slot `sidebar.right.pane.tab.title` 会在标题前放置按扩展名选择的 `FileTypeIcon`。 -- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;共享的 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog) 弱化目录颜色,以左侧渐隐保留被裁剪路径的尾部字符,并在悬停时展示完整值。有多个受支持的渲染器时才显示下拉菜单。仅文本兼容的源文件提供纯文本选项;只有一个渲染器时不显示查看器控件。已知的二进制容器后缀没有注册渲染器时,在路径头部下方显示文件类型图标和不支持预览的说明,并且不会发起读取。两个 Session 作用域的 list 子 slot 在文件元数据报出 Host 路径后把当前文件交给其他插件,owner prop 为 `absolutePath`:`sidebar.right.tab.document.actions` 在每个显示头部的状态里渲染在头部自有控件之后;`sidebar.right.tab.document.unpreviewable` 渲染在不支持预览的空态里,以及可读但本预览无法渲染的文件(`not-text`、`too-large`)的空态里,占据原本放重试的位置;载体或未分类的读取失败仍保留重试,不存在或非普通文件的路径只给说明。两者已发布的占位者都是 [`ui-open-in-app`](../ui-open-in-app/README.zh.md)。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。自动刷新默认开启;独立开关暂时隐藏,状态、文案、样式与切换逻辑保留。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与 Files tab 右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。 +- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;共享的 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog) 弱化目录颜色,以左侧渐隐保留被裁剪路径的尾部字符,并在悬停时展示完整值。有多个受支持的渲染器时才显示下拉菜单。仅文本兼容的源文件提供纯文本选项;只有一个渲染器时不显示查看器控件。已知的二进制容器后缀没有注册渲染器时,在路径头部下方显示文件类型图标和不支持预览的说明,并且不会发起读取。两个 Session 作用域的 list 子 slot 在文件元数据报出 Host 路径后把当前文件交给其他插件,owner prop 为 `absolutePath`:`sidebar.right.tab.document.actions` 在每个显示头部的状态里渲染在头部自有控件之后、导航栏开关之前,位置在各状态间相同,因此状态切换时不会重新挂载;`sidebar.right.tab.document.unpreviewable` 渲染在不支持预览的空态里,以及可读但本预览无法渲染的文件(`not-text`、`too-large`)的空态里,占据原本放重试的位置;载体或未分类的读取失败仍保留重试,不存在或非普通文件的路径只给说明。两者已发布的占位者都是 [`ui-open-in-app`](../ui-open-in-app/README.zh.md)。预览直接渲染 [`ui-sidebar-files`](../ui-sidebar-files/README.zh.md#the-navigator) 的 Session 作用域 Factory:`sidebar.files.navigator` 位于内容行的正文之后,传入 `{ useTabInfo, page: false, onOpen }`,其中 `onOpen(address)` 调用 `tab.actions.openResource(address, { replaceTab: true })`;`sidebar.files.navigator.toggle` 位于头部末端,传入 `{ useTabInfo }`。两者在停靠与浮动分栏中从加载到完成保持挂载;未注册的 Factory 不绘制内容。导航栏在内容行的定位框与层叠上下文内决定宽度,让正文变窄或覆盖正文,不遮挡浮窗缩放手柄。自行处理 Escape 的渲染器浮层需调用 `preventDefault()`,避免同时关闭导航栏。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。自动刷新默认开启;独立开关暂时隐藏,状态、文案、样式与切换逻辑保留。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与文件树右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。 - **共享加载与视图状态**,会话作用域、按 tab id 分桶。store 持有累计页或完整字节、读取与观察版本、待处理 Resource 变化、自动刷新状态、加载/失败状态、渲染器选择、滚动位置、换行和已响应的导航 revision。普通 inject face 调用 Remote 读取,并经声明的 store action 写入。重新载入和加载模式变化会淘汰旧请求;tab 的中止信号清理其状态。 -文档实现在 `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。`binaryExtensions` 列出 `extensions` 中不可按文本阅读的后缀,这些后缀不提供纯文本选项。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、`content`、`wrap`、`scrollportRef`、`addResource`、`setResources` 和标准 `useTabInfo`/`useResource` 钩子。依赖成员由 tab 持有的 `ResourceGroup` 管理;替换依赖始终保留根 Resource。内部滚动元素挂载 `scrollportRef`;卸载时恢复共享正文的滚动职责。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变。HTML、SVG 和未匹配的扩展名保留纯文本回退,与加载方式无关。 +文档实现在 `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap?, sourceLines? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。`binaryExtensions` 列出 `extensions` 中不可按文本阅读的后缀,这些后缀不提供纯文本选项。`sourceLines` 声明 `text-pages` 正文为[导航](#navigation)提供源码行锚点。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、`content`、`wrap`、`scrollportRef`、`addResource`、`setResources` 和标准 `useTabInfo`/`useResource` 钩子。依赖成员由 tab 持有的 `ResourceGroup` 管理;替换依赖始终保留根 Resource。内部滚动元素挂载 `scrollportRef`;卸载时恢复共享正文的滚动职责。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变。HTML、SVG 和未匹配的扩展名保留纯文本回退,与加载方式无关。 `loading: 'text-pages'` 和 `'bytes-complete'` 使用共享文件读取器。选择 `'renderer'` 时,所选正文在读取任何字节前挂载,并接收 `content: { kind: 'renderer', revision, loaded, failed, reload }`。其注入 face 负责内容加载、错误、store 更新和取消。`loaded(version)` 记录已展示的源版本并结束加载。`failed()` 结束加载但不记录成功版本,使后续文件变化可以重试;已被替换的 revision 所发出的报告会被忽略。`reload()` 增加 revision,正文据此取消并替换当前请求。正文也在卸载和 tab 关闭时取消请求,将已完成内容保留在自己声明的 tab store 中,并在 tab 结束时释放。[Office 预览](#office-preview) 使用此模式,转换后的字节和字体元数据不会进入共享文件 store。 @@ -57,7 +57,7 @@ Web 和桌面端均通过代码工作工具选择 HTML 预览策略。渲染器 - 资源快照仅包含 `status`、`value` 和 `failure`;`value` 是 `WorkspaceFileStat` 元数据。提供方可用后,内容读取无需等待首个元数据帧。观察失败优先于 Preview 的变更提示显示;元数据不可用时保留已加载内容。 - **文本页** —— 纯文本、Markdown 和代码通过 inject 回调调用 `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`。首次挂载读取第一页;滚动到正文末尾或点击 **加载更多** 会读取下一页,直到 `eof`。owner 以 `{ kind: 'text', text, pages, eof }` 提供累计前缀,包含源码偏移和行数。Markdown 和代码增量渲染此前缀,不把每页当成独立文档。第一页之后到达的更新版本页会使读取从头开始,避免混合版本。尚无内容时,失败会以文件类型图标、说明和该失败对应的出路(重试、unpreviewable 子 slot,或什么都不给)填满正文;较晚的失败保留已有内容并在其下提供重试。 - **完整字节** —— PDF、HTML、常见图片和表格调用 `remote.workspaceFiles.readBytes(sessionId, path, {}, signal)`。二进制 Remote 直接返回 `data: Uint8Array`,供 `{ kind: 'bytes', data }` 使用。Host 的 `maxFileBytes` 上限拒绝超大文件,不截断。PDF 和表格渲染器在传给 Worker 前复制保留的字节,使 Preview 缓冲区仍可使用。字节仅保存在临时视图状态中,绝不进入持久布局或 Session JSONL。加载模式变化会淘汰先前结果。 -- **重新载入** —— 手动重新载入仅让当前 Preview tab 通过自己的 Remote 回调重读,保留滚动偏好并淘汰旧请求。`ResourceGroup` 成员变化后,自动刷新使用相同回调。各成员首次元数据仅建立基线,不触发重新载入或首读版本对账;后续在读取期间收到的变化仍会留待下一次刷新。读取既不刷新共享元数据,也不清除其它 tab 的提示。 +- **重新载入** —— 手动重新载入仅让当前 Preview tab 通过自己的 Remote 回调重读,保留滚动偏好并淘汰旧请求。`ResourceGroup` 成员变化后,自动刷新使用相同回调。每个 tab 记忆自己的滚动偏移;新内容到达时,若记忆偏移在当前位置之下,视图移到该偏移,文本预览会继续读取后续页,直到该偏移可达或文件结束。内容短于记忆偏移时,停在内容末尾的滚动事件属于布局夹紧,不改写记忆偏移,因此文件变短后再变长仍会回到该位置。记忆的是像素距离,前文变化导致段落移动时不跟随段落。各成员首次元数据仅建立基线,不触发重新载入或首读版本对账;后续在读取期间收到的变化仍会留待下一次刷新。读取既不刷新共享元数据,也不清除其它 tab 的提示。 开启代码工作工具时,HTML 以贴合正文四边的 Blob iframe 运行,沙箱属性严格为 `sandbox="allow-scripts"`,不含 `allow-same-origin`;脚本无法访问父应用的源或文件读取接口。渲染器通过注入的 Remote 回调,加载直接声明的相对 `.js` 经典脚本和 `.css` 样式表;固定安全上限为单个资源 4 MiB、总计 32 MiB、64 个不同资源。Host 代码解析关联路径,带 `baseFile` 的 `readBytes` 返回原生字节。依赖 Resource 在读取返回后加入,使用返回的 `absolutePath`,失败时则使用字符串类型的 `error.details.path`;没有 Host 路径时,Client 不自行猜测。在渲染器内部,base64 仅用于把 iframe 引导载荷嵌入脚本文本。`` 将依赖解析交给浏览器,HTTPS 资源也由浏览器处理。本地模块 import、CSS `url()`/`@import` 和动态 `fetch` 不使用 Host 文件访问。读取失败、无效 UTF-8 或超出上限都使预览失败,不发布部分资源包。替换或卸载文档会释放其 Blob URL。 @@ -125,7 +125,7 @@ Office Remote 通过 Connection 的 multipart 二进制传输返回原生 `Uint8 ## 导航 -`ctx.sidebarRight.openResource(address, { params: { line } })` 通过 `file` 参数携带 1 起算的源码行号。在 `text-pages` 模式下,owner 顺序加载到该行或 EOF。纯文本与代码渲染器提供源码行锚点;Markdown 不提供。所选渲染器没有锚点时,导航保持待处理;用户切换到纯文本或代码后执行。代码导航直接滚动内部源码视口。落定的行显示一条高亮带,停留 0.6 秒后在 1.4 秒内渐隐;切换换行或调整预览尺寸会提前移除它;重新挂载的 tab 恢复滚动位置时不显示高亮带。字节模式渲染器不消费源码行导航。每个完成的导航 revision 只响应一次。不带 `revealIfOpened: false` 打开同一文件时聚焦已有 tab,并送达新 revision。 +`ctx.sidebarRight.openResource(address, { params: { line } })` 通过 `file` 参数携带 1 起算的源码行号。在 `text-pages` 模式下,owner 顺序加载到该行或 EOF。纯文本与代码渲染器声明 `sourceLines`;Markdown 不声明。未声明 `sourceLines` 的渲染器直接响应导航、不移动正文,之后切换到纯文本或代码也不会重放。`sourceLines` 渲染器尚未画出该行时,导航保持待处理。代码导航直接滚动内部源码视口。落定的行显示一条高亮带,停留 0.6 秒后在 1.4 秒内渐隐;切换换行或调整预览尺寸会提前移除它;重新挂载的 tab 恢复滚动位置时不显示高亮带。字节模式渲染器不消费源码行导航。每个完成的导航 revision 只响应一次。不带 `revealIfOpened: false` 打开同一文件时聚焦已有 tab,并送达新 revision。 文档路径标签提供文件[右键菜单](../ui-context-menu/README.zh.md),保留选中行号与当前窗格。 @@ -147,7 +147,7 @@ Office Remote 通过 Connection 的 multipart 二进制传输返回原生 `Uint8 - **PDF 栅格分配有上限。** 每页位图最多为 16,777,216 像素;超大页面或高像素密度屏幕上的高比例缩放仍可能低于设备分辨率。 - **字节视图不恢复滚动位置。** PDF、HTML 与图片的渲染器重新挂载或重新载入时可能回到顶部;固定 PDF 与图片缩放可产生横向滚动,HTML iframe 的滚动属于其不透明浏览上下文。 - **本地 HTML 依赖集合有限。** 只打包直接引用的经典 `.js` 脚本和 `.css` 样式表。浏览器解析的资源仍受浏览器源与网络规则限制;iframe 不获得运行时文件读取桥接。 -- **滚动写入未节流。** 每次滚动事件都把偏移记进 store;行块已 memo 化,于是由此引发的重渲染交还给 React 的是同一批元素。 +- **滚动写入未节流。** 除布局夹紧外,每次滚动事件都把偏移记进 store;行块已 memo 化,于是由此引发的重渲染交还给 React 的是同一批元素。 - **PDF chunk 加载失败后需要刷新页面。** React 会在页面生命周期内缓存被拒绝的 lazy import;已加载正文中的普通 PDF 打开或渲染失败仍可重试。 diff --git a/packages/client/ui-sidebar-documentpreview/package.json b/packages/client/ui-sidebar-documentpreview/package.json index 2a71e60377..a2df1a7d39 100644 --- a/packages/client/ui-sidebar-documentpreview/package.json +++ b/packages/client/ui-sidebar-documentpreview/package.json @@ -66,6 +66,7 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-session": "workspace:*", "@deepseek-ai/dsh-client-ui-settings": "workspace:*", + "@deepseek-ai/dsh-client-ui-sidebar-files": "workspace:*", "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-office-to-pdf": "workspace:*", diff --git a/packages/client/ui-sidebar-documentpreview/src/client/PreviewFrame.tsx b/packages/client/ui-sidebar-documentpreview/src/client/PreviewFrame.tsx new file mode 100644 index 0000000000..6ee191a3fe --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/PreviewFrame.tsx @@ -0,0 +1,82 @@ +/** + * The text preview's layout: an optional banner, the path header, and the + * content row, with the workspace navigator Factories in fixed positions. + * + * Every preview state renders this one Component, so the navigator, its + * header toggle, and the file handoff keep their DOM nodes, focus, and scroll position while the + * preview moves between unsupported, loading, and text. The two Factory + * positions are memoized Components whose props are only the tab hook and + * `renderFactorySlot`; a preview-only update, such as a recorded scroll, + * does not re-render them. + */ +import { memo, useCallback } from 'react' +import type { ReactNode } from 'react' +import type { PropsRenderFactories, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-sidebar-files/client' +import type { ContextMenuTriggerProps } from '@deepseek-ai/dsh-client-ui-context-menu/client' +import { PathLabel } from '@deepseek-ai/dsh-client-ui-primitives' +import css from './TextPreview.module.css' + +/** The inputs both navigator Factory positions render from. */ +type NavigatorSeatProps = Pick, 'useTabInfo'> & PropsRenderFactories + +/** The workspace navigator after the preview's content. */ +const NavigatorSeat = memo(function NavigatorSeat({ useTabInfo, renderFactorySlot }: NavigatorSeatProps): ReactNode { + const { tab } = useTabInfo() + const onOpen = useCallback((address: string): void => { + tab.actions.openResource(address, { replaceTab: true }) + }, [tab.actions]) + return renderFactorySlot('sidebar.files.navigator', { useTabInfo, page: false, onOpen }) +}) + +/** The navigator's toggle at the header's end. */ +const NavigatorToggleSeat = memo(function NavigatorToggleSeat({ useTabInfo, renderFactorySlot }: NavigatorSeatProps): ReactNode { + return renderFactorySlot('sidebar.files.navigator.toggle', { useTabInfo }) +}) + +/** + * Props of {@link PreviewFrame}. `banner`, `tools`, `handoff`, and `children` are + * ReactNode values passed between Components of this package; no slot owner + * data or injected member carries them. + */ +export type PreviewFrameProps = NavigatorSeatProps & { + /** The path the header shows. */ + readonly path: string + /** Context menu handlers bound to the header path. */ + readonly pathTrigger: ContextMenuTriggerProps + /** A notice above the header. */ + readonly banner?: ReactNode + /** Controls between the path and the file handoff. */ + readonly tools?: ReactNode + /** The contributions that hand the file to the Host, in the same position in every state, before the navigator toggle. */ + readonly handoff?: ReactNode + /** The content before the navigator. */ + readonly children: ReactNode + /** Data attributes forwarded to the root element. */ + readonly [attribute: `data-${string}`]: string | undefined +} + +/** + * The text preview's layout for every preview state. + * @param props - the header and content parts, the navigator inputs, and the root's data attributes. + * @returns the preview root. + */ +export function PreviewFrame({ + path, pathTrigger, banner, tools, handoff, children, useTabInfo, renderFactorySlot, ...data +}: PreviewFrameProps): ReactNode { + return ( +
+ {banner} +
+ + {tools} + {handoff} + +
+
+ {children} + +
+
+ ) +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css index 72b808750c..5a3c128bd1 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css @@ -5,10 +5,32 @@ display: flex; flex: 1 1 auto; flex-direction: column; + min-width: 0; height: 100%; min-height: 0; } +/* Everything below the header: the body, then the navigator another plugin + contributes in a docked or floating pane. The navigator either narrows the + body or positions an overlay against this row, so the header always spans + the pane. */ +.content { + position: relative; + /* Child layers stay below the floating pane's resize handle. */ + isolation: isolate; + display: flex; + flex: 1 1 auto; + min-width: 0; + min-height: 0; +} + +/* Renderers' sticky toolbars stack inside the body, below an overlaying navigator. */ +.content > :first-child { + isolation: isolate; + flex: 1 1 auto; + min-width: 0; +} + /* One 38px row flush under the pane's strip: the path, then the type's controls at its end. With the strip's 38px above, its rule lies where the conversation header's does (ui-conversation ConversationRoot `.header`, @@ -54,6 +76,8 @@ margin: 0; padding: 0; overflow: auto; + /* The remembered offset is a pixel distance; browser anchoring would move it when content above it reflows. */ + overflow-anchor: none; /* The notice and retry surfaces in this sheet are elevated, so the file body's scroller rebinds the thumb indirection in a complete pair. */ --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); @@ -216,12 +240,14 @@ empty states below instead of sitting in the corner. */ .status { display: flex; + flex: 1 1 auto; flex-direction: column; gap: 8px; align-items: center; justify-content: center; box-sizing: border-box; height: 100%; + min-height: 0; padding: 12px 10px; } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx index 3f50163470..ca6ddbec03 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx @@ -8,20 +8,27 @@ * workspace unknown — takes the same bar's place over the pages already loaded, * with the same reload. The type's controls, viewer choice, wrap and reload, sit at the end of * the path row; the Sidebar's strip carries none of them. + * + * Every state renders inside `PreviewFrame`, which places the workspace + * navigator and its header toggle from the files Factories, in docked and + * floating panes. Both render empty when unregistered and stay mounted across + * loading states. A document layer handling Escape calls `preventDefault()` to + * keep the navigator from also closing. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ReactNode } from 'react' import clsx from 'clsx' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' -import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import type { InjectFace, PropsLocale, PropsRenderFactories, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import { FileTypeIcon, IconNowrapFillRegular, IconPauseOutlineRegular, IconPlayOutlineRegular, - IconRefreshOutlineRegular, IconWrapFillRegular, Menu, PathLabel, Tooltip, classifyFileType, + IconRefreshOutlineRegular, IconWrapFillRegular, Menu, Tooltip, classifyFileType, } from '@deepseek-ai/dsh-client-ui-primitives' import { pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path' import type { TextInjected } from './face.ts' import { emptyFailureRecourse, failureLine } from './failure-line.ts' import { LoadingIndicator } from './LoadingIndicator.tsx' +import { PreviewFrame } from './PreviewFrame.tsx' import { hostFileOf } from './rpc.ts' import type { TextStore } from './store.ts' import type { DocumentContent } from './document/contract.ts' @@ -43,7 +50,11 @@ export interface TextPreviewInjected extends TextInjected { /** The body's composed props: the tab, its navigation, the shared store and face, and copy. */ export type TextPreviewProps = & PropsRuntime<'sidebar.right.pane.tab'> - & PropsRenderSlots<'sidebar.right.tab.document' | 'sidebar.right.tab.document.action' | 'sidebar.right.tab.document.actions' | 'sidebar.right.tab.document.unpreviewable'> + & PropsRenderSlots< + | 'sidebar.right.tab.document' | 'sidebar.right.tab.document.action' | 'sidebar.right.tab.document.actions' + | 'sidebar.right.tab.document.unpreviewable' + > + & PropsRenderFactories & PropsStore & InjectFace & PropsLocale<'sidebarDocumentPreview'> @@ -55,7 +66,7 @@ export type TextPreviewProps = */ export function TextPreview({ useTabInfo, useResource, useStore, actions, loadPage, reloadPages, - loadAll, reloadAll, prepareRenderer, useDocumentPreviews, renderSlot, t, + loadAll, reloadAll, prepareRenderer, useDocumentPreviews, renderSlot, renderFactorySlot, t, addResource, setResources, useContextMenu, sessionId, }: TextPreviewProps): ReactNode { const { tab, panel } = useTabInfo() @@ -125,21 +136,31 @@ export function TextPreview({ }, [started, tab.id, file, signal, loadPage, loadAll, prepareRenderer, canRead, mode, selected, meta.value?.version]) // Come back where the reader was once there is content to scroll: on a remount, - // after a reload rebuilt the content, or after the selected renderer changed. - // Scroll writes preserve both identities, so they never re-land. + // after a reload rebuilt the content, after the selected renderer changed, or + // after another text page arrived. The offset only moves down to the remembered + // one, and the next page is read while the remembered offset lies past the + // loaded pages. A line navigation not answered yet positions the body itself. + // Scroll writes preserve every identity here, so they never re-land. useEffect(() => { const body = scrollportRef.current - if (hasContent && body !== null && state !== undefined) body.scrollTop = state.scrollTop - }, [hasContent, selected?.id]) + if (!hasContent || body === null || state === undefined) return + if (mode === 'text-pages' && line !== undefined && current?.revision !== navigation.revision) return + body.scrollTop = Math.max(body.scrollTop, state.scrollTop) + if (mode === 'text-pages' && body.scrollTop + 1 < state.scrollTop && current !== undefined + && !current.eof && !current.loading && current.failure === undefined && canRead) { + loadPage(tab.id, file, loadedThrough + 1, signal, meta.value?.version) + } + }, [hasContent, selected?.id, pages, current?.revision]) // Answer a navigation once: a line the pages do not reach yet loads the next // page (again, until the pages cover it or the file ends); a line they hold - // is scrolled to and marked with a fading band. The store remembers the - // answer, so a remount restores the reader's place instead. + // is scrolled to and marked with a fading band. A renderer without source-line + // anchors answers it without moving. The store remembers the answer, so a + // remount restores the reader's place instead. useEffect(() => { const body = scrollportRef.current if (current === undefined || body === null || current.revision === navigation.revision) return - if (line === undefined || mode !== 'text-pages') { + if (line === undefined || mode !== 'text-pages' || selected?.sourceLines !== true) { actions.navigated(tab.id, navigation.revision) return } @@ -195,18 +216,21 @@ export function TextPreview({ return { kind: 'text', pages: loaded, text: loaded.filter(page => page.lines > 0).map(page => page.text).join('\n'), eof: current.eof } }, [mode, loaded, current?.complete, current?.eof, current?.loadRevision, rendererReload, actions, tab.id]) + const frame = { + path: displayPath, + pathTrigger: getTriggerProps({ kind: 'file', value: { sessionId, path: displayPath, paneId: panel.id, ...(line === undefined ? {} : { line }) } }), + useTabInfo, + renderFactorySlot, + } + const handoff = fileOwner !== undefined && renderSlot('sidebar.right.tab.document.actions', fileOwner) + // A known binary suffix with no matching renderer never reads: no plain-text // fallback, no viewer control, only the path, the unsupported line, and the // contributions that hand the file to the Host. if (selected === undefined && unviewable) { const { name: unsupportedName } = pathPartsOf(displayPath) return ( -
-
- - {fileOwner !== undefined && renderSlot('sidebar.right.tab.document.actions', fileOwner)} -
+
@@ -214,16 +238,18 @@ export function TextPreview({ {fileOwner !== undefined && renderSlot('sidebar.right.tab.document.unpreviewable', fileOwner)}
-
+ ) } if (state === undefined || selected === undefined) { return ( -
- {meta.status === 'none' - ?

{t('resourceUnavailable')}

- : } -
+ +
+ {meta.status === 'none' + ?

{t('resourceUnavailable')}

+ : } +
+
) } const next = loadedThrough + 1 @@ -233,8 +259,12 @@ export function TextPreview({ loadPage(tab.id, file, next, signal, meta.value?.version) } return ( -
- {meta.failure !== undefined && hasContent +

)} -
- - {candidates.length > 1 - && ( - { setMenuOpen(value => !value) }}> - {selected.title()} - - )} - items={candidates.map(candidate => ({ id: candidate.id, label: candidate.title() }))} - selectedId={selected.id} - onSelect={(id) => { actions.selected(tab.id, id); setMenuOpen(false) }} - onClose={() => { setMenuOpen(false) }} - align="end" - portal - dense - /> + tools={( + <> + {candidates.length > 1 + && ( + { setMenuOpen(value => !value) }}> + {selected.title()} + + )} + items={candidates.map(candidate => ({ id: candidate.id, label: candidate.title() }))} + selectedId={selected.id} + onSelect={(id) => { actions.selected(tab.id, id); setMenuOpen(false) }} + onClose={() => { setMenuOpen(false) }} + align="end" + portal + dense + /> + )} + {selected.wrap === true && ( + // The tooltip names the action while the stable aria name and + // `aria-pressed` expose the control and its current state. + + + )} - {selected.wrap === true && ( - // The tooltip names the action while the stable aria name and - // `aria-pressed` expose the control and its current state. - + {content !== undefined && renderSlot('sidebar.right.tab.document.action', { content }, { entryKey: selected.id, hookContext: useTabInfo })} + + {/* Preview and file-tree refresh controls own different reload lifecycles and locale namespaces. */} + {/* jscpd:ignore-start */} + - )} - {content !== undefined && renderSlot('sidebar.right.tab.document.action', { content }, { entryKey: selected.id, hookContext: useTabInfo })} - - {/* Preview and file-tree refresh controls own different reload lifecycles and locale namespaces. */} - {/* jscpd:ignore-start */} - - - - {/* jscpd:ignore-end */} - {fileOwner !== undefined && renderSlot('sidebar.right.tab.document.actions', fileOwner)} -
+ {/* jscpd:ignore-end */} + + )} + handoff={handoff} + >
= body.scrollHeight - body.clientHeight - 1 + && body.scrollTop < state.scrollTop + if (!clamped) actions.scrolled(tab.id, body.scrollTop) if (mode === 'text-pages' && current?.failure === undefined && body.clientHeight > 0 && body.scrollTop + body.clientHeight >= body.scrollHeight - 1) loadNext() }} @@ -410,6 +445,6 @@ export function TextPreview({ )}
-
+ ) } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/code/index.ts b/packages/client/ui-sidebar-documentpreview/src/client/code/index.ts index eeceda36c4..c7748f1eb6 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/code/index.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/code/index.ts @@ -19,6 +19,7 @@ export function apply(ctx: Context): void { title: () => t('title'), loading: 'text-pages', wrap: true, + sourceLines: true, })) ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document', () => ctx.slots.register( { name: 'sidebar.right.tab.document', key: ID, locale: NS }, CodeBody, diff --git a/packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts b/packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts index de7c00c562..bcfdc15b75 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts @@ -25,6 +25,11 @@ export interface DocumentPreviewDefinition { readonly loading: DocumentLoadMode /** Whether the implementation consumes the document's wrap preference. */ readonly wrap?: boolean + /** + * Whether a `text-pages` body exposes source-line anchors for line navigation; + * without it, a navigation to a line is answered without moving the body. + */ + readonly sourceLines?: boolean } /** diff --git a/packages/client/ui-sidebar-documentpreview/src/client/text/index.ts b/packages/client/ui-sidebar-documentpreview/src/client/text/index.ts index 6a11cebf62..50b33dc8c3 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/text/index.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/text/index.ts @@ -13,7 +13,7 @@ export const PLAIN_BODY_ID = '@deepseek-ai/dsh-client-ui-sidebar-documentpreview * @returns plain-text registration metadata. */ export function textBodyDefinition(title: () => string): DocumentPreviewDefinition { - return { id: PLAIN_BODY_ID, extensions: [], priority: 'builtin', title, loading: 'text-pages', wrap: true } + return { id: PLAIN_BODY_ID, extensions: [], priority: 'builtin', title, loading: 'text-pages', wrap: true, sourceLines: true } } /** @param ctx - owning plugin context. Register the fallback metadata and keyed body. */ diff --git a/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts index b2b3d7e911..efdcaad691 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts @@ -43,6 +43,7 @@ interface Recorded { store?: unknown inject?: unknown component: unknown + children?: Record } async function boot() { @@ -104,6 +105,10 @@ describe('ui-sidebar-documentpreview apply', () => { ['sidebar.right.tab.document', '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/excel', 'sidebarExcel', LazyExcelBody], ['sidebar.right.tab.document', '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/code', 'sidebarCodePreview', CodeBody], ]) + expect(Object.keys(registered[0]!.children!)).toEqual([ + 'sidebar.right.tab.document', 'sidebar.right.tab.document.actions', + 'sidebar.right.tab.document.unpreviewable', 'sidebar.right.tab.document.action', + ]) expect(registered[0]?.store).toBeDefined() expect(typeof registered[0]?.inject).toBe('function') }) diff --git a/packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts b/packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts index 54323f0793..28dee1d768 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts @@ -141,8 +141,7 @@ export function harness(script: Record> onTestFinished(() => { controller.abort() }) const tabActions = { bindCommands: vi.fn(() => vi.fn()), openResource: vi.fn(), openTab: vi.fn(), close: vi.fn(), replace: vi.fn() } const definitions = [textBodyDefinition(() => t('viewer.text'))] - // The document seat renders the plain body; the file-handoff seats render a - // marker carrying what the owner handed them. + // The document slot renders the plain body; other positions mark their input. const renderSlot: TextPreviewProps['renderSlot'] = (key: string, owner: unknown, opts?: { hookContext?: unknown }) => key === 'sidebar.right.tab.document' ? createElement(TextBody, { @@ -155,7 +154,7 @@ export function harness(script: Record> const props = (navigation: { params?: unknown; revision: number } = { revision: 1 }) => ({ useTabInfo: () => ({ sidebar: { expanded: true, fullscreen: false }, - panel: { id: 'pane-1' }, + panel: { id: 'pane-1', isFloating: false }, tab: { id: tabId, kind: 'text', contentId: ADDRESS, title: 'notes.md', visible: true, navigation: { address: ADDRESS, params: navigation.params, revision: navigation.revision }, @@ -175,6 +174,7 @@ export function harness(script: Record> addResource: face.addResource, setResources: face.setResources, useDocumentPreviews: () => definitions, renderSlot, + renderFactorySlot: (key: string) => createElement('div', { 'data-navigator-factory': key }), t, }) as TextPreviewProps return { diff --git a/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx index 738ec72f6d..fb24a887b4 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx @@ -13,6 +13,7 @@ import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import type { FileNavigatorInput } from '@deepseek-ai/dsh-client-ui-sidebar-files/client' import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' import type { OwnerOf } from '@deepseek-ai/dsh-client-ui-slots' import { TextPreview } from '../src/client/TextPreview.tsx' @@ -99,7 +100,7 @@ class PendingIntersectionObserver { function codeProps(h: ReturnType, navigation: { params?: unknown; revision: number }): TextPreviewProps { const props = h.props(navigation) const definition: DocumentPreviewDefinition = { - id: 'code', extensions: ['md'], title: () => 'Code', loading: 'text-pages', wrap: true, + id: 'code', extensions: ['md'], title: () => 'Code', loading: 'text-pages', wrap: true, sourceLines: true, } return { ...props, @@ -492,36 +493,85 @@ describe('TextPreview — navigation and view', () => { expect(h.instance.getSnapshot().byTab[TAB_ID]?.scrollTop).toBe(360) }) - it.each([ - ['Code', 'code', 2 * LINE_HEIGHT], - ['Plain text', PLAIN_BODY_ID, 2 * LINE_HEIGHT], - ])('retries a Markdown line navigation after switching to %s', async (_name, rendererId, expectedScrollTop) => { - PendingIntersectionObserver.instances = [] - vi.stubGlobal('IntersectionObserver', PendingIntersectionObserver) - const h = harness({ 1: page(1, ['a', 'b', 'c'], true) }) - const definitions: DocumentPreviewDefinition[] = [ - { id: 'markdown', extensions: ['md'], title: () => 'Markdown', loading: 'text-pages', wrap: false }, - { id: 'code', extensions: ['md'], title: () => 'Code', loading: 'text-pages', wrap: true }, - { id: PLAIN_BODY_ID, extensions: [], title: () => 'Plain text', loading: 'text-pages', wrap: true }, - ] - const base = h.props({ params: { line: 3 }, revision: 1 }) - const props: TextPreviewProps = { + const CODE_DEFINITION: DocumentPreviewDefinition = { + id: 'code', extensions: ['md'], title: () => 'Code', loading: 'text-pages', wrap: true, sourceLines: true, + } + const PLAIN_DEFINITION: DocumentPreviewDefinition = { + id: PLAIN_BODY_ID, extensions: [], title: () => 'Plain text', loading: 'text-pages', wrap: true, sourceLines: true, + } + + /** + * Offer `definitions` for a Markdown file; plain text renders lines, Code renders lines unless `codeDrawsLines` is false, + * and every other renderer draws no source lines. + */ + function rendererProps( + h: ReturnType, + navigation: { params?: unknown; revision: number }, + definitions: DocumentPreviewDefinition[], + codeDrawsLines: boolean, + ): TextPreviewProps { + const base = h.props(navigation) + return { ...base, useDocumentPreviews: selector => selector(definitions), renderSlot: documentSlots((_key, owner, opts) => { const documentOwner = owner as unknown as OwnerOf<'sidebar.right.tab.document'> - if (opts.entryKey === 'code') return key} /> + if (opts.entryKey === 'code' && codeDrawsLines) return key} /> if (opts.entryKey === PLAIN_BODY_ID) return return
}), } + } + + /** A Markdown file that also offers Code and plain text; only the latter two expose source lines. */ + function markdownProps(h: ReturnType, navigation: { params?: unknown; revision: number }): TextPreviewProps { + const markdown: DocumentPreviewDefinition = { id: 'markdown', extensions: ['md'], title: () => 'Markdown', loading: 'text-pages', wrap: false } + return rendererProps(h, navigation, [markdown, CODE_DEFINITION, PLAIN_DEFINITION], true) + } + + it.each([['Code', 'code'], ['Plain text', PLAIN_BODY_ID]])( + 'answers a line navigation in Markdown without moving, and switching to %s does not jump later', + async (_name, rendererId) => { + PendingIntersectionObserver.instances = [] + vi.stubGlobal('IntersectionObserver', PendingIntersectionObserver) + const h = harness({ 1: page(1, ['a', 'b', 'c'], true) }) + const view = render() + await settle() + expect(body(view.container).scrollTop).toBe(0) + expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(1) + act(() => { h.instance.actions.selected(TAB_ID, rendererId) }) + await settle() + expect(scrollport(view.container).scrollTop).toBe(0) + expect(marks(view.container)).toEqual([]) + }, + ) + + it('restores the reading position of a Markdown tab opened at a line, on a remount and after a reload', async () => { + const h = harness({ 1: page(1, ['a', 'b', 'c'], true) }) + const navigation = { params: { line: 3 }, revision: 1 } + const first = render() + await settle() + fireEvent.scroll(body(first.container), { target: { scrollTop: 300 } }) + first.unmount() + const second = render() + await settle() + expect(body(second.container).scrollTop).toBe(300) + h.script(1, page(1, ['A', 'B', 'C'], true, 'v2')) + click(second.container, '[data-textpreview-tool="reload"]') + body(second.container).scrollTop = 0 + await settle() + expect(body(second.container).scrollTop).toBe(300) + }) + + it('keeps a line navigation pending while a source-line renderer has not drawn the line', async () => { + const h = harness({ 1: page(1, ['a', 'b', 'c'], true) }) + const props = rendererProps(h, { params: { line: 3 }, revision: 1 }, [CODE_DEFINITION, PLAIN_DEFINITION], false) const view = render() await settle() - expect(body(view.container).scrollTop).toBe(0) expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBeUndefined() - act(() => { h.instance.actions.selected(TAB_ID, rendererId) }) + act(() => { h.instance.actions.selected(TAB_ID, PLAIN_BODY_ID) }) await waitFor(() => { expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(1) }) - expect(scrollport(view.container).scrollTop).toBe(expectedScrollTop) + expect(body(view.container).scrollTop).toBe(2 * LINE_HEIGHT) }) it('lands on code lines before and after syntax highlighting is ready', async () => { @@ -726,3 +776,210 @@ it('opens a path menu with the preview pane and requested line', async () => { sessionId: SESSION, path: ABSOLUTE_PATH, line: 42, paneId: 'pane-1', } }, { x: 0, y: 0 }) }) + +describe('TextPreview — reading position across reloads', () => { + const VIEW_HEIGHT = 100 + const numbered = (from: number, count: number): string[] => Array.from({ length: count }, (_, i) => `l${from + i}`) + + /** Lay the body out like a browser: one line height per line, offsets clamped to the content. */ + function layOut(element: HTMLElement): void { + const max = (): number => Math.max(0, element.querySelectorAll('[data-textpreview-line]').length * LINE_HEIGHT - VIEW_HEIGHT) + let top = 0 + Object.defineProperty(element, 'clientHeight', { configurable: true, get: () => VIEW_HEIGHT }) + Object.defineProperty(element, 'scrollHeight', { configurable: true, get: () => max() + VIEW_HEIGHT }) + Object.defineProperty(element, 'scrollTop', { + configurable: true, + get: () => { top = Math.min(top, max()); return top }, + set: (value: number) => { top = Math.max(0, Math.min(value, max())) }, + }) + } + + it('returns to an offset past the first page after a reload emptied the body', async () => { + const h = harness({ 1: page(1, numbered(1, 10), false), 11: page(11, numbered(11, 10), true) }) + const view = render() + await settle() + const scrolled = body(view.container) + layOut(scrolled) + click(view.container, '[data-textpreview-more]') + await settle() + fireEvent.scroll(scrolled, { target: { scrollTop: 300 } }) + h.script(1, page(1, numbered(1, 10), false, 'v2')) + h.script(11, page(11, numbered(11, 10), true, 'v2')) + click(view.container, '[data-textpreview-tool="reload"]') + // The emptied body pins the browser's offset to 0 and reports it as a scroll. + expect(scrolled.scrollTop).toBe(0) + fireEvent.scroll(scrolled) + expect(h.instance.getSnapshot().byTab[TAB_ID]?.scrollTop).toBe(300) + await settle() + expect(h.read).toHaveBeenLastCalledWith(SESSION, PATH, 11, h.controller.signal) + expect(scrolled.scrollTop).toBe(300) + }) + + it('keeps the remembered offset while a shorter file holds the view at its end', async () => { + const h = harness({ 1: page(1, numbered(1, 20), true) }) + const view = render() + await settle() + const scrolled = body(view.container) + layOut(scrolled) + fireEvent.scroll(scrolled, { target: { scrollTop: 300 } }) + h.script(1, page(1, numbered(1, 10), true, 'v2')) + h.setVersion('v2') + view.rerender() + await settle() + fireEvent.scroll(scrolled) + expect(scrolled.scrollTop).toBe(100) + expect(h.instance.getSnapshot().byTab[TAB_ID]?.scrollTop).toBe(300) + h.script(1, page(1, numbered(1, 20), true, 'v3')) + h.setVersion('v3') + view.rerender() + await settle() + expect(scrolled.scrollTop).toBe(300) + }) + + it('remembers where the reader scrolls up to, and the next reload keeps that place', async () => { + const h = harness({ 1: page(1, numbered(1, 20), true) }) + const view = render() + await settle() + const scrolled = body(view.container) + layOut(scrolled) + fireEvent.scroll(scrolled, { target: { scrollTop: 300 } }) + fireEvent.scroll(scrolled, { target: { scrollTop: 120 } }) + expect(h.instance.getSnapshot().byTab[TAB_ID]?.scrollTop).toBe(120) + h.script(1, page(1, numbered(1, 20), true, 'v2')) + click(view.container, '[data-textpreview-tool="reload"]') + fireEvent.scroll(scrolled) + await settle() + expect(scrolled.scrollTop).toBe(120) + }) +}) + +describe('TextPreview — workspace navigator Factories', () => { + /** The navigator occurrences a render holds: the header's toggle, and the content row's navigator after the body. */ + function seats(container: HTMLElement) { + const row = container.querySelector('[data-textpreview-content]') + const header = container.querySelector('[data-textpreview-path]')?.parentElement + return { + toggle: header?.lastElementChild?.getAttribute('data-navigator-factory'), + navigator: row?.lastElementChild?.getAttribute('data-navigator-factory'), + } + } + + it('keeps the Factory positions\' and the handoff\'s DOM identity while the preview goes from waiting to loaded', async () => { + const h = harness({ 1: page(1, ['a'], true) }) + const props = h.props() + const renderFactorySlot = vi.fn(props.renderFactorySlot) + const none: TextPreviewProps['useDocumentPreviews'] = select => select([]) + const view = render() + expect(renderFactorySlot.mock.calls).toEqual([ + ['sidebar.files.navigator.toggle', { useTabInfo: props.useTabInfo }], + ['sidebar.files.navigator', { useTabInfo: props.useTabInfo, page: false, onOpen: expect.any(Function) as FileNavigatorInput['onOpen'] }], + ]) + expect(view.container.querySelector('[data-textpreview-state="loading"]')).not.toBeNull() + expect(seats(view.container)).toEqual({ + toggle: 'sidebar.files.navigator.toggle', navigator: 'sidebar.files.navigator', + }) + const navigator = view.container.querySelector('[data-textpreview-content] > [data-navigator-factory]')! + const toggle = view.container.querySelector('[data-textpreview-path]')?.parentElement?.lastElementChild + const handoff = view.container.querySelector('[data-slot="sidebar.right.tab.document.actions"]') + expect(handoff?.getAttribute('data-slot-path')).toBe(ABSOLUTE_PATH) + expect(toggle?.previousElementSibling).toBe(handoff) + navigator.tabIndex = 0 + navigator.focus() + navigator.scrollTop = 64 + view.rerender() + await settle() + expect(view.container.querySelector('[data-textpreview-state="text"]')).not.toBeNull() + expect(seats(view.container)).toEqual({ + toggle: 'sidebar.files.navigator.toggle', navigator: 'sidebar.files.navigator', + }) + expect(view.container.querySelector('[data-textpreview-content] > [data-navigator-factory]')).toBe(navigator) + expect(view.container.querySelector('[data-textpreview-path]')?.parentElement?.lastElementChild).toBe(toggle) + // The handoff keeps its seat between the tools and the toggle in every state. + expect(view.container.querySelector('[data-slot="sidebar.right.tab.document.actions"]')).toBe(handoff) + expect(toggle?.previousElementSibling).toBe(handoff) + expect(document.activeElement).toBe(navigator) + expect(navigator.scrollTop).toBe(64) + }) + + it.each([false, true])('owns the navigator open callback and replaces itself (floating: %s)', async (isFloating) => { + const h = harness({ 1: page(1, ['a'], true) }) + const props = h.props() + const info = props.useTabInfo() + const openResource = vi.fn() + const useTabInfo: TextPreviewProps['useTabInfo'] = () => ({ + ...info, panel: { ...info.panel, isFloating }, tab: { ...info.tab, actions: { ...info.tab.actions, openResource } }, + }) + let open: ((address: string) => void) | undefined + const renderFactorySlot: TextPreviewProps['renderFactorySlot'] = (name, input) => { + if (name === 'sidebar.files.navigator') open = (input as FileNavigatorInput).onOpen + return null + } + render() + await settle() + expect(open).toBeTypeOf('function') + open!('dsh-resource://file/session/s-1/b.txt') + expect(openResource).toHaveBeenCalledExactlyOnceWith('dsh-resource://file/session/s-1/b.txt', { replaceTab: true }) + }) + + it('starts a text preview without a notice with its header', async () => { + const h = harness({ 1: page(1, ['a'], true) }) + const view = render() + await settle() + const preview = view.container.querySelector('[data-textpreview-state="text"]') + expect(preview?.firstElementChild?.contains(view.container.querySelector('[data-textpreview-path]'))).toBe(true) + expect(view.container.querySelector('[data-textpreview-changed], [data-textpreview-meta-failed]')).toBeNull() + }) + + it('keeps the navigator\'s and the toggle\'s DOM nodes from unsupported through loading to text', async () => { + const h = harness({ 1: page(1, ['a'], true) }) + const base = h.props() + const info = base.useTabInfo() + const address = 'dsh-resource://file/session/s-1/work/clip.mp4' + const useTabInfo: TextPreviewProps['useTabInfo'] = () => ( + { ...info, tab: { ...info.tab, contentId: address, navigation: { ...info.tab.navigation, address } } } + ) + const video: DocumentPreviewDefinition = { id: 'video', extensions: ['mp4'], title: () => 'Video', loading: 'text-pages' } + const none: TextPreviewProps['useDocumentPreviews'] = select => select([]) + const some: TextPreviewProps['useDocumentPreviews'] = select => select([video]) + const props = { ...base, useTabInfo } + const view = render() + await settle() + const nodes = () => ({ + navigator: view.container.querySelector('[data-textpreview-content]')?.lastElementChild, + toggle: view.container.querySelector('[data-textpreview-path]')?.parentElement?.lastElementChild, + }) + expect(view.container.querySelector('[data-textpreview-state="unsupported"]')).not.toBeNull() + const first = nodes() + expect(first.navigator?.getAttribute('data-navigator-factory')).toBe('sidebar.files.navigator') + expect(first.toggle?.getAttribute('data-navigator-factory')).toBe('sidebar.files.navigator.toggle') + const live = h.useResource() + h.useResource.mockReturnValue({ status: 'none', value: undefined, failure: undefined }) + view.rerender() + await settle() + expect(view.container.querySelector('[data-textpreview-state="loading"]')).not.toBeNull() + expect(nodes().navigator).toBe(first.navigator) + expect(nodes().toggle).toBe(first.toggle) + h.useResource.mockReturnValue(live) + view.rerender() + await settle() + expect(view.container.querySelector('[data-textpreview-state="text"]')).not.toBeNull() + expect(nodes().navigator).toBe(first.navigator) + expect(nodes().toggle).toBe(first.toggle) + }) + + it('does not re-render the navigator Factories for a preview-only update', async () => { + const h = harness({ 1: page(1, ['a', 'b'], true) }) + const props = h.props() + const renderFactorySlot = vi.fn(props.renderFactorySlot) + const view = render() + await settle() + renderFactorySlot.mockClear() + h.useResource.mockClear() + fireEvent.scroll(body(view.container), { target: { scrollTop: 40 } }) + expect(h.instance.getSnapshot().byTab[TAB_ID]?.scrollTop).toBe(40) + // The preview read its metadata again, so it re-rendered. + expect(h.useResource).toHaveBeenCalled() + expect(renderFactorySlot).not.toHaveBeenCalled() + }) + +}) diff --git a/packages/client/ui-sidebar-documentpreview/tsconfig.client.json b/packages/client/ui-sidebar-documentpreview/tsconfig.client.json index 5ed28d13eb..32f9770fc9 100644 --- a/packages/client/ui-sidebar-documentpreview/tsconfig.client.json +++ b/packages/client/ui-sidebar-documentpreview/tsconfig.client.json @@ -44,6 +44,9 @@ { "path": "../ui-session" }, + { + "path": "../ui-sidebar-files" + }, { "path": "../ui-sidebar-right" }, diff --git a/packages/client/ui-sidebar-files/README.i18n.yaml b/packages/client/ui-sidebar-files/README.i18n.yaml index de01926f07..d420350ecc 100644 --- a/packages/client/ui-sidebar-files/README.i18n.yaml +++ b/packages/client/ui-sidebar-files/README.i18n.yaml @@ -3,23 +3,29 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-files/README.md /: - en: d6514e0d092a210b - zh: 9980cb0f4fc6f903 + en: d7dbdeee1744fac0 + zh: 35dc07a9e797f855 /deepseek-ai-dsh-client-ui-sidebar-files: en: ae5625c23b3a864f zh: 20cf48a229e806bf /deepseek-ai-dsh-client-ui-sidebar-files/summary: - en: 1605ae92aee5c231 - zh: ad519ddb9e338892 + en: 27a736e484be8693 + zh: 4778c310014c2c16 /deepseek-ai-dsh-client-ui-sidebar-files/table-of-contents: - en: 9411fb4809692e09 - zh: dd13990d39c5be7f + en: 94e520881bd8666e + zh: 869ed79bd370c8b3 /deepseek-ai-dsh-client-ui-sidebar-files/what-it-registers: - en: ed52405be7c3fa24 - zh: d06dbd49c786c5f0 + en: 2b620997cf39cac8 + zh: ddfaa2da1c1bcd55 +/deepseek-ai-dsh-client-ui-sidebar-files/the-navigator: + en: 803d10142360264b + zh: 042568a8ba293037 /deepseek-ai-dsh-client-ui-sidebar-files/the-tree: - en: 4d14a19bbccca137 - zh: 28fb52f159deb007 + en: c6d5d2d068bdec7b + zh: dd41bcfa3e9080ad +/deepseek-ai-dsh-client-ui-sidebar-files/reusable-tree-factory: + en: 0a3e9645426b4203 + zh: 0478669e4157f15c /deepseek-ai-dsh-client-ui-sidebar-files/model-experience: en: a18a43f6842f1371 zh: 69f2a2b35f06dfc7 @@ -27,8 +33,8 @@ en: 5a6febb45089991a zh: b17ed755fd098b83 /deepseek-ai-dsh-client-ui-sidebar-files/known-limitations-and-deferred-work: - en: 0221377b59299a3c - zh: abdacebf73b75390 + en: dd317b62980a9269 + zh: 0be3b9924f6b6c67 /deepseek-ai-dsh-client-ui-sidebar-files/known-limitations-and-deferred-work/dev-note: - en: d8b51fb935f38650 - zh: fee9d330ecf8ec0e + en: 1ec99e9fb6b4c948 + zh: c4f7883934307c3c diff --git a/packages/client/ui-sidebar-files/README.md b/packages/client/ui-sidebar-files/README.md index 66d845524f..7443828930 100644 --- a/packages/client/ui-sidebar-files/README.md +++ b/packages/client/ui-sidebar-files/README.md @@ -1,5 +1,5 @@ --- -description: "The right Sidebar's file-tree tab type for the dsh web client: the session workspace root listed one level at a time over the wire, opening files into the Sidebar by resource address." +description: "The right Sidebar's workspace file browser for the dsh web client: the files page and one workspace tree per pane beside docked and floating file previews, listed one level at a time over the wire." kind: "package-reference" --- @@ -9,12 +9,14 @@ English | [中文](README.zh.md) ## Summary -Browse a Session's workspace tree and open files in Sidebar previews. The root and expanded directories refresh automatically from direct-entry watches; manual reload remains available. The tab is reached from the guide and claims no resource address. +Browse a Session's workspace tree beside the files you preview. Each pane, docked or floating, keeps one tree that every file tab in it shows: switching, opening, or closing files keeps its width, expansion, and scroll, and the tree marks the current file. A preview's tree opens a file in place of that preview; the `files` page opens it in a new tab where the right Sidebar places it by default. The root and expanded directories refresh automatically from direct-entry watches; manual reload remains available. The `files` page is reached from the guide and claims no resource address. ## Table of Contents - [What it registers](#what-it-registers) +- [The navigator](#the-navigator) - [The tree](#the-tree) +- [Reusable tree Factory](#tree-factory) - [Model Experience](#model-experience) - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) - [Dev Note](#dev-note) @@ -24,33 +26,62 @@ Browse a Session's workspace tree and open files in Sidebar previews. The root a ## What it registers -The `workspace.files` command opens or focuses the file page in the focused pane; from the conversation it uses the current Session's active dock pane. A guide gives way to the file page, and repeated opens retain one file page per pane. Desktop defaults to Mod+P. The guide entry displays the effective shortcut; Windows and macOS Web use the [shortcut service’s platform defaults](../shortcuts/README.md); Linux Web leaves this command unbound by default. +The `workspace.files` command shows the navigator drawn beside a docked tab's content in the focused pane and moves keyboard focus to its tree, keeping the tab; from the conversation it uses the current Session's active dock pane. Elsewhere — the files page itself, a tab without the navigator, a Session without a working directory, a floating tab — it opens or focuses the files page, whose tree always shows. A guide gives way to the files page, and repeated opens retain one files page per pane. Desktop defaults to Mod+P. The guide entry displays the effective shortcut; Windows and macOS Web use the [shortcut service’s platform defaults](../shortcuts/README.md); Linux Web leaves this command unbound by default. - **The type** — `ctx.sidebarRightTabs.register(...)` with kind `files`, id `@deepseek-ai/dsh-client-ui-sidebar-files`, band `builtin`, no patterns, and one guide entry (order 10, its title and description from the `sidebarFiles` namespace, its glyph a yellow folder) that opens the type. -- **The body** — the keyed `sidebar.right.pane.tab` seat under that id: a header row under the strip, then the tree. The shared [`PathLabel`](../ui-primitives/README.md#component-catalog) displays the root path with subdued directories and a primary final segment. A clipped path retains its trailing characters with a left-edge fade; hovering reveals the full path. The reload control and the Session-scoped `sidebar.right.tab.files.actions` list stay at its right, with the displayed root passed as `absolutePath`; [`ui-open-in-app`](../ui-open-in-app/README.md) contributes the workspace directory opener. +- **The page body** — the keyed `sidebar.right.pane.tab` seat under that id. Its header row spans the page and shows the root through the shared [`PathLabel`](../ui-primitives/README.md#component-catalog) with subdued directories and a primary final segment; a clipped path retains its trailing characters with a left-edge fade, and hovering reveals the full path. The tree's reload control, the Session-scoped `sidebar.right.tab.files.actions` list, which the page's registration declares, with the displayed root passed as `absolutePath`, follow at its end, in the order a preview's header ends with its reload and file actions; [`ui-open-in-app`](../ui-open-in-app/README.md) contributes the workspace directory opener. The pane's navigator fills the page below the header, docked or floating, and has no toggle there. +- **The rows** — the root-scoped Component Factory `sidebar.files.tree`, with caller-owned entries and a local `accessory` slot; see [Reusable tree Factory](#tree-factory). +- **The navigator** — the Session-scoped Component Factory `sidebar.files.navigator`, rendered directly by the page body and [`ui-sidebar-documentpreview`](../ui-sidebar-documentpreview/README.md) beside every preview, docked or floating. It draws the tree and its resize edge without a header of its own: beside a preview the preview's header is the only one, and the tree's reload and directory actions stay on the `files` page. +- **The toggle** — the Session-scoped Factory `sidebar.files.navigator.toggle`, rendered directly at the end of the preview header with `useTabInfo`. Both Factories share the page's store handle and inject face, retaining one open choice per pane; the page body has no toggle. - **The chip title** — the keyed `sidebar.right.pane.tab.title` seat under that id: a shared `FileTypeIcon` folder glyph at 16px before the type's label. The tree's own rows never draw this sheet. -Source files under `src/client/`: `definition.tsx` (the type), `store.ts` (what it keeps), `face.ts` (Remote reads and watches), `directory-node.ts` (open directories and their lifetimes), `FilesBody.tsx` (what it draws, with its ordering and failure-line helpers), `FilesTitle.tsx` (the chip title), `locales.ts` (what it says), and `index.ts` (the wiring). +The [registration](src/client/index.ts) assembles the page, navigator, toggle, and tree Factories over the shared [files face](src/client/face.ts) and [pane store](src/client/store.ts). + + +## The navigator + +Render `sidebar.files.navigator` with `FileNavigatorInput`: `{ useTabInfo, page, onOpen }`. The required `onOpen: (address: string) => void` callback belongs to the containing plugin; the navigator builds the file address without choosing placement. `page` controls presentation only: `true` always fills the files page, while `false` follows the preview's pane preference and available width. + +The navigator is drawn below the preview's header, which spans the pane, at the right of the row it shares with the body. Beside a preview, its pane's record decides whether it shows: a pane starts with it hidden, and the choice holds for every file tab of the pane until the toggle, Escape, the workspace command, or navigation to a covered preview changes it. A pane without a record of its own, such as a floating panel a tab was dragged out into, starts hidden at the default width. A click outside the tree does not hide it, and opening content in another pane does not hide it either. On the files page the tree fills the page below the header, docked or floating, without a separator, and always shows: the page draws no toggle and Escape leaves it. A file the page opens starts with the destination pane's own choice, hidden for a pane that has made none. + +Its width is the pane's preference, 260px until the reader drags the separator on its left edge with the primary button or moves it with the arrow keys, Home (narrowest), or End (widest), clamped to the row: at least 180px when the row allows it, and at most the row's width less 336px where the navigator sits beside the body and less 120px where it floats. Beside a preview in a viewport at least 768px wide, the row's width, measured by the face, picks one of three layouts. From 560px on, the navigator is a raised panel beside the body, 8px inside the row's top, right, and bottom edges and 8px clear of the body, which narrows by the panel and its margins but keeps at least 320px. From 400px up to 560px, the same panel floats over the body's right part, so the body keeps its full width and the part the panel leaves uncovered stays scrollable and interactive; the panel covers the body's scrollbar and a renderer's sticky toolbar where they run beneath it. Both panels have rounded corners, the floating dock panel's surface and shadow in light mode, and the compact tree's dark background with a soft white shadow in dark mode. The 560px threshold depends on the row alone, so resizing the tree never moves it between the two layouts. In a row narrower than 400px, or a viewport narrower than 768px, where the right Sidebar presents its panel fullscreen, the navigator fills the row below the header without the separator, and navigation to that preview hides the tree so the file shows. While filled, the covered body is hidden from keyboard navigation, including embedded frames. If the body holds focus when the row or viewport enters this layout, including the tree's first drawing, focus moves into the tree without changing the pane's open choice. The browser may blur the hidden body before the new width is reported; that focus also moves into the tree unless the reader moves the keyboard or the pointer, or the window loses focus, before the report. Docked and floating panes follow the same rules; the floating pane's resize handle remains reachable with the tree open in every layout. Beside a preview, an unmodified Escape that reaches the page's document from the body or the tree in the row closes the navigator at any row width and moves keyboard focus to the toggle; a key pressed while an embedded frame's document holds focus does not bubble to the page's document, so it leaves the navigator open. Escape is heard on the document after the row's own handlers, so a handler in the row that prevents it keeps it. + +The preview's `onOpen` passes [`replaceTab: true`](../ui-sidebar-right/README.md#ctxsidebarright); the files page passes `{ paneId: panel.id }` when docked and omits placement when floating. The right Sidebar owns replacement, default docked placement, and reveal. A replacement retains the pane's tree. When the tree holds keyboard focus and an open replaces or switches the tab in the same pane, the next drawing focuses the current file's row; revealing another pane leaves the initiating tree and its focus unchanged. + +A new `tab.navigation.revision` closes only the destination preview's tree, and only when it covers the content row at the destination's current width. The rule applies to every navigation source, including an open from that floating preview's own tree. Hidden trees consume navigation too, so manually showing one does not replay an old close. Ordinary tab switches, floating or docking, and revision `0` restored by reload or undo do not close the tree. The files face retains consumption records for the tab lifetime; navigation subscriptions end on tab closure or plugin disposal. The page's always-visible tree is not closed by navigation. + +The tree marks the row of the current file with `aria-current`. While the tree is drawn, a file not yet revealed in the pane has its ancestors expanded one listed level at a time and its row scrolled into view; a kept level that is being listed again, as when a hidden tree shows or a collapsed directory reopens, is waited for before a name missing from it ends the reveal; the pane remembers the file it revealed last, so a remount of the same tab changes neither expansion nor scroll. A directory the reader collapses during a reveal ends it, and the pane counts the file as revealed. A file outside the workspace root, or one of another Session, is not located; under a Windows drive or UNC root, containment ignores case and separator spelling, and under a POSIX root it is case-sensitive. ## The tree -The root is the session's working directory, read from `useSessions().byId[sessionId].cwd`. Filesystem roots such as `/` and Windows drive roots are valid tree roots. Every level is keyed by absolute path; a child's path is its parent's joined with the entry name by `/`. A level is listed when it is first expanded, through `remote.workspaceFiles.list(sessionId, absolutePath)` on the `@deepseek-ai/dsh-api-workspace-files` namespace; the adapter keeps the listing's entries and truncation flag and drops its workspace-relative path. Rows are ordered directories first, then by natural, case-insensitive name; dotfiles are shown like any other entry. +The root is the session's working directory, read from `useSessions().byId[sessionId].cwd`. Filesystem roots such as `/` and Windows drive roots are valid tree roots. Every level is keyed by absolute path; a child's path is its parent's joined with the entry name by `/`. A level is listed when it is first expanded, through `remote.workspaceFiles.list(sessionId, absolutePath)` on the `@deepseek-ai/dsh-api-workspace-files` namespace. The rows are drawn by the [tree Factory](#tree-factory), which orders them; `workspace-tree.ts` passes every listed entry with its listing's name and type as `meta.kind`, the root key without trailing separators, and localized failure lines. The Factory reads its own copy from the files locale. Dotfiles are shown like any other entry. | Entry type | Row | |---|---| | `directory` | Toggles; reopening lists again and restores still-present expanded descendants. Displayed entries remain cached while collapsed. | -| `file` | Opens `dsh-resource://file/session//`, built by `fileAddressFor` from `@deepseek-ai/dsh-util-workspace-path` from the entry's absolute path and the tree's root, through `useTabInfo().tab.actions.openResource`, landing in the tab's own pane. | +| `file` | Opens `dsh-resource://file/session//`, built by `fileAddressFor` from `@deepseek-ai/dsh-util-workspace-path` from the entry's absolute path and the tree's root, as [the navigator](#the-navigator) describes. While no registered tab type can open that address, for example without `ui-sidebar-documentpreview`, the row is shown greyed and not clickable with a line saying so, and it becomes clickable as soon as a type that opens it registers. | | `other` | Shown greyed and not clickable, so the directory is reported whole. | A level cut by the endpoint's entry cap ends with a marker; an empty level says so; a level that failed shows one line per code — `workspace-file/not-found`, `outside-workspace`, `not-directory` — and the transport's own message otherwise. Reload refreshes the root and expanded levels in place, retaining displayed entries during reads instead of resetting the whole tree; collapsed levels are fetched again when they next open. A session without a working directory shows a single line instead of a tree. -State lives in the type's own store, bucketed by tab id: `root`, `levels` (loading / ready / failed per absolute path), `expanded`, `autoRefresh`, and `scrollTop`, which the body tracks locally while scrolling and commits once when it unmounts. Because the store outlives the body, switching to another sidebar tab and back remounts the tree with its levels intact and its scroll offset restored. The owner's `signal` ends a bucket: on abort the tab is forgotten, and neither a listing that settles afterwards nor the unmount's offset commit writes anything. +State lives outside the drawing, keyed by the pane id that `useTabInfo().panel` reports. The type's own store holds, per pane, the navigator's `open` choice and `width`, and its `tree` once first shown: `root`, `levels` (loading / ready / failed per absolute path, a ready level marked `refreshing` while it is listed again), `expanded`, and `autoRefresh`. Every file tab of a pane reads the same record, so switching, opening, or closing tabs redraws the navigator with its levels, expansion, width, and scroll intact; a tab switch lists nothing again. Hiding the tree, or showing only tabs without it, closes its directory watches; showing it again lists the root and the expanded directories again in place while their kept rows stay drawn. A tree rooted at a directory other than the Session's current working directory is replaced, and its scroll position and revealed file are reset. A pane keeps its tree while it shows the navigator or holds an open tab that last drew the navigator there: a file tab moved to another pane shows that pane's tree, a tab replaced in place, as a picked file replaces a preview, keeps it, and floating or docking a tab never ends it by itself. Once the last such tab closes or draws the navigator elsewhere, the pane's tree is forgotten. The page and every preview's navigator share one face per Session. -Each open `DirectoryNode` owns its target watch for the Tab lifetime; collapse closes that node and its hidden descendants. Expansion changes during ancestor restoration update the store and pending nodes, so restored descendants follow the latest expansion preferences. Automatic refresh defaults to enabled; its separate toggle is hidden while state, labels, styles, and toggle logic remain. Changes received during a directory read or its completion remain pending for another refresh. +Each open `DirectoryNode` owns its target watch while the tree is drawn; collapse closes that node and its hidden descendants. Expansion changes during ancestor restoration update the store and pending nodes, so restored descendants follow the latest expansion preferences. Automatic refresh defaults to enabled; its separate toggle is hidden while state, labels, styles, and toggle logic remain. Changes received during a directory read or its completion remain pending for another refresh. File rows expose the shared [context menu](../ui-context-menu/README.md) without invoking ordinary click navigation. Directory rows and the root path use the `directory` target for native reveal and path copying without expanding the tree. + +## Reusable tree Factory + +To draw workspace listings, changed files, or local diffs, declare `const TREE_OPTIONS = { slots: { accessory: ModuleLevelComponent }, fallback: null }` at module scope, then call `renderFactorySlot('sidebar.files.tree', input, TREE_OPTIONS)`. Keep the options and its `slots` object stable; recreating the local selection makes the whole tree redraw on parent renders. The root-scoped Factory is available only while the files plugin is loaded; without it, `renderFactorySlot` renders the supplied `fallback`, or nothing when omitted. Import declarations and `FileTreeInput`, `TreeEntry`, `TreeEntryMeta`, `TreeEntryKind`, and `TreeDirectoryState` type-only from `@deepseek-ai/dsh-client-ui-sidebar-files/client`; no component value is exported. + +`FileTreeInput` carries `root`, flat `entries`, `expanded`, `onToggle(entry, expanded)`, and `onOpen(entry)`. Optional `directories` supplies loading, localized failure, and truncation states by key; `current` and `currentRef` mark and expose the current file button. `canOpen(entry)` can refuse a file without disabling its accessory. `rowTriggerProps(entry, kind)` supplies context menu handlers for directory rows and openable file rows. The Factory owns loading, empty, truncated, and unavailable copy through the files locale; callers own data access, expansion, opening, ancestor reveal, and scrolling. + +A `TreeEntry` is `{ path, name, meta?: { kind?, hidden?, size? } }`. Only `/` separates levels; the parent is the prefix before the last `/`, or `root` when there is none. Names remain caller-supplied; repeated paths keep the first entry. Root-key entries are ignored, including the empty root. Missing intermediate directories are added once, with explicit entries taking precedence. An omitted kind means directory when entries lie below that path, otherwise file. Hidden rows use subdued ink without filtering; size is not drawn. Only visible levels sort, directories first and then by natural case-insensitive name order; each level keeps its sorting cache until its names or directory kinds change. + +The local `accessory` slot has root scope and owner props `{ entry: TreeEntry }`; its Component may also use the derived `FactoryLocalComponentPropsOf<'sidebar.files.tree', 'accessory'>` seats. It receives only `entry` and the Factory's own seats, not the caller's store or inject face. Supply a module-level Component to preserve its identity. Its content sits beside the main button, never inside its action or disabled scope. The default Component renders nothing. The accessory wrapper always exists in the DOM; CSS hides it without taking row space when the slot is absent or its Component returns empty content. + ## Model Experience @@ -63,7 +94,7 @@ None; directory listings travel over the Remote and assemble no model request. ## Known Limitations and Deferred Work -- **Listing only.** No search, artifact filter, drag-and-drop, rename, or current-file highlight. +- **Listing only.** No search, artifact filter, drag-and-drop, or rename. - **One root.** The tree is rooted at the session's working directory; there is no way to browse above it, and the Host refuses paths outside the workspace root anyway. @@ -74,6 +105,6 @@ None; directory listings travel over the Remote and assemble no model request. None. -The page refresh shortcut refreshes the focused file tree through its ordinary directory reader. The reload control displays the effective binding on hover and keyboard focus. +The page refresh shortcut refreshes the files page's tree through its ordinary directory reader while the tree shows; on a preview it reloads the preview. The page's reload control displays the effective binding on hover and keyboard focus. diff --git a/packages/client/ui-sidebar-files/README.zh.md b/packages/client/ui-sidebar-files/README.zh.md index aefd2c6d08..69e40033ca 100644 --- a/packages/client/ui-sidebar-files/README.zh.md +++ b/packages/client/ui-sidebar-files/README.zh.md @@ -1,5 +1,5 @@ --- -description: "dsh Web 客户端右侧 Sidebar 的文件树 tab 类型:通过网络逐层列出会话工作区根目录,按资源地址把文件打开到 Sidebar。" +description: "dsh Web 客户端右侧 Sidebar 的工作区文件浏览器:文件页,以及停靠与浮动文件预览旁每个分栏一棵的工作区目录树,通过网络逐层列出。" kind: "package-reference" --- @@ -9,12 +9,14 @@ kind: "package-reference" ## 概述 -浏览 Session 的工作区目录树,并在 Sidebar 预览中打开文件。根目录与已展开目录通过直接子项监听自动刷新,手动重新读取仍可用。该 tab 从引导页进入,不认领资源地址。 +在预览文件的同时浏览 Session 的工作区目录树。每个分栏(停靠或浮动)保留一棵树,分栏内的每个文件 tab 都显示它:切换、打开或关闭文件时保留其宽度、展开状态与滚动位置,并标出当前文件。预览旁的目录树在该预览的位置打开文件;`files` 页则在右侧 Sidebar 默认落位的新 tab 中打开文件。根目录与已展开目录通过直接子项监听自动刷新,手动重新读取仍可用。`files` 页从引导页进入,不认领资源地址。 ## 目录 - [注册了什么](#what-it-registers) +- [导航栏](#the-navigator) - [树](#the-tree) +- [复用目录树 Factory](#tree-factory) - [模型体验](#model-experience) - [已知限制与暂缓事项](#known-limitations-and-deferred-work) - [开发备注](#dev-note) @@ -24,33 +26,62 @@ kind: "package-reference" ## 注册了什么 -命令 `workspace.files` 在焦点分栏内打开或聚焦文件页;从聊天区触发时使用当前会话的活动停靠分栏。文件页替换开始页,同一分栏内重复打开仍保留一个文件页。桌面默认键为 Mod+P。开始页入口显示有效快捷键;Windows 和 macOS Web 使用[快捷键服务的平台默认值](../shortcuts/README.zh.md);Linux Web 默认不绑定此命令。 +命令 `workspace.files` 在焦点分栏内显示画在停靠 tab 内容旁的导航栏,并把键盘焦点移到目录树,保留该 tab;从聊天区触发时使用当前会话的活动停靠分栏。其他情况下(文件页本身、不带导航栏的 tab、没有工作目录的会话、浮动 tab)打开或聚焦文件页,文件页的目录树始终显示。文件页替换开始页,同一分栏内重复打开仍保留一个文件页。桌面默认键为 Mod+P。开始页入口显示有效快捷键;Windows 和 macOS Web 使用[快捷键服务的平台默认值](../shortcuts/README.zh.md);Linux Web 默认不绑定此命令。 - **类型**:`ctx.sidebarRightTabs.register(...)`,kind 为 `files`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-files`,档位 `builtin`,没有 patterns,另有一个打开该类型的引导页入口(order 10,标题与描述取自 `sidebarFiles` 命名空间,图标为黄色文件夹)。 -- **正文**:以该 id 为键的 `sidebar.right.pane.tab` slot:strip 下的一行标题行,然后是树。共享的 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog) 显示根路径,目录使用弱化颜色,最后一段使用主色。路径过长时保留尾部字符并在左侧渐隐;悬停显示完整路径。重新读取控件和 Session 作用域的 `sidebar.right.tab.files.actions` 列表位于右端,并通过 `absolutePath` 传入当前显示的根目录;[`ui-open-in-app`](../ui-open-in-app/README.zh.md) 在此提供工作区目录打开按钮。 +- **文件页正文**:以该 id 为键的 `sidebar.right.pane.tab` slot。其标题行横跨页面,通过共享的 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog) 显示根路径,目录使用弱化颜色,最后一段使用主色;路径过长时保留尾部字符并在左侧渐隐,悬停显示完整路径。末端依次是目录树的重新读取控件、由文件页注册声明的 Session 作用域 `sidebar.right.tab.files.actions` 列表(通过 `absolutePath` 传入当前显示的根目录),顺序与预览标题行末端的重新读取与文件操作一致;[`ui-open-in-app`](../ui-open-in-app/README.zh.md) 在此提供工作区目录打开按钮。标题行下方由该分栏的导航栏铺满整页,停靠与浮动时都一样,这里没有开关。 +- **各行**:root 作用域的 Component Factory `sidebar.files.tree`,接收调用方的条目和局部 `accessory` slot;见[复用目录树 Factory](#tree-factory)。 +- **导航栏**:Session 作用域的 Component Factory `sidebar.files.navigator`。文件页正文和 [`ui-sidebar-documentpreview`](../ui-sidebar-documentpreview/README.zh.md) 直接渲染它,显示在每个预览旁,无论停靠还是浮动。它只绘制目录树及其调整宽度的边缘,没有自己的标题行:预览旁只有预览自己的标题行,目录树的重新读取与目录操作留在 `files` 页上。 +- **开关**:Session 作用域的 Factory `sidebar.files.navigator.toggle`,在预览头部末端直接渲染,传入 `useTabInfo`。两个 Factory 共用文件页的 store handle 和 inject face,每个分栏保留一份开关状态;文件页正文没有开关。 - **标签页标题**:以该 id 为键的 `sidebar.right.pane.tab.title` slot:类型标签前的一枚 16px 共享 `FileTypeIcon` 文件夹图标。树本身的行不画这枚图标。 -`src/client/` 下的源文件:`definition.tsx`(类型是什么)、`store.ts`(它保存什么)、`face.ts`(Remote 读取与监听)、`directory-node.ts`(打开的目录及其生命周期)、`FilesBody.tsx`(它画什么,含排序与失败行两个辅助函数)、`FilesTitle.tsx`(标签页标题)、`locales.ts`(它说什么)、`index.ts`(接线)。 +[注册入口](src/client/index.ts) 在共享 [files face](src/client/face.ts) 与[分栏 store](src/client/store.ts) 上组装文件页、导航栏、开关和目录树 Factory。 + + +## 导航栏 + +用 `FileNavigatorInput` 渲染 `sidebar.files.navigator`:`{ useTabInfo, page, onOpen }`。必填的 `onOpen: (address: string) => void` 回调由容纳它的插件提供;导航栏构造文件地址,不决定落位。`page` 只控制呈现:`true` 时始终铺满文件页,`false` 时遵循预览所在分栏的偏好与可用宽度。 + +导航栏画在横跨分栏的预览标题行下方,位于它与正文共用的那一行的右侧。在预览旁,是否显示由其分栏的记录决定:分栏默认隐藏,这一选择对分栏内的每个文件 tab 都有效,直到开关、Escape、工作区命令,或导航到被目录树遮住的预览改变它。自身没有记录的分栏(例如 tab 拖出后所在的浮窗)从隐藏、默认宽度开始。点击目录树以外的地方不会隐藏它;在另一分栏打开内容也不会隐藏它。在文件页上,目录树铺满标题行下方的整页,停靠与浮动时都一样,不显示分隔条,且始终显示:文件页不画开关,Escape 也不关闭它。文件页打开的文件沿用落定分栏自己的选择,尚未做过选择的分栏为隐藏。 + +其宽度是分栏的偏好,在读者用主键拖动左缘分隔条,或用方向键、Home(最窄)、End(最宽)调整之前为 260px,并限制在这一行内:行宽允许时至少 180px;与正文并排时至多为行宽减去 336px,悬浮时至多为行宽减去 120px。在预览旁、视口至少 768px 时,face 测得的行宽决定三种布局之一。行宽至少 560px 时,导航栏是正文旁的浮起面板,距行的上、右、下边缘各 8px,与正文相隔 8px;正文让出面板及其外边距的宽度而变窄,但至少保留 320px。行宽在 400px 到 560px 之间时,同样的面板悬浮在正文右侧之上,因此正文保持完整宽度,面板未覆盖的部分仍可滚动、可交互;正文的滚动条和渲染器的吸顶工具栏在面板下方的部分会被它遮住。两种面板都带圆角:浅色模式采用浮动停靠面板的背景与阴影,深色模式采用与紧凑目录树一致的深背景和柔和白色阴影。560px 这一界限只取决于行宽,因此调整目录树宽度不会让它在两种布局之间切换。在窄于 400px 的行中,或窄于 768px、右侧 Sidebar 以全屏呈现面板的视口中,导航栏铺满标题行下方的整行,不显示分隔条,导航到该预览后会隐藏目录树,让文件显示出来。铺满时,被覆盖的正文及其内嵌框架会隐藏,不能通过键盘进入。若行宽或视口进入此布局时正文持有焦点(包括目录树首次绘制时),焦点移到目录树内,不改变分栏的开关选择。浏览器可能在新宽度报告之前就让被隐藏的正文失焦;除非读者在报告之前移动了键盘焦点或指针,或窗口失去焦点,这一焦点同样移到目录树内。停靠与浮动分栏遵循相同规则;目录树开启时,在每种布局下浮窗的缩放手柄都仍可操作。在预览旁,从这一行内的正文或目录树传到页面 document 的不带修饰键的 Escape,在任何行宽下都会关闭导航栏,并把键盘焦点移到开关上;内嵌框架的 document 持有焦点时,按键不会冒泡到页面 document,因此导航栏保持打开。Escape 在 document 上接收,晚于这一行自己的处理器,因此行内处理器若阻止了默认行为,这次按键就留给它。 + +预览的 `onOpen` 传入 [`replaceTab: true`](../ui-sidebar-right/README.zh.md#ctxsidebarright);文件页在停靠时传入 `{ paneId: panel.id }`,浮动时省略落位。右侧 Sidebar 负责原位替换、默认停靠落位与揭示。原位替换保留分栏的目录树。目录树持有键盘焦点时,若打开操作在同一分栏内替换或切换 tab,下次绘制会聚焦当前文件所在行;揭示其他分栏时,发起侧的目录树与焦点保持不变。 + +新的 `tab.navigation.revision` 只关闭目标预览的目录树,而且仅在它按目标当前宽度铺满内容行时关闭。该规则适用于任何导航来源,包括从浮动预览自己的目录树发起的打开。隐藏的目录树也消费导航,因此手动显示它不会重放旧的关闭操作。普通 tab 切换、浮出或停靠,以及刷新或 undo 恢复的 revision `0` 都不关闭目录树。files face 的消费记录按 tab 生命周期保存;导航订阅在 tab 关闭或插件卸载时释放。文件页始终可见的目录树不因导航而关闭。 + +树用 `aria-current` 标出当前文件所在行。目录树绘制期间,分栏内尚未定位过的文件会逐层展开其已列出的祖先目录,并把该行滚动到可见区域;保留的某一层正在重新列出时(例如隐藏的目录树重新显示,或收起的目录重新展开),定位会等它列完,再认定其中缺少的名称不存在;分栏记住上次定位的文件,因此同一 tab 重新挂载时既不改变展开状态,也不改变滚动位置。定位期间读者收起某个目录会结束这次定位,分栏把该文件记为已定位。工作区根目录之外的文件或其他 Session 的文件不会被定位;在 Windows 盘符或 UNC 根目录下,判断是否位于根目录内时不区分大小写和斜杠方向,在 POSIX 根目录下区分大小写。 ## 树 -根是会话的工作目录,读自 `useSessions().byId[sessionId].cwd`。`/` 和 Windows 盘根等文件系统根路径均可作为树的根。每一层以绝对路径为键;子路径是父路径以 `/` 拼上条目名。一层在首次展开时经 `@deepseek-ai/dsh-api-workspace-files` 命名空间的 `remote.workspaceFiles.list(sessionId, absolutePath)` 列出;适配器保留列表的条目与截断标志,丢弃其工作区相对路径。行序为目录优先,其后按自然序、不分大小写的名称排列;dotfiles 与其他条目一样显示。 +根是会话的工作目录,读自 `useSessions().byId[sessionId].cwd`。`/` 和 Windows 盘根等文件系统根路径均可作为树的根。每一层以绝对路径为键;子路径是父路径以 `/` 拼上条目名。一层在首次展开时经 `@deepseek-ai/dsh-api-workspace-files` 命名空间的 `remote.workspaceFiles.list(sessionId, absolutePath)` 列出。各行由[目录树 Factory](#tree-factory) 绘制并排序;`workspace-tree.ts` 传入每个已列出的条目,名字取目录列表中的拼写,类型作为 `meta.kind`,根的键去掉末尾分隔符,并传入已本地化的失败行。Factory 自行从 files locale 读取文案。dotfiles 与其他条目一样显示。 | 条目类型 | 行 | |---|---| | `directory` | 切换展开与折叠;再次打开时重新列举,并恢复仍存在的已展开后代。折叠期间保留已显示条目的缓存。 | -| `file` | 经 `useTabInfo().tab.actions.openResource` 打开 `dsh-resource://file/session//`,地址由 `@deepseek-ai/dsh-util-workspace-path` 的 `fileAddressFor` 从条目的绝对路径与树的根生成,落在该 tab 自己的 pane 里。 | +| `file` | 按[导航栏](#the-navigator)所述打开 `dsh-resource://file/session//`,地址由 `@deepseek-ai/dsh-util-workspace-path` 的 `fileAddressFor` 从条目的绝对路径与树的根生成。没有已注册的 tab 类型能打开该地址时(例如未启用 `ui-sidebar-documentpreview`),该行灰显、不可点击并说明原因;一旦有能打开它的类型注册,就变为可点击。 | | `other` | 灰显且不可点击,从而完整呈现目录内容。 | 被端点条目上限截断的层以一条标记收尾;空层如实说明;失败的层按错误码各显示一行(`workspace-file/not-found`、`outside-workspace`、`not-directory`),其他情况显示传输层自己的消息。重新读取就地刷新根与展开中的层,读取期间保留显示条目,不重置整棵树;折叠的层在下次打开时重新拉取。没有工作目录的会话只显示一行说明,而不是树。 -状态保存在类型自己的存储里,按 tab id 分桶:`root`、`levels`(每个绝对路径的 loading / ready / failed)、`expanded`、`autoRefresh` 与 `scrollTop`——滚动期间偏移由正文自己记录,卸载时一次性写入。存储比 body 活得久,切到其他侧栏 tab 再切回来时树带着已加载的层重新挂载,滚动位置也随之恢复。owner 的 `signal` 终结一个桶:中止时忘掉该 tab,其后才结算的列表与卸载时的偏移提交都什么也不写。 +状态保存在绘制之外,以 `useTabInfo().panel` 报告的分栏 id 为键。类型自己的存储按分栏保存导航栏的 `open` 选择与 `width`,以及首次显示后的 `tree`:`root`、`levels`(每个绝对路径的 loading / ready / failed,ready 层重新列出期间标记 `refreshing`)、`expanded` 与 `autoRefresh`。分栏内的每个文件 tab 读取同一份记录,因此切换、打开或关闭 tab 时导航栏重新绘制,已加载的层、展开状态、宽度与滚动位置保持不变;切换 tab 不会重新列目录。隐藏目录树,或只显示不带目录树的 tab,会关闭其目录监听;再次显示时就地重新列出根目录与已展开的目录,期间保留的行照常显示。根目录不是 Session 当前工作目录的树会被替换,其滚动位置与已定位文件随之重置。分栏在显示导航栏、或仍有打开的 tab 最近一次在该分栏绘制导航栏时保留其目录树:移到另一分栏的文件 tab 显示那个分栏的目录树;原位替换的 tab(如选中的文件替换预览)保留它;浮动或停靠 tab 本身从不终结它。最后一个这样的 tab 关闭或在别处绘制导航栏后,分栏的目录树即被忘掉。文件页与每个预览的导航栏在同一 Session 中共用一个 face。 -每个打开的 `DirectoryNode` 在 Tab 生命周期内持有自己的目标监听;折叠关闭该节点及其不可见后代。祖先恢复期间的展开状态变化会更新存储和待恢复节点,后代恢复时遵循最新展开偏好。自动刷新默认开启;独立开关暂时隐藏,状态、文案、样式与切换逻辑保留。目录读取期间或收尾时收到的变化仍会留待下一次刷新。 +每个打开的 `DirectoryNode` 在目录树绘制期间持有自己的目标监听;折叠关闭该节点及其不可见后代。祖先恢复期间的展开状态变化会更新存储和待恢复节点,后代恢复时遵循最新展开偏好。自动刷新默认开启;独立开关暂时隐藏,状态、文案、样式与切换逻辑保留。目录读取期间或收尾时收到的变化仍会留待下一次刷新。 文件行提供共享[右键菜单](../ui-context-menu/README.zh.md),不会同时触发普通点击导航。文件夹行和根路径使用 `directory` 操作对象,提供本机显示位置和复制路径操作,不会展开目录。 + +## 复用目录树 Factory + +要绘制工作区列表、变更文件或本地 diff,在模块级声明 `const TREE_OPTIONS = { slots: { accessory: ModuleLevelComponent }, fallback: null }`,再调用 `renderFactorySlot('sidebar.files.tree', input, TREE_OPTIONS)`。保持 options 及其 `slots` 对象稳定;重新创建局部选择会让整棵树随父组件渲染而重画。该 root 作用域 Factory 仅在 files 插件加载时可用;未加载时,`renderFactorySlot` 绘制传入的 `fallback`,省略时不绘制内容。从 `@deepseek-ai/dsh-client-ui-sidebar-files/client` 仅以类型方式导入声明以及 `FileTreeInput`、`TreeEntry`、`TreeEntryMeta`、`TreeEntryKind`、`TreeDirectoryState`;不导出组件值。 + +`FileTreeInput` 携带 `root`、扁平 `entries`、`expanded`、`onToggle(entry, expanded)` 和 `onOpen(entry)`。可选的 `directories` 按键提供加载、本地化失败和截断状态;`current` 与 `currentRef` 标记并提供当前文件按钮。`canOpen(entry)` 可以拒绝打开文件而不禁用其附件。`rowTriggerProps(entry, kind)` 为文件夹行和可打开的文件行提供右键菜单处理器。Factory 通过 files locale 拥有加载、空目录、截断与不可用文案;调用方负责数据访问、展开、打开、祖先定位和滚动。 + +`TreeEntry` 为 `{ path, name, meta?: { kind?, hidden?, size? } }`。只有 `/` 分隔层级;父级是最后一个 `/` 前的部分,没有 `/` 时为 `root`。名字保持调用方传入的值;重复路径保留首个条目。根键条目被忽略,包括空根。缺少的中间目录只补一次,显式条目优先。省略 kind 时,路径下有条目则为目录,否则为文件。隐藏行用弱化文字颜色显示,不过滤;大小不显示。只有可见层排序,目录优先,再按名字的自然顺序、不区分大小写排列;各层保留排序缓存,直到名字或目录类型变化。 + +局部 `accessory` slot 使用 root 作用域,owner props 为 `{ entry: TreeEntry }`;其组件还可使用派生的 `FactoryLocalComponentPropsOf<'sidebar.files.tree', 'accessory'>` 席位。它只收到 `entry` 和 Factory 自己的席位,拿不到调用方的 store 或 inject face。传入模块级组件以保持身份稳定。附件位于主按钮旁,不在其操作或禁用范围内。默认组件不渲染内容。附件包装容器始终存在于 DOM 中;未提供 slot 或其组件返回空内容时,CSS 将其隐藏且不占用行空间。 + ## 模型体验 @@ -63,7 +94,7 @@ kind: "package-reference" ## 已知限制与暂缓事项 -- **只有列目录。**没有搜索、产物过滤、拖拽、重命名或当前文件高亮。 +- **只有列目录。**没有搜索、产物过滤、拖拽或重命名。 - **只有一个根。**树以会话工作目录为根;没有办法浏览到它之上,而 Host 本来也拒绝工作区根之外的路径。 @@ -74,6 +105,6 @@ kind: "package-reference" 无。 -页面刷新快捷键通过常规目录读取器刷新聚焦的文件树。重新读取控件在悬停和键盘聚焦时显示有效绑定。 +页面刷新快捷键在文件页上、目录树显示期间通过常规目录读取器刷新其树,在预览中重新载入预览。文件页的重新读取控件在悬停和键盘聚焦时显示有效绑定。 diff --git a/packages/client/ui-sidebar-files/package.json b/packages/client/ui-sidebar-files/package.json index a511b36bee..338cb705ac 100644 --- a/packages/client/ui-sidebar-files/package.json +++ b/packages/client/ui-sidebar-files/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar-files", - "description": "Workspace file tree tab type for the right Sidebar: lazy directory listing over the workspaceFiles Remote namespace, opening files into the Sidebar", + "description": "The workspace files page and one workspace directory tree per pane beside docked and floating file previews, listed lazily over the workspaceFiles Remote namespace", "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" diff --git a/packages/client/ui-sidebar-files/src/client/FileNavigator.tsx b/packages/client/ui-sidebar-files/src/client/FileNavigator.tsx new file mode 100644 index 0000000000..6ce68e82b2 --- /dev/null +++ b/packages/client/ui-sidebar-files/src/client/FileNavigator.tsx @@ -0,0 +1,207 @@ +/** + * One pane's workspace navigator: the tree and the edge that resizes it. The + * `files` page draws it below its header; beside a preview the preview's own + * header is the only one. Everything it shows lives in the store and face, + * keyed by pane, so a remount on a tab switch or replacement draws it + * unchanged, keyboard focus included when the tree held it. Its layout + * follows `PUSH_FROM`, `FILL_BELOW`, and `FULLSCREEN_QUERY`; the package + * README describes its behavior. + */ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { CSSProperties, ReactNode } from 'react' +import clsx from 'clsx' +import type { UseSidebarRightTabInfo } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import type { TreeEntry } from './FileTree.tsx' +import type { FactoryComponentPropsOf } from '@deepseek-ai/dsh-client-ui-slots' +import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path' +import type { FilesInjected } from './face.ts' +import { DEFAULT_NAVIGATOR_WIDTH, PUSH_FROM, locateFile, navigatorGeometry, relativeFileOf, type createFilesStore } from './store.ts' +import { parentOf, treeRootOf, workspaceDirectories, workspaceEntries } from './workspace-tree.ts' +import { FILL_BELOW } from './navigator-layout.ts' +import css from './FilesBody.module.css' + +/** What a navigator occurrence is drawn for. */ +export interface FileNavigatorInput { + /** The tab the navigator is drawn beside, and its pane. */ + readonly useTabInfo: UseSidebarRightTabInfo + /** Drawn for the `files` page: always shown and filling the page. */ + readonly page: boolean + /** Ask the containing plugin to open a file. @param address - the file's resource address. */ + readonly onOpen: (address: string) => void +} + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotFactoryMap { + /** One pane's workspace tree with its width and show/hide state. */ + 'sidebar.files.navigator': { + scope: 'session' + props: FileNavigatorInput + store: ReturnType + locale: 'sidebarFiles' + inject: FilesInjected + } + } +} + +/** One keyboard resize step. */ +const RESIZE_STEP = 20 + +/** + * How a preview's navigator shares its row with the body: beside it, taking + * width from it (`push`); floating over its right part (`overlay`); or + * covering the whole row (`fill`). + */ +type NavigatorMode = 'push' | 'overlay' | 'fill' + +/** + * The layout a preview's navigator takes in a row; `FULLSCREEN_QUERY` makes every layout fill in CSS. + * @param rowWidth - the row's width measured by the face; `undefined` before it is measured. + * @returns `overlay` until measured, then the layout for the width. + */ +function navigatorMode(rowWidth: number | undefined): NavigatorMode { + if (rowWidth === undefined) return 'overlay' + return rowWidth >= PUSH_FROM ? 'push' : rowWidth >= FILL_BELOW ? 'overlay' : 'fill' +} + +/** + * The navigator Factory's component. + * @param props - the tab it is drawn for, the pane's store records, the tree's face, and copy. + * @returns the navigator, or nothing while hidden or without a workspace. + */ +export function FileNavigator({ + useTabInfo, page, onOpen, sessionId, useSessions, useStore, actions, useRowWidth, useCanOpen, + attach, placeBeside, hold, toggle, scrolled, bodyDrawn, currentDrawn, navigatorDrawn, t, renderFactorySlot, useContextMenu, +}: FactoryComponentPropsOf<'sidebar.files.navigator'>): ReactNode { + const { tab, panel } = useTabInfo() + const { getTriggerProps } = useContextMenu() + const paneId = panel.id + const cwd = useSessions(sessions => sessions.byId[sessionId]?.cwd) + const state = useStore(store => store.byPane[paneId]) + const rowWidth = useRowWidth(paneId) + const canOpen = useCanOpen(check => check) + const open = page || state?.open === true + // Measured by the face; the viewport rule is CSS's, read again when a file opens. + const mode = navigatorMode(rowWidth) + const tree = state?.tree?.root === cwd ? state?.tree : undefined + const [dragging, setDragging] = useState(false) + const drag = useRef<{ readonly x: number; readonly width: number } | undefined>(undefined) + // Stable, so each element registers once per drawing rather than on every render. + const navigatorRef = useCallback( + (element: HTMLElement | null) => { navigatorDrawn(paneId, element, !page) }, + [navigatorDrawn, paneId, page], + ) + const bodyRef = useCallback( + (body: HTMLDivElement | null) => { bodyDrawn(paneId, tab.contentId, body) }, + [bodyDrawn, paneId, tab.contentId], + ) + const currentRef = useCallback((row: HTMLElement | null) => { currentDrawn(paneId, row) }, [currentDrawn, paneId]) + + useEffect(() => attach(paneId, tab.id, tab.signal), [attach, paneId, tab.id, tab.signal]) + // Only a navigator that can draw a tree keeps the workspace command on its tab. + useEffect(() => page || cwd === undefined ? undefined : placeBeside(tab.id), [page, cwd, placeBeside, tab.id]) + useEffect(() => cwd === undefined || !open ? undefined : hold(paneId, cwd), [cwd, open, paneId, hold]) + + const relativePath = tree === undefined ? undefined : relativeFileOf(tab.contentId, sessionId, tree.root) + const location = useMemo( + () => tree === undefined || relativePath === undefined ? undefined : locateFile(tree, relativePath), + [tree, relativePath], + ) + const root = tree?.root + const levels = tree?.levels + const expanded = tree?.expanded + const entries = useMemo(() => levels === undefined ? undefined : workspaceEntries(levels), [levels]) + const directories = useMemo( + () => root === undefined || levels === undefined || expanded === undefined + ? undefined + : workspaceDirectories({ root, levels, expanded }, t), + [root, levels, expanded, t], + ) + // Kept between renders, so the tree redraws only when what it shows changes, + // not on every measure of the row or render of the host. + const view = useMemo(() => { + if (tree === undefined) return undefined + return { + root: treeRootOf(tree.root), + expanded: tree.expanded, + onToggle: (entry: TreeEntry) => { toggle(paneId, parentOf(tree, entry.path), entry.path, tree.expanded) }, + onOpen: (entry: TreeEntry) => { onOpen(fileAddressFor(sessionId, tree.root, entry.path)) }, + canOpen: (entry: TreeEntry) => canOpen(fileAddressFor(sessionId, tree.root, entry.path)), + rowTriggerProps: (entry: TreeEntry, kind: 'file' | 'directory') => getTriggerProps(kind === 'directory' + ? { kind, value: { sessionId, path: entry.path } } + : { kind, value: { sessionId, path: entry.path, paneId } }), + } + }, [tree, paneId, sessionId, toggle, canOpen, onOpen, getTriggerProps]) + + if (cwd === undefined || !open) return null + const geometry = navigatorGeometry(rowWidth, state?.width ?? DEFAULT_NAVIGATOR_WIDTH) + // The page's tree always fills the page; a preview's follows its row. + const shown = page ? undefined : mode + const clamp = (next: number): number => Math.min(Math.max(next, geometry.minimum), geometry.maximum) + return ( + + ) +} diff --git a/packages/client/ui-sidebar-files/src/client/FileTree.module.css b/packages/client/ui-sidebar-files/src/client/FileTree.module.css new file mode 100644 index 0000000000..d7bd27a608 --- /dev/null +++ b/packages/client/ui-sidebar-files/src/client/FileTree.module.css @@ -0,0 +1,113 @@ +/* The tree's rows; the caller's container owns the font, colour, padding, and scrolling. */ +.level { + margin: 0; + padding: 0; + list-style: none; +} + +/* Every nested level indents by one step; the root level sits flush. */ +.level .level { + padding-left: 18px; +} + +.item { + margin: 0; + padding: 0; +} + +.row { + display: flex; + align-items: center; + width: 100%; + min-width: 0; + border-radius: var(--dsw-radius-md); +} + +.action { + display: flex; + flex: 1; + gap: 6px; + align-items: center; + min-width: 0; + /* Rows abut, so the hover fill is the whole row; the tree's air is this + inset, not a gap between rows. */ + padding: 5px 10px; + color: inherit; + font: inherit; + text-align: left; + background: transparent; + border: 0; + border-radius: var(--dsw-radius-md); + cursor: pointer; +} + +/* Local Components can render nothing; empty wrappers must not take row space. */ +.accessory:empty { + display: none; +} + +.accessory { + display: flex; + flex: 0 0 auto; + align-items: center; + padding-right: 10px; +} + +@media (hover: hover) { + .row:hover { + background: var(--dsw-alias-interactive-bg-hover); + } + + .current:hover { + background: var(--dsw-alias-interactive-bg-active); + } +} + +/* The caller's current file; hovering keeps its fill. */ +.current { + background: var(--dsw-alias-interactive-bg-active); +} + +/* The folder glyph rides the tertiary ink, as the workspace tree's does (ui-workspace Rows `.slot`). */ +.icon { + flex: 0 0 auto; + color: var(--dsw-alias-label-tertiary); +} + +/* The type's coloured sheet at the folder glyph's 16px; its colour is its own. */ +.fileIcon { + flex: 0 0 auto; +} + +.name { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +/* An entry the caller marks hidden: subdued, with the secondary ink that stays + readable in both themes. */ +.hidden { + color: var(--dsw-alias-label-secondary); +} + +/* Neither an openable file nor a directory: shown so the directory is + reported whole, greyed so nobody tries to open it. */ +.other { + color: var(--dsw-alias-label-tertiary); + cursor: default; +} + +@media (hover: hover) { + .inactive:hover { + background: transparent; + } +} + +.note { + margin: 0; + padding: 3px 10px; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; +} diff --git a/packages/client/ui-sidebar-files/src/client/FileTree.tsx b/packages/client/ui-sidebar-files/src/client/FileTree.tsx new file mode 100644 index 0000000000..7a9b129896 --- /dev/null +++ b/packages/client/ui-sidebar-files/src/client/FileTree.tsx @@ -0,0 +1,288 @@ +/** + * A controlled file tree Component Factory over flat entries. The caller owns + * the data, which directories are expanded, each directory's listing + * state, and what a click does; the tree derives each entry's level from its + * path, adds the directories the paths imply, orders every drawn level, and + * draws the rows and their icons. It redraws when its + * props or the occurrence's local selection change, and orders a drawn level + * again only when the names or directory kinds in that level change. + */ +import { memo, useMemo } from 'react' +import type { ReactNode } from 'react' +import clsx from 'clsx' +import type { FactoryComponentPropsOf } from '@deepseek-ai/dsh-client-ui-slots' +import type { ContextMenuTriggerProps } from '@deepseek-ai/dsh-client-ui-context-menu/client' +import { FileTypeIcon, classifyFileType, IconFolderCloseRegular, IconFolderOpenRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import type {} from './locales.ts' +import css from './FileTree.module.css' + +/** What a row is: a directory toggles, a file opens, and anything else is listed without either. */ +export type TreeEntryKind = 'file' | 'directory' | 'other' + +/** The standard facts the tree reads about an entry; every field is optional. */ +export interface TreeEntryMeta { + /** What the entry is. Without it, an entry with other entries below its path is a directory, and any other is a file. */ + readonly kind?: TreeEntryKind + /** The row is drawn subdued; the entry and those below it are still listed. */ + readonly hidden?: boolean + /** Size in bytes; absent means unknown. The tree does not draw it. */ + readonly size?: number +} + +/** + * One tree entry. `path` identifies it and places it: its parent is the part + * before the last `/`, or the tree's root when it has no `/`. `name` is what + * the row shows and need not be the end of `path`. + */ +export interface TreeEntry { + /** The entry's key; only `/` separates levels. */ + readonly path: string + /** The name drawn on the row. */ + readonly name: string + /** The standard facts the tree reads. */ + readonly meta?: TreeEntryMeta +} + +/** What the caller knows about one directory's listing besides its entries; every field is optional. */ +export interface TreeDirectoryState { + /** The listing is not known yet; the directory shows a loading line instead of its entries. */ + readonly loading?: true + /** Why the directory could not be listed, already localized; the entries known so far still show. */ + readonly failure?: string + /** Entries are missing from the listing. */ + readonly truncated?: true +} + +/** The `sidebar.files.tree` Factory's input: data and callbacks only. */ +export interface FileTreeInput { + /** The root's key; the root itself has no row and its entries are always shown. */ + readonly root: string + /** + * Every entry the caller knows, in any order, each `path` once. Entries + * whose `path` equals `root` are ignored, including when both are empty. + * A parent key below the root without an entry of its own gets a directory + * entry named after the key's last segment; the empty + * key is never added. An entry under neither the root nor an expanded + * directory is not drawn. + */ + readonly entries: readonly TreeEntry[] + /** Keys of the expanded directories. */ + readonly expanded: readonly string[] + /** Listing state by directory key, root included; a directory absent here is listed and complete. */ + readonly directories?: Readonly> | undefined + /** Key of the file to mark as current, when it is drawn. */ + readonly current?: string | undefined + /** Receives the current file's main button while drawn. @param row - the button, or `null` once it goes. */ + readonly currentRef?: ((row: HTMLElement | null) => void) | undefined + /** + * A directory row was pressed. + * @param entry - the caller's directory entry, or the one the tree added. + * @param expanded - whether the press asks to expand it. + */ + readonly onToggle: (entry: TreeEntry, expanded: boolean) => void + /** + * An openable file row was pressed. + * @param entry - the caller's entry. + */ + readonly onOpen: (entry: TreeEntry) => void + /** + * Whether a file opens; a file it refuses is dimmed and titled with the localized no-viewer message. Every file opens without it. + * @param entry - the caller's entry. + * @returns `false` to refuse it. + */ + readonly canOpen?: ((entry: TreeEntry) => boolean) | undefined + /** + * Context menu handlers for a directory row or an openable file row's main button. + * @param entry - the caller's entry, or the directory the tree added. + * @param kind - the kind the tree resolved for the row. + * @returns the handlers spread onto the row's button. + */ + readonly rowTriggerProps?: ((entry: TreeEntry, kind: 'file' | 'directory') => ContextMenuTriggerProps) | undefined +} + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotFactoryMap { + /** A controlled file tree over caller-supplied entries; the caller decides what a click does. */ + 'sidebar.files.tree': { + scope: 'root' + props: FileTreeInput + locale: 'sidebarFiles' + slots: { + /** Independent content beside each row's main action. */ + accessory: { scope: 'root'; props: { readonly entry: TreeEntry } } + } + } + } +} + +/** No row accessory unless the caller selects a local Component. */ +function EmptyAccessory(): null { + return null +} + +/** One entry with the kind the tree resolved for it. */ +interface TreeNode { + readonly entry: TreeEntry + readonly kind: TreeEntryKind +} + +/** Natural, case-insensitive name order, so `file2` precedes `file10`. */ +const byName = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }) + +/** + * The key of the level an entry is listed in. + * @param path - the entry's key. + * @param root - the root's key. + * @returns the part of `path` before its last `/`, or `root` when it has none. + */ +function parentOf(path: string, root: string): string { + const slash = path.lastIndexOf('/') + return slash < 0 ? root : path.slice(0, slash) +} + +/** + * Group entries under their parent keys, adding the directories their paths imply. + * @param entries - the caller's entries; a repeated `path` keeps its first entry. + * @param root - the root's key. + * @returns each parent key's children. + */ +function levelsOf(entries: readonly TreeEntry[], root: string): ReadonlyMap { + const byPath = new Map() + for (const entry of entries) if (entry.path !== root && !byPath.has(entry.path)) byPath.set(entry.path, entry) + for (const entry of entries) { + if (entry.path === root) continue + for (let parent = parentOf(entry.path, root); parent !== root && parent !== '' && !byPath.has(parent); parent = parentOf(parent, root)) { + byPath.set(parent, { path: parent, name: parent.slice(parent.lastIndexOf('/') + 1), meta: { kind: 'directory' } }) + } + } + const groups = new Map() + for (const entry of byPath.values()) { + const parent = parentOf(entry.path, root) + const group = groups.get(parent) + if (group === undefined) groups.set(parent, [entry]) + else group.push(entry) + } + const levels = new Map() + for (const [parent, group] of groups) { + levels.set(parent, group.map(entry => ({ entry, kind: entry.meta?.kind ?? (groups.has(entry.path) ? 'directory' : 'file') }))) + } + return levels +} + +/** + * One level's display order: directories first, then everything else, each by name. + * @param level - one parent key's children. + * @returns the children's indices in display order. + */ +function orderOf(level: readonly TreeNode[]): readonly number[] { + return level.map((node, index) => ({ node, index })).sort(({ node: left }, { node: right }) => { + const group = Number(right.kind === 'directory') - Number(left.kind === 'directory') + return group !== 0 ? group : byName.compare(left.entry.name, right.entry.name) + }).map(({ index }) => index) +} + +/** What every level shares: the grouped entries, input, locale, and accessory. */ +interface TreeContext { + readonly levels: ReadonlyMap + readonly expanded: ReadonlySet + readonly input: FileTreeInput + readonly t: FactoryComponentPropsOf<'sidebar.files.tree'>['t'] + readonly Accessory: ReturnType['useFactorySlot']> +} + +/** One entry's row, and its children when it is an expanded directory. */ +function Entry({ node, tree }: { node: TreeNode; tree: TreeContext }): ReactNode { + const { input, Accessory } = tree + const { entry, kind } = node + const hidden = entry.meta?.hidden === true && css.hidden + const label = {entry.name} + if (kind === 'directory') { + const expanded = tree.expanded.has(entry.path) + return ( +
  • +
    + +
    +
    + {expanded &&
    } +
  • + ) + } + if (kind === 'file' && input.canOpen?.(entry) !== false) { + const current = entry.path === input.current + return ( +
  • +
    + +
    +
    +
  • + ) + } + // A file the caller refuses is listed like anything else that does not open. + const file = kind === 'file' + return ( +
  • +
    + + {file && } + {label} + +
    +
    +
  • + ) +} + +/** No entries, shared so that an empty level orders nothing. */ +const NO_ENTRIES: readonly never[] = [] + +/** One directory's rows: a loading line until it is listed, then its entries and notes. */ +function Level({ path, tree }: { path: string; tree: TreeContext }): ReactNode { + const { t } = tree + const state = tree.input.directories?.[path] + const level = tree.levels.get(path) ?? NO_ENTRIES + // The order reads only each child's name and whether it is a directory, so + // it is kept while those are unchanged; the rows draw this render's entries. + const sortKey = JSON.stringify(level.map(node => [node.kind === 'directory', node.entry.name])) + const order = useMemo(() => orderOf(level), [sortKey]) + if (state?.loading === true) return
  • {t('loading')}
  • + return ( + <> + {state?.failure !== undefined &&
  • {state.failure}
  • } + {level.length === 0 && state?.failure === undefined &&
  • {t('empty')}
  • } + {order.map(index => level[index]).filter(node => node !== undefined) + .map(node => )} + {state?.truncated === true &&
  • {t('truncated')}
  • } + + ) +} + +/** + * The tree from its root. + * @param props - the caller's entries, expansion, states, callbacks, and framework seats. + * @returns the root level's list. + */ +function FileTreeView({ t, useFactorySlot, ...input }: FactoryComponentPropsOf<'sidebar.files.tree'>): ReactNode { + const Accessory = useFactorySlot('accessory', EmptyAccessory) + const levels = useMemo(() => levelsOf(input.entries, input.root), [input.entries, input.root]) + const expanded = useMemo(() => new Set(input.expanded), [input.expanded]) + const tree: TreeContext = { levels, expanded, input, t, Accessory } + return
    +} + +/** + * The file tree. It redraws when a prop or the occurrence's local selection + * changes, so a caller keeps its inputs and local selection stable between renders. + */ +export const FileTree = memo(FileTreeView) diff --git a/packages/client/ui-sidebar-files/src/client/FilesBody.module.css b/packages/client/ui-sidebar-files/src/client/FilesBody.module.css index ef7717fb9f..c250c5d4ae 100644 --- a/packages/client/ui-sidebar-files/src/client/FilesBody.module.css +++ b/packages/client/ui-sidebar-files/src/client/FilesBody.module.css @@ -1,10 +1,9 @@ -/* The pane body is a block scroller with a definite height, so the tree takes - that height outright: the header row stays put and the levels below are the - one scroller. */ -.root { +/* The `files` page: its header across the pane, then the content row. */ +.page { display: flex; flex: 1 1 auto; flex-direction: column; + min-width: 0; height: 100%; min-height: 0; color: var(--dsw-alias-label-primary); @@ -12,10 +11,152 @@ line-height: 1.5; } -/* One 38px row flush under the pane's strip: the root's path, then reload at - its end. With the strip's 38px above, its rule lies where the conversation - header's does (ui-conversation ConversationRoot `.header`, 76px with the - same rule). */ +/* Below the header: the pane's navigator. Like the document preview's + content row, it is the box a navigator that fills the row is positioned + against. */ +.content { + position: relative; + /* Child layers stay below the floating pane's resize handle. */ + isolation: isolate; + display: flex; + flex: 1 1 auto; + min-width: 0; + min-height: 0; +} + +/* The navigator takes its content row's height, on its own left rule, at the + pane's clamped width preference the component passes in. */ +.navigator { + position: relative; + display: flex; + flex: none; + flex-direction: column; + box-sizing: border-box; + width: var(--files-navigator-width); + height: 100%; + min-width: 0; + min-height: 0; + color: var(--dsw-alias-label-primary); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: 1.5; + background: var(--dsw-alias-bg-base); + border-left: 0.5px solid var(--dsw-alias-border-l3); +} + +/* The `files` page's navigator fills the page below its header. */ +.wholePage { + flex: 1 1 auto; + width: auto; + border-left: 0; +} + +/* The resize edge straddles the navigator's left rule. */ +.separator { + position: absolute; + top: 0; + bottom: 0; + left: -4px; + z-index: 1; + width: 8px; + cursor: col-resize; + touch-action: none; +} + +/* A row narrower than FileNavigator `FILL_BELOW`. */ +.fill { + position: absolute; + inset: 0; + z-index: 2; + width: auto; + height: auto; + border-left: 0; +} + +/* Covered content stays laid out but cannot receive keyboard focus. */ +:has(> .navigator.fill) > :not(.navigator) { + visibility: hidden; +} + +/* navigator-layout `FULLSCREEN_QUERY`: every layout fills the row. */ +@media (width < 768px) { + :has(> .navigator:not(.wholePage)) > :not(.navigator) { + visibility: hidden; + } + + .navigator { + position: absolute; + inset: 0; + z-index: 2; + width: auto; + height: auto; + border-left: 0; + } + + .separator { + display: none; + } +} + +/* Beside a preview in a row from FileNavigator `FILL_BELOW` on, the navigator + is a raised card inset by the tree body's own 8px so its corners show, with + the floating dock panel's surface (ui-dockkit `.float`) at a radius sized + for a panel this narrow. */ +@media (width >= 768px) { + .push, + .overlay { + background: var(--dsw-alias-bg-layer-2); + border: 0; + border-radius: var(--dsw-radius-lg); + --dsw-elevation-stroke-color: var(--dsw-alias-border-l2); + box-shadow: var(--dsw-elevation-prominent); + /* Elevated surface: the tree's scrollbar takes the l2 thumb pair (ui-theme + styles/scrollbar.css). */ + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); + } + + /* From store `PUSH_FROM` on: a flex item of the row that narrows the body. + The horizontal margins are store `PUSH_MARGINS`. */ + .push { + align-self: stretch; + height: auto; + margin: 8px; + } + + /* Below it: floating over the body's right part. */ + .overlay { + position: absolute; + top: 8px; + right: 8px; + bottom: 8px; + z-index: 2; + height: auto; + } + + :global(body[data-ds-dark-theme]) .push, + :global(body[data-ds-dark-theme]) .overlay { + background: var(--dsw-alias-bg-base); + box-shadow: + var(--dsw-elevation-stroke), + 0 3px 8px 0 color-mix(in srgb, var(--dsw-static-neutral-00) 8%, transparent), + 0 0 20px 0 color-mix(in srgb, var(--dsw-static-neutral-00) 6%, transparent); + } + + /* The bar stops where the card's corners curve, so they do not clip it. */ + .push .body::-webkit-scrollbar-track, + .overlay .body::-webkit-scrollbar-track { + margin: var(--dsw-radius-lg) 2px; + } +} + +.dragging { + cursor: col-resize; + user-select: none; +} + +/* The page's 38px row flush under the pane's strip: the root's path, then + reload and the directory actions. With the strip's 38px above, its rule lies where the conversation header's does + (ui-conversation ConversationRoot `.header`, 76px with the same rule). */ .header { display: flex; flex: 0 0 auto; @@ -51,77 +192,7 @@ margin: 2px; } -.level { - margin: 0; - padding: 0; - list-style: none; -} - -/* Every nested level indents by one step; the root level sits under the header. */ -.level .level { - padding-left: 18px; -} - -.item { - margin: 0; - padding: 0; -} - -.row { - display: flex; - gap: 6px; - align-items: center; - width: 100%; - min-width: 0; - /* Rows abut, so the hover fill is the whole row; the tree's air is this - inset, not a gap between rows. */ - padding: 5px 10px; - color: inherit; - font: inherit; - text-align: left; - background: transparent; - border: 0; - border-radius: var(--dsw-radius-md); - cursor: pointer; -} - -@media (hover: hover) { - .row:hover { - background: var(--dsw-alias-interactive-bg-hover); - } -} - -/* The folder glyph rides the tertiary ink, as the workspace tree's does (ui-workspace Rows `.slot`). */ -.icon { - flex: 0 0 auto; - color: var(--dsw-alias-label-tertiary); -} - -/* The type's coloured sheet at the folder glyph's 16px; its colour is its own. */ -.fileIcon { - flex: 0 0 auto; -} - -.name { - min-width: 0; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} - -/* Neither a file nor a directory: shown so the directory is reported whole, - greyed so nobody tries to open it. */ -.other { - color: var(--dsw-alias-label-tertiary); - cursor: default; -} - -@media (hover: hover) { - .other:hover { - background: transparent; - } -} - +/* The navigator's line before its tree starts, in the tree's note style (ui-primitives `.note`). */ .note { margin: 0; padding: 3px 10px; @@ -172,7 +243,8 @@ } } -.tool[aria-pressed='true'] { +.tool[aria-pressed='true'], +.tool[aria-expanded='true'] { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); } diff --git a/packages/client/ui-sidebar-files/src/client/FilesBody.tsx b/packages/client/ui-sidebar-files/src/client/FilesBody.tsx index d133c4f231..b1361cf415 100644 --- a/packages/client/ui-sidebar-files/src/client/FilesBody.tsx +++ b/packages/client/ui-sidebar-files/src/client/FilesBody.tsx @@ -1,186 +1,60 @@ -import type { ContextMenuTriggerProps } from '@deepseek-ai/dsh-client-ui-context-menu/client' /** - * The file tree's body: the session's workspace root, listed one level at a time. + * The `files` page: a file browser with no file chosen yet. * - * Everything the tree keeps lives in its store, keyed by tab; everything it asks - * for goes through its injected face. The component itself only decides what to - * draw for each absolute path and what a click means: a directory toggles, a - * file opens through the owner's `tabActions` for a `file:` viewer to claim, and - * anything else is shown but refuses to open. The header uses the shared - * PathLabel for the root, followed by reload and workspace directory actions. + * Its header spans the page: the workspace root's path, the tree's reload and + * the directory actions, in the order a preview's header ends with its own + * reload and file actions. The pane's navigator fills the page below it and + * stays shown, so the page draws no toggle; picking a file leaves this page as + * it is and opens the file where the right Sidebar places it by default + * (`FileNavigator`). */ -import { useEffect, useLayoutEffect, useRef } from 'react' +import { useCallback, useEffect } from 'react' import type { ReactNode } from 'react' -import clsx from 'clsx' -import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' +import type {} from '@deepseek-ai/dsh-client-ui-context-menu/client' import type { - PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, TranslateNS, + InjectFace, PropsLocale, PropsRenderFactories, PropsRenderSlots, PropsRuntime, PropsStore, } from '@deepseek-ai/dsh-client-ui-slots' import { - FileTypeIcon, IconFolderCloseRegular, IconFolderOpenRegular, IconRefreshOutlineRegular, Tooltip, classifyFileType, - IconPauseOutlineRegular, IconPlayOutlineRegular, PathLabel, + IconPauseOutlineRegular, IconPlayOutlineRegular, IconRefreshOutlineRegular, PathLabel, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' -import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path' -import type { WorkspaceDirectoryEntry } from '@deepseek-ai/dsh-api-workspace-files/types' -import { childPath } from './face.ts' import type { FilesInjected } from './face.ts' +import type {} from './FileNavigator.tsx' import type {} from './locales.ts' -import type { FilesTabState, createFilesStore } from './store.ts' +import type { createFilesStore } from './store.ts' import css from './FilesBody.module.css' -/** File-tree readers and actions supplied by its registration. */ -export type FilesBodyInjected = FilesInjected - -/** The body's composed props: the tab it draws, its store, its face, and its copy. */ +/** The page's composed props: the tab it draws, its directory-actions seat, the pane records and the tree's face, and copy. */ export type FilesBodyProps = & PropsRuntime<'sidebar.right.pane.tab'> & PropsRenderSlots<'sidebar.right.tab.files.actions'> + & PropsRenderFactories & PropsStore> - & FilesBodyInjected + & InjectFace & PropsLocale<'sidebarFiles'> -/** Natural, case-insensitive name order, so `file2` precedes `file10`. */ -const byName = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }) - /** - * Order one level's entries for display: directories first, then everything - * else, each group by name. The endpoint's order is a listing fact; this is the - * reader's. - * @param entries - the listing as the endpoint returned it. - * @returns a new array, directories first, then by name within each group. + * The `files` page body. + * @param props - composed slot props. + * @returns the header above the pane's navigator, or the workspace line. */ -export function orderEntries(entries: readonly WorkspaceDirectoryEntry[]): WorkspaceDirectoryEntry[] { - return [...entries].sort((left, right) => { - const group = Number(right.type === 'directory') - Number(left.type === 'directory') - return group !== 0 ? group : byName.compare(left.name, right.name) - }) -} - -/** - * Say why a directory could not be listed, in terms of the directory. - * @param t - namespace-bound translate. - * @param failure - the settled Remote failure. - * @returns the line to show under the directory. - */ -export function failureLine(t: TranslateNS<'sidebarFiles'>, failure: RemoteFailure): string { - switch (failure.code) { - case 'workspace-file/not-found': return t('error.notFound') - case 'workspace-file/outside-workspace': return t('error.outsideWorkspace') - case 'workspace-file/not-directory': return t('error.notDirectory') - // Carrier and unclassified host failures reach the reader as themselves: - // this tree knows nothing useful to add to a transport-level message. - default: return t('error.unavailable', { message: failure.message }) - } -} - -/** What every level shares: the tab's tree and the two gestures. */ -interface TreeContext { - readonly state: FilesTabState - readonly onToggle: (parent: string, path: string) => void - readonly onOpen: (path: string) => void - readonly getPathTriggerProps: (kind: 'file' | 'directory', path: string) => ContextMenuTriggerProps - readonly t: TranslateNS<'sidebarFiles'> -} - -/** One entry's row, and its children when it is an expanded directory. */ -function Entry({ parent, entry, tree }: { parent: string; entry: WorkspaceDirectoryEntry; tree: TreeContext }): ReactNode { - const path = childPath(parent, entry.name) - if (entry.type === 'directory') { - const expanded = tree.state.expanded.includes(path) - return ( -
  • - - {expanded &&
    } -
  • - ) - } - if (entry.type === 'file') { - return ( -
  • - -
  • - ) - } - return ( -
  • - - {entry.name} - -
  • - ) -} - -/** One directory's rows: its state while listing, its entries once listed. */ -function Level({ path, tree }: { path: string; tree: TreeContext }): ReactNode { - const { state, t } = tree - const level = state.levels[path] - if (level === undefined || level.kind === 'loading') { - return
  • {t('loading')}
  • - } - if (level.kind === 'failed') { - return ( -
  • - {failureLine(t, level.failure)} -
  • - ) - } - const entries = orderEntries(level.level.entries) - return ( - <> - {level.failure !== undefined &&
  • {failureLine(t, level.failure)}
  • } - {entries.length === 0 &&
  • {t('empty')}
  • } - {entries.map(entry => )} - {level.level.truncated &&
  • {t('truncated')}
  • } - - ) -} - -/** The file tree's body: the workspace root and whatever the reader has opened under it. */ -export function FilesBody({ - useTabInfo, sessionId, useSessions, useStore, actions, - start, refresh, setAutoRefresh, toggle, t, renderSlot, useContextMenu, -}: FilesBodyProps): ReactNode { +export function FilesBody(props: FilesBodyProps): ReactNode { + const { useTabInfo, sessionId, useSessions, useStore, refresh, setAutoRefresh, renderSlot, renderFactorySlot, t, useContextMenu } = props const { tab, panel } = useTabInfo() const { getTriggerProps } = useContextMenu() - const getPathTriggerProps = (kind: 'file' | 'directory', path: string) => getTriggerProps(kind === 'directory' - ? { kind, value: { sessionId, path } } : { kind, value: { sessionId, path, paneId: panel.id } }) - useEffect(() => tab.actions.bindCommands({ refresh: () => { refresh(tab.id) } }), [tab.actions, tab.id, refresh]) - const { signal, actions: tabActions } = tab + const onOpen = useCallback((address: string): void => { + if (panel.isFloating) tab.actions.openResource(address) + else tab.actions.openResource(address, { paneId: panel.id }) + }, [tab.actions, panel.id, panel.isFloating]) + useEffect( + () => tab.actions.bindCommands({ refresh: () => { refresh(panel.id) } }), + [tab.actions, panel.id, refresh], + ) const cwd = useSessions(sessions => sessions.byId[sessionId]?.cwd) - const state = useStore(store => store.byTab[tab.id]) - const bodyRef = useRef(null) - const scrollTopRef = useRef(0) - // Come back where the reader was: loaded levels outlive the body in the - // store, so a remounted tree lays out at its full height before this runs - // and the stored offset re-lands exactly. A fresh tree stores 0. - const seeded = state !== undefined - useLayoutEffect(() => { - const body = bodyRef.current - if (seeded && body !== null) { - body.scrollTop = state.scrollTop - scrollTopRef.current = body.scrollTop - } - }, [seeded]) - // Scrolling only moves the ref; the store hears about it once, on unmount, - // so a scroll neither re-renders the tree nor writes after the owner's - // abort has forgotten the bucket. - useEffect(() => () => { - if (seeded && !signal.aborted) actions.scrolled(tab.id, scrollTopRef.current) - }, [seeded, signal, tab.id, actions]) - useEffect(() => { - // A bucket gone because the record aborted must not be re-seeded by a - // component that has not unmounted yet. - if (state !== undefined || cwd === undefined || signal.aborted) return - start(tab.id, cwd, signal) - }, [state, cwd, tab.id, signal, start]) + // The setting belongs to a listed tree, so there is nothing to set before one. + const autoRefresh = useStore((store) => { + const tree = store.byPane[panel.id]?.tree + return tree?.root === cwd ? tree?.autoRefresh : undefined + }) if (cwd === undefined) { return ( @@ -189,28 +63,19 @@ export function FilesBody({
    ) } - if (state === undefined) return null - const tree: TreeContext = { - state, - onToggle: (parent, path) => { toggle(tab.id, parent, path, state.expanded, signal) }, - // Every row is under the tree's root, so its address is session-relative. - getPathTriggerProps, - onOpen: (path) => { tabActions.openResource(fileAddressFor(sessionId, state.root, path)) }, - t, - } - const reload = (): void => { - refresh(tab.id) - } + const automatic = autoRefresh ?? true + const navigator = renderFactorySlot('sidebar.files.navigator', { useTabInfo, page: true, onOpen }) return ( -
    +
    - + @@ -220,23 +85,14 @@ export function FilesBody({ aria-label={t('reload')} aria-keyshortcuts={tab.refreshShortcut?.aria} data-files-reload - onClick={reload} + onClick={() => { refresh(panel.id) }} > - {renderSlot('sidebar.right.tab.files.actions', { - absolutePath: state.root, - })} -
    -
    { scrollTopRef.current = event.currentTarget.scrollTop }} - > -
    + {renderSlot('sidebar.right.tab.files.actions', { absolutePath: cwd })}
    +
    {navigator}
    ) } diff --git a/packages/client/ui-sidebar-files/src/client/NavigatorToggle.tsx b/packages/client/ui-sidebar-files/src/client/NavigatorToggle.tsx new file mode 100644 index 0000000000..f30da3999d --- /dev/null +++ b/packages/client/ui-sidebar-files/src/client/NavigatorToggle.tsx @@ -0,0 +1,66 @@ +/** + * The navigator's show/hide control, at the end of a document preview's + * header through its Factory; the `files` page's tree is always + * shown and has none. It writes the pane's choice, which every file tab of + * the pane reads, and registers itself with the face, which hands it the + * keyboard when Escape closes the navigator. + */ +import { useCallback } from 'react' +import type { ReactNode } from 'react' +import type { FactoryComponentPropsOf } from '@deepseek-ai/dsh-client-ui-slots' +import type { UseSidebarRightTabInfo } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import { IconFolderCloseRegular, IconFolderOpenRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import type { FilesInjected } from './face.ts' +import type { createFilesStore } from './store.ts' +import type {} from './locales.ts' +import css from './FilesBody.module.css' + +/** The tab whose pane's navigator this control toggles. */ +export interface NavigatorToggleInput { + readonly useTabInfo: UseSidebarRightTabInfo +} + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotFactoryMap { + /** Show or hide the pane's workspace tree; absent without a working directory. */ + 'sidebar.files.navigator.toggle': { + scope: 'session' + props: NavigatorToggleInput + store: ReturnType + locale: 'sidebarFiles' + inject: Pick + } + } +} + +/** + * Show or hide the pane's navigator. + * @param props - the tab, the pane records, the face's registration, and copy. + * @returns the toggle, or nothing without a workspace. + */ +export function NavigatorToggle( + { useTabInfo, sessionId, useSessions, useStore, actions, toggleDrawn, t }: FactoryComponentPropsOf<'sidebar.files.navigator.toggle'>, +): ReactNode { + const { panel } = useTabInfo() + const cwd = useSessions(sessions => sessions.byId[sessionId]?.cwd) + const open = useStore(store => store.byPane[panel.id]?.open) === true + // Stable, so the control registers once per drawing rather than on every render. + const toggleRef = useCallback((button: HTMLElement | null) => { toggleDrawn(panel.id, button) }, [toggleDrawn, panel.id]) + if (cwd === undefined) return null + const label = t(open ? 'navigator.hide' : 'navigator.show') + return ( + + + + ) +} diff --git a/packages/client/ui-sidebar-files/src/client/face.ts b/packages/client/ui-sidebar-files/src/client/face.ts index f1a5c13d96..0957339723 100644 --- a/packages/client/ui-sidebar-files/src/client/face.ts +++ b/packages/client/ui-sidebar-files/src/client/face.ts @@ -1,31 +1,35 @@ /** - * The tree's asynchronous half: listing directories into the store. - * - * The component never awaits anything. It calls `start` / `refresh` / `toggle`, and - * this face performs the listing and writes the outcome through the store's own - * actions — the Slot-standard `inject` shape, so the session id is resolved by - * the framework and the write set stays the store's. - * - * The listing itself is bound here to the Client Remote face: the tree keys - * every level by absolute path and hands the endpoint that same absolute path; - * the endpoint answers with the directory's workspace-relative path as well, - * which the tree has no use for and drops. - * - * One level has one listing in force: asking for a level again — the reload - * gesture, a directory reopened after a reset — retires the listing still in - * flight for it, whose settlement then writes nothing. Cleanup rides the owner's - * `signal`: a request is not made for a record that already ended, and when the - * record goes away the bucket and the tab's listing bookkeeping are forgotten, - * so no later settlement writes to it. + * The navigator's half outside React. The injected face (`filesFace`) lists + * directories into the store through its own actions, reveals the tab's file, + * carries scroll and keyboard focus between drawings of a pane's tree, and + * measures the navigator's row. The component never awaits or observes + * anything: it registers what it draws and calls the face. The per-pane + * lifetimes are stated on `attach` and `hold`. Beside the face live its + * inputs: the Remote listing and watch bindings and `canOpenOf`. */ import type { ClientRemote, RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' -import type { BoundActions } from '@deepseek-ai/dsh-client-store' -import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { PaneId, TabId } from '@deepseek-ai/dsh-client-ui-dockkit' +import type { SidebarRightTabDefinition, SidebarRightTabNavigation } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { DirLevel, createFilesStore } from './store.ts' +import { childPath, locateFile, relativeFileOf, type DirLevel, type FilesStoreInstance } from './store.ts' import type { WorkspaceFileWatchFrame } from '@deepseek-ai/dsh-api-workspace-files/types' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { DirectoryNode } from './directory-node.ts' +import { FILL_BELOW, FULLSCREEN_QUERY } from './navigator-layout.ts' + +/** + * The box an element is laid out in: its nearest ancestor that generates one, + * passing over wrappers drawn with `display: contents`. + * @param element - the laid-out element. + * @returns that ancestor, or `null` for a detached element. + */ +function layoutParent(element: HTMLElement): HTMLElement | null { + let parent = element.parentElement + while (parent !== null && getComputedStyle(parent).display === 'contents') parent = parent.parentElement + return parent +} /** * Observe one directory without recursively watching its descendants. @@ -87,7 +91,7 @@ export type WorkspaceFilesListRemote = { } /** - * Bind the listing to one Remote face, keeping only what the tree stores. + * Bind the listing to one Remote face. * @param remote - the Client Remote face carrying the `workspaceFiles` namespace. * @returns the listing the tree's face performs. */ @@ -99,123 +103,667 @@ export function createList(remote: WorkspaceFilesListRemote): ListWorkspaceDirec } } -/** - * The absolute path of one child entry. - * - * Joined with `/` whatever the parent's separators: the Host resolves mixed - * separators, and the tree only needs a stable key. - * @param parent - absolute path of the listed directory. - * @param name - the entry's basename. - * @returns the child's absolute path. - */ -export function childPath(parent: string, name: string): string { - return `${parent.replace(/[/\\]+$/, '')}/${name}` -} - -/** The tree's injected business face, as the body receives it. */ +/** The tree's injected business face, as the navigator receives it. */ export interface FilesInjected { - /** Refresh the open directory tree. @param tabId - owning tab. */ - readonly refresh: (tabId: TabId) => void - /** Control automatic rereads without closing watches. @param tabId - owning tab. @param enabled - automatic-refresh setting. */ - readonly setAutoRefresh: (tabId: TabId, enabled: boolean) => void /** - * Seed this tab's tree and list its root. - * @param tabId - the tab being drawn. + * Register a navigator mounted for one tab in one pane, until the returned + * release. A pane's records — the store's tree, the scroll offset, the file + * revealed last — last while a navigator is registered there or an open tab, + * judged by its `tabSignal`, whose latest navigator was there. Once neither + * holds when the task ends, they are forgotten, the listing closes, and a + * listing that settles afterwards writes nothing. A tab replaced in place + * keeps them because its successor registers in the same task; floating or + * docking a tab never ends them by itself. + * @param paneId - the pane being drawn. + * @param tabId - the tab the navigator is drawn for. + * @param tabSignal - that tab's lifetime. + * @returns the release. + */ + readonly attach: (paneId: PaneId, tabId: TabId, tabSignal: AbortSignal) => () => void + /** + * Place this face beside a tab's content for the workspace command, while a + * navigator that can draw a tree is mounted there, until the returned + * release; the pane's records do not depend on it. + * @param tabId - the tab whose content the navigator sits beside. + * @returns the release. + */ + readonly placeBeside: (tabId: TabId) => () => void + /** + * List an attached pane's tree at a workspace directory, with its watches, + * while a navigator draws it. A tree the store keeps at that root is listed + * again in place, its kept rows still drawn; one at another root, or none, is + * replaced, resetting the scroll offset and the revealed file. A hold taken + * in the same task as the last release, as a tab switch's next drawing does, + * keeps the listing open and lists nothing. + * @param paneId - the pane being drawn. * @param root - absolute path of the workspace root. - * @param signal - the tab record's lifetime. + * @returns the release; the listing closes once no hold remains when the current task ends. */ - readonly start: (tabId: TabId, root: string, signal: AbortSignal) => void - /** - * List one directory into the store. - * @param tabId - the tab being drawn. - * @param path - absolute directory path. - * @param signal - the tab record's lifetime. - */ - readonly load: (tabId: TabId, path: string, signal: AbortSignal) => void + readonly hold: (paneId: PaneId, root: string) => () => void + /** Refresh the open directory tree. @param paneId - owning pane. */ + readonly refresh: (paneId: PaneId) => void + /** Control automatic rereads without closing watches. @param paneId - owning pane. @param enabled - automatic-refresh setting. */ + readonly setAutoRefresh: (paneId: PaneId, enabled: boolean) => void /** * Open or collapse one directory, retaining intent during ancestor restoration. - * @param tabId - the tab being drawn. + * @param paneId - the pane being drawn. * @param parentPath - the listed parent directory's exact tree key. * @param path - absolute directory path. * @param expanded - current expansion preferences, including descendants to restore. - * @param signal - the tab record's lifetime. */ - readonly toggle: (tabId: TabId, parentPath: string, path: string, expanded: readonly string[], signal: AbortSignal) => void + readonly toggle: (paneId: PaneId, parentPath: string, path: string, expanded: readonly string[]) => void + /** + * Record where a pane's tree is scrolled to, without rendering anything. + * @param paneId - the pane being drawn. + * @param scrollTop - the scroll offset in px. + */ + readonly scrolled: (paneId: PaneId, scrollTop: number) => void + /** + * Hide a pane's navigator; when its drawing held keyboard focus, the pane's + * toggle takes it once the commit ends, as on Escape. + * @param paneId - the pane to hide it in. + */ + readonly hide: (paneId: PaneId) => void + /** + * Show a pane's navigator and give its tree keyboard focus now, or when it + * is next drawn; a pane drawn but not yet attached shows it once attached. + * A pane with no drawing recorded is left alone. + * @param paneId - the pane whose tree takes focus. + */ + readonly focus: (paneId: PaneId) => void + /** + * Register the tree's scroll container as drawn for one tab, or with `null` + * as gone. Drawing restores the pane's scroll offset, takes keyboard focus + * that the previous drawing held when it went or that `focus` asked for, and + * reveals the tab's file unless the pane revealed it last. + * @param paneId - the pane being drawn. + * @param address - the tab's content address. + * @param body - the scroll container, or `null` once it is removed. + */ + readonly bodyDrawn: (paneId: PaneId, address: string, body: HTMLElement | null) => void + /** + * Register the drawn row of the tab's file, or with `null` as gone; a reveal + * that completed before the row was drawn scrolls it into view then. + * @param paneId - the pane being drawn. + * @param row - the row, or `null` once it is removed or no longer current. + */ + readonly currentDrawn: (paneId: PaneId, row: HTMLElement | null) => void + /** + * Register a navigator's element, or with `null` as gone. While it is + * drawn, the face measures the row it is laid out in and, when `closable`, + * closes the navigator on Escape in that row. When a new width leaves the + * focused resize edge removed or hidden, the tree takes keyboard focus. + * When a closable navigator starts filling the row, including its first + * drawing, focus in the covered content moves to the tree without closing it; + * so does focus that hiding the covered content left on the document body, + * unless the reader moved the keyboard or pointer or the window lost focus + * before the width was reported. + * @param paneId - the pane being drawn. + * @param navigator - the navigator element, or `null` once it is removed. + * @param closable - whether Escape closes this drawing; the `files` page's tree stays shown. + */ + readonly navigatorDrawn: (paneId: PaneId, navigator: HTMLElement | null, closable: boolean) => void + /** + * Register the drawn show/hide control of a pane's navigator, or with `null` as gone. + * @param paneId - the pane the control is drawn in. + * @param toggle - the control, or `null` once it is removed. + */ + readonly toggleDrawn: (paneId: PaneId, toggle: HTMLElement | null) => void + readonly hooks: { + /** Whether some registered tab type can open an address; a new predicate whenever the tab types change. */ + readonly canOpen: HostObservable<(address: string) => boolean> + } + readonly keyedHooks: { + /** The measured width of the row a pane's navigator is laid out in, in CSS pixels, keyed by pane id; `undefined` until measured. */ + readonly rowWidth: (paneId: string) => HostObservable + } } +/** The slice of the right Sidebar's tab type registry that tells whether an address can be opened. */ +export interface TabTypeRegistry { + /** @returns the registered types; a new array whenever registrations change. */ + entries(): readonly SidebarRightTabDefinition[] + /** @param address - a resource address. @returns the types that would open it, best first. */ + candidates(address: string): readonly SidebarRightTabDefinition[] + /** @param listener - called after registrations change. @returns the unsubscribe. */ + subscribe(listener: () => void): () => void +} + +/** + * Observe whether any registered tab type can open an address. + * @param registry - the right Sidebar's tab type registry. + * @returns a source whose snapshot is a predicate, replaced whenever registrations change. + */ +export function canOpenOf(registry: TabTypeRegistry): HostObservable<(address: string) => boolean> { + let seen = registry.entries() + let check = (address: string): boolean => registry.candidates(address).length > 0 + return { + getSnapshot: () => { + const entries = registry.entries() + if (entries !== seen) { + seen = entries + check = address => registry.candidates(address).length > 0 + } + return check + }, + subscribe: listener => registry.subscribe(listener), + } +} + +/** One pane's listing and drawings, outside React. */ +interface PaneRecord { + /** Ends when the records are forgotten; set once a navigator attached the pane. */ + lifetime: AbortController | undefined + /** Navigators registered in the pane. */ + mounts: number + /** Open tabs whose latest navigator was in the pane. */ + readonly tabs: Set + /** Navigators drawing the tree. */ + holds: number + /** The listing and its watches, while the tree is held. */ + root: DirectoryNode | undefined + /** Where the tree was last scrolled to, in px. */ + scrollTop: number + /** The file whose row was revealed last, so a remount does not reveal it again. */ + revealed: string | undefined + /** The file whose row a reveal is walking to. */ + revealing: string | undefined + /** The drawn tree's scroll container and the address of the tab it is drawn for. */ + drawn: { readonly body: HTMLElement; readonly address: string } | undefined + /** The drawn row of the tab's file. */ + current: HTMLElement | undefined + /** A completed reveal waits for its row to be drawn. */ + scrollToCurrent: boolean + /** Who asked for keyboard focus at the tree's next drawing. */ + focus: object | undefined + /** `focus` asked to show the navigator before the pane attached; attaching shows it. */ + show: boolean + /** The measured width of the row the navigator shares with the preview or the page's line. */ + readonly width: SnapshotStore + /** The drawn navigator element. */ + navigator: HTMLElement | undefined + /** Whether the drawn navigator may close to expose its content. */ + closable: boolean + /** Stop measuring the row and listening for Escape in it. */ + unobserve: (() => void) | undefined + /** The drawn show/hide control. */ + toggle: HTMLElement | undefined +} + +/** One tab's navigation consumption and latest drawing, retained until the tab closes. */ +interface TabHome { + paneId: PaneId + pane: PaneRecord + mounts: number + revision: number + readonly navigation: HostObservable +} + +/** `focus` asked for the tree's keyboard focus. */ +const REQUESTED = {} + +/** + * Place a face beside a tab's content, where the workspace command finds it, + * until the returned release. + * @param tabId - the tab whose content a navigator sits beside. + * @param face - the face that navigator draws through. + * @returns the release. + */ +export type PlaceFace = (tabId: TabId, face: FilesInjected) => () => void + /** * Bind the tree's face to one directory listing. * @param list - the bound `workspaceFiles.list` call. * @param watch - target-scoped directory observation. - * @returns the Slot `inject` factory: session and bound actions in, face out. + * @param canOpen - whether some tab type can open an address. + * @param navigationOf - the attached tab's navigation; subscriptions end with this plugin or their disposer. + * @returns the face factory: session, its store instance, and where to place the face beside tabs in, face out. */ export function filesFace( list: ListWorkspaceDirectory, watch: WatchWorkspaceDirectory, -): (sessionId: SessionId, actions: BoundActions>) => FilesInjected { - return ( - sessionId: SessionId, - actions: BoundActions>, - ): FilesInjected => { - /** Per tab, per absolute path: the listing generation a settlement must match; the latest request wins. */ - const generations = new Map>() - const roots = new Map() - const nextGeneration = (tabId: TabId, path: string): number => { - const byPath = generations.get(tabId) ?? new Map() - generations.set(tabId, byPath) - const generation = (byPath.get(path) ?? 0) + 1 - byPath.set(path, generation) - return generation + canOpen: HostObservable<(address: string) => boolean>, + navigationOf: (sessionId: SessionId, tabId: TabId) => HostObservable, +): ( + sessionId: SessionId, + store: Pick, + place: PlaceFace, +) => FilesInjected { + return (sessionId, store, place): FilesInjected => { + const { actions } = store + const panes = new Map() + const record = (paneId: PaneId): PaneRecord => { + let pane = panes.get(paneId) + if (pane === undefined) { + pane = { + lifetime: undefined, mounts: 0, tabs: new Set(), holds: 0, root: undefined, scrollTop: 0, + revealed: undefined, revealing: undefined, drawn: undefined, current: undefined, scrollToCurrent: false, + focus: undefined, show: false, + width: createSnapshotStore(undefined), + navigator: undefined, closable: false, unobserve: undefined, toggle: undefined, + } + panes.set(paneId, pane) + } + return pane } - const load = async (tabId: TabId, path: string, signal: AbortSignal): Promise => { - if (signal.aborted) return - const generation = nextGeneration(tabId, path) - actions.loading(tabId, path) - return list(sessionId, path, signal).then((result) => { - // A newer listing of this level was asked for since, or the record is - // gone and its bookkeeping with it: nothing left for this one to write. - if (signal.aborted || generations.get(tabId)?.get(path) !== generation) return - if (result.ok) actions.loaded(tabId, path, result.value) - else actions.failed(tabId, path, result.error) - return result.ok ? result.value : undefined + /** Each open tab's latest pane. */ + const homes = new Map() + const forget = (paneId: PaneId, pane: PaneRecord): void => { + pane.lifetime?.abort() + void pane.root?.close() + pane.root = undefined + pane.unobserve?.() + panes.delete(paneId) + actions.forget(paneId) + } + // A tab replaced in place goes before its successor mounts, in the same task. + const settle = (paneId: PaneId, pane: PaneRecord): void => { + if (pane.mounts > 0 || pane.tabs.size > 0) return + queueMicrotask(() => { + if (pane.mounts === 0 && pane.tabs.size === 0 && panes.get(paneId) === pane) forget(paneId, pane) }) } - return { - refresh: (tabId) => { void roots.get(tabId)?.refreshTree() }, - setAutoRefresh: (tabId, enabled) => { - actions.autoRefresh(tabId, enabled) - roots.get(tabId)?.setAutomatic(enabled) - }, - start(tabId, root, signal) { - actions.start(tabId, root) - signal.addEventListener('abort', () => { - void roots.get(tabId)?.close() - roots.delete(tabId) - generations.delete(tabId) - actions.forget(tabId) - }, { once: true }) - roots.set(tabId, new DirectoryNode(root, - (path, lifetime) => load(tabId, path, lifetime), - (path, lifetime) => watch(sessionId, path, lifetime), - (path, error) => { - if (!signal.aborted) actions.failed(tabId, path, new RemoteError('gateway/internal', error instanceof Error ? error.message : String(error), {})) - }, signal, - ).open()) - }, - load: (tabId, path, signal) => { void load(tabId, path, signal) }, - toggle(tabId, parentPath, path, expanded, signal) { - if (signal.aborted) return - const root = roots.get(tabId) - if (root === undefined) return - const parent = root.find(parentPath) - if (parent === undefined && !expanded.includes(parentPath)) return - const collapsing = expanded.includes(path) - const next = collapsing ? expanded.filter(value => value !== path) : [...expanded, path] - root.setExpanded(next) - if (collapsing) void parent?.collapse(path) - else parent?.expand(path, next) - actions.toggled(tabId, path) - }, + const leave = (tabId: TabId, home: { readonly paneId: PaneId; readonly pane: PaneRecord }): void => { + home.pane.tabs.delete(tabId) + settle(home.paneId, home.pane) } + const load = async (paneId: PaneId, path: string, signal: AbortSignal): Promise => { + const aborted = (): boolean => signal.aborted + // A closed node has nothing left to write, and its pane's tree may be gone. + if (aborted()) return undefined + actions.loading(paneId, path) + const result = await list(sessionId, path, signal) + if (aborted()) return undefined + if (result.ok) actions.loaded(paneId, path, result.value) + else actions.failed(paneId, path, result.error) + return result.ok ? result.value : undefined + } + // Store writes after which a reveal takes one step, rather than one per write. + let batching = false + const batch = (write: () => void): void => { + batching = true + try { + write() + } finally { + batching = false + } + } + // A kept level is listed again only once its directory's watch is ready; + // until that listing settles, a reveal must not take its entries as current. + const relisting = (paneId: PaneId, paths: readonly string[]): void => { + for (const path of paths) { + if (store.getSnapshot().byPane[paneId]?.tree?.levels[path]?.kind === 'ready') actions.loading(paneId, path) + } + } + // A record made by a drawing whose navigator never attached, because its + // tab had closed, has nothing else to end it. + const drop = (paneId: PaneId): void => { + const pane = panes.get(paneId) + if (pane !== undefined && pane.lifetime === undefined) settle(paneId, pane) + } + // `manual` is the reader's toggle; a reveal's own expansions pass `false`. + const expand = (paneId: PaneId, parentPath: string, path: string, expanded: readonly string[], manual: boolean): void => { + const pane = panes.get(paneId) + const root = pane?.root + if (pane === undefined || root === undefined) return + const parent = root.find(parentPath) + if (parent === undefined && !expanded.includes(parentPath)) return + const collapsing = expanded.includes(path) + // A reader who collapses a directory ends the pane's reveal, so it neither + // expands that directory again nor replays when the tree is drawn again. + if (manual && collapsing && pane.revealing !== undefined) { + pane.revealed = pane.revealing + pane.revealing = undefined + } + const next = collapsing ? expanded.filter(value => value !== path) : [...expanded, path] + root.setExpanded(next) + if (collapsing) void parent?.collapse(path) + else parent?.expand(path, next) + batch(() => { + actions.toggled(paneId, path) + if (!collapsing) relisting(paneId, next.filter(value => value === path || value.startsWith(childPath(path, '')))) + }) + reveal() + } + const focusTree = (pane: PaneRecord): void => { + const handoff = {} + pane.focus = handoff + // The dock keeps a newly shown tab's host inert until its own layout + // effect, later in this drawing's commit, and the tab's body may draw the + // tree again before the task ends; focus moves once the commit ends, to + // the drawing in place then. + queueMicrotask(() => { + if (pane.focus !== handoff) return + pane.focus = undefined + const target = pane.current ?? pane.drawn?.body + target?.focus() + }) + } + // One step of a reveal: expand the next collapsed ancestor, wait while a + // level on the way lists, or finish. Every store change takes the next step; + // a kept tree without its listing waits for the hold that lists it again. + const step = (paneId: PaneId, pane: PaneRecord): void => { + const address = pane.revealing + const tree = store.getSnapshot().byPane[paneId]?.tree + if (address === undefined || tree === undefined || pane.root === undefined) return + const path = relativeFileOf(address, sessionId, tree.root) + const location = path === undefined ? undefined : locateFile(tree, path) + const next = location?.ancestors.find(ancestor => !tree.expanded.includes(ancestor.path)) + if (next !== undefined) { + expand(paneId, next.parent, next.path, tree.expanded, false) + return + } + if (location?.pending === true) return + pane.revealing = undefined + pane.revealed = address + if (location?.current === undefined) return + if (pane.current === undefined) pane.scrollToCurrent = true + else pane.current.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + } + const reveal = (): void => { + for (const [paneId, pane] of panes) if (pane.revealing !== undefined) step(paneId, pane) + } + store.subscribe(() => { if (!batching) reveal() }) + const open = (paneId: PaneId, pane: PaneRecord, signal: AbortSignal, root: string): void => { + if (pane.root?.path === root) return + void pane.root?.close() + const tree = store.getSnapshot().byPane[paneId]?.tree + const kept = tree?.root === root ? tree : undefined + if (kept === undefined) { + actions.start(paneId, root) + pane.scrollTop = 0 + pane.revealed = undefined + pane.revealing = pane.drawn?.address + } + // The node's own lifetime ends with the pane, the last hold, or a later root replacing it. + const node = new DirectoryNode(root, + (path, lifetime) => load(paneId, path, lifetime), + (path, lifetime) => watch(sessionId, path, lifetime), + (path, error) => { + if (pane.root === node) actions.failed(paneId, path, new RemoteError('gateway/internal', error instanceof Error ? error.message : String(error), {})) + }, signal, kept?.expanded, + ) + if (kept !== undefined) node.setAutomatic(kept.autoRefresh) + pane.root = node + node.open() + if (kept !== undefined) batch(() => { relisting(paneId, kept.expanded) }) + step(paneId, pane) + } + // A navigation belongs to its target tab, not the tree that initiated it. + const answerNavigation = (home: TabHome): void => { + if (home.mounts === 0) return + const navigation = home.navigation.getSnapshot() + if (navigation.revision <= home.revision) return + const { pane, paneId } = home + if (store.getSnapshot().byPane[paneId]?.open !== true) { + home.revision = navigation.revision + return + } + const navigator = pane.navigator + if (navigator === undefined || pane.drawn?.address !== navigation.address) return + const row = layoutParent(navigator) + if (row === null) return + home.revision = navigation.revision + const window = row.ownerDocument.defaultView + if (pane.closable && (row.getBoundingClientRect().width < FILL_BELOW + || (typeof window?.matchMedia === 'function' && window.matchMedia(FULLSCREEN_QUERY).matches))) face.hide(paneId) + } + const answerDrawn = (pane: PaneRecord): void => { + for (const home of homes.values()) if (home.pane === pane) answerNavigation(home) + } + const face: FilesInjected = { + attach(paneId, tabId, tabSignal) { + if (tabSignal.aborted) { + drop(paneId) + return () => {} + } + const pane = record(paneId) + pane.lifetime ??= new AbortController() + if (pane.show) { + pane.show = false + actions.shown(paneId, true) + } + pane.mounts += 1 + let home = homes.get(tabId) + if (home === undefined) { + const first: TabHome = { paneId, pane, mounts: 0, revision: 0, navigation: navigationOf(sessionId, tabId) } + home = first + homes.set(tabId, first) + const unsubscribe = first.navigation.subscribe(() => { answerNavigation(first) }) + tabSignal.addEventListener('abort', () => { + unsubscribe() + homes.delete(tabId) + leave(tabId, first) + }, { once: true }) + } else if (home.pane !== pane) { + leave(tabId, home) + home.paneId = paneId + home.pane = pane + } + home.mounts += 1 + pane.tabs.add(tabId) + answerNavigation(home) + let attached = true + return () => { + if (!attached) return + attached = false + pane.mounts -= 1 + home.mounts -= 1 + settle(paneId, pane) + } + }, + placeBeside: tabId => place(tabId, face), + hold(paneId, root) { + const pane = panes.get(paneId) + if (pane?.lifetime === undefined) return () => {} + pane.holds += 1 + open(paneId, pane, pane.lifetime.signal, root) + let held = true + return () => { + if (!held) return + held = false + pane.holds -= 1 + // A tab switch draws the next tree in the same commit, which holds it again first. + queueMicrotask(() => { + if (pane.holds > 0) return + void pane.root?.close() + pane.root = undefined + }) + } + }, + refresh: (paneId) => { void panes.get(paneId)?.root?.refreshTree() }, + setAutoRefresh: (paneId, enabled) => { + actions.autoRefresh(paneId, enabled) + panes.get(paneId)?.root?.setAutomatic(enabled) + }, + toggle: (paneId, parentPath, path, expanded) => { expand(paneId, parentPath, path, expanded, true) }, + scrolled: (paneId, scrollTop) => { + const pane = panes.get(paneId) + if (pane?.lifetime !== undefined) pane.scrollTop = scrollTop + }, + hide(paneId) { + const pane = panes.get(paneId) + // The store keeps records only for attached panes. + if (pane?.lifetime === undefined) return + const navigator = pane.navigator + const held = pane.focus !== undefined || navigator?.contains(navigator.ownerDocument.activeElement) === true + pane.focus = undefined + actions.shown(paneId, false) + // The tab an open lands on draws its own toggle in the commit React + // flushes in a microtask queued before this one, so this focus finds it. + if (held) queueMicrotask(() => { panes.get(paneId)?.toggle?.focus() }) + }, + focus(paneId) { + // A tab that just moved draws in its new pane before that drawing's + // effects attach it, so its record, made by the drawing, is enough; the + // store records the navigator shown once the pane attaches. + const pane = panes.get(paneId) + if (pane === undefined) return + if (pane.lifetime === undefined) pane.show = true + else actions.shown(paneId, true) + if (pane.drawn === undefined) pane.focus = REQUESTED + else focusTree(pane) + }, + bodyDrawn(paneId, address, body) { + if (body === null) { + // A drawing goes after its pane may have been forgotten; that leaves nothing to record. + const pane = panes.get(paneId) + if (pane === undefined) return + const gone = pane.drawn?.body + pane.drawn = undefined + pane.revealing = undefined + pane.scrollToCurrent = false + drop(paneId) + if (gone?.contains(gone.ownerDocument.activeElement) !== true) return + // Only a drawing in the same commit, the next tab's, takes the keyboard over. + const handoff = {} + pane.focus = handoff + queueMicrotask(() => { if (pane.focus === handoff) pane.focus = undefined }) + return + } + const pane = record(paneId) + pane.drawn = { body, address } + body.scrollTop = pane.scrollTop + if (pane.focus !== undefined) focusTree(pane) + pane.revealing = pane.revealed === address ? undefined : address + step(paneId, pane) + }, + currentDrawn(paneId, row) { + if (row === null) { + const pane = panes.get(paneId) + if (pane !== undefined) pane.current = undefined + drop(paneId) + return + } + const pane = record(paneId) + pane.current = row + if (!pane.scrollToCurrent) return + pane.scrollToCurrent = false + row.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + }, + navigatorDrawn(paneId, navigator, closable) { + const pane = navigator === null ? panes.get(paneId) : record(paneId) + if (pane === undefined) return + pane.unobserve?.() + pane.unobserve = undefined + pane.navigator = navigator ?? undefined + pane.closable = closable + if (navigator === null) { + drop(paneId) + return + } + const row = layoutParent(navigator) + if (row === null) return + const document = row.ownerDocument + // The resize edge while it holds the keyboard. A width at which the + // tree fills the row removes it, and the fullscreen viewport hides it, + // which blurs it before the row reports its new width; either way the + // keyboard moves to the tree. + let edge: Element | undefined + const handOff = (): void => { + const held = edge + if (held === undefined) return + const active = document.activeElement + if (active !== held && active !== document.body && active !== null) { + edge = undefined + return + } + // An edge still drawn keeps the mark only while it still holds the keyboard. + if (held.isConnected && getComputedStyle(held).display !== 'none') { + if (active !== held) edge = undefined + return + } + edge = undefined + focusTree(pane) + } + const focusin = (event: FocusEvent): void => { + edge = event.target instanceof Element && event.target.hasAttribute('data-files-navigator-resize') ? event.target : undefined + } + const focusout = (event: FocusEvent): void => { + if (event.relatedTarget === null) queueMicrotask(handOff) + else edge = undefined + } + const viewport = closable && typeof matchMedia === 'function' ? matchMedia(FULLSCREEN_QUERY) : undefined + let filling = false + const hidden = (element: Element): boolean => getComputedStyle(element).visibility === 'hidden' + // Covered content that a fill's `visibility: hidden` blurred to nowhere, + // until the next measurement or until the reader moves the keyboard or + // the pointer, or the window loses focus. The browser may blur it in a + // task before the observer or the media query reports the fill. + let covered: Element | undefined + const release = (event: FocusEvent): void => { + const target = event.target + covered = event.relatedTarget === null && target instanceof Element && !navigator.contains(target) && hidden(target) + ? target + : undefined + } + const forget = (): void => { covered = undefined } + const measure = (): void => { + // Layout can blur content that the fullscreen stylesheet hides. + const active = document.activeElement + const blurred = covered + covered = undefined + const width = row.getBoundingClientRect().width + const fills = closable && (width < FILL_BELOW || viewport?.matches === true) + const entering = fills && !filling + filling = fills + pane.width.set(width) + answerDrawn(pane) + const inContent = active !== null && row.contains(active) && !navigator.contains(active) + const blurredByFill = blurred !== undefined && (active === null || active === document.body) + && row.contains(blurred) && hidden(blurred) + if (entering && store.getSnapshot().byPane[paneId]?.open === true + && (inContent || blurredByFill) && pane.focus === undefined) focusTree(pane) + // React removes the edge in the commit it flushes in a microtask + // queued before this one, so the check sees the new drawing. + if (edge !== undefined) queueMicrotask(handOff) + } + // On the document, so the row's own handlers see the key first and can keep it. + const close = (event: KeyboardEvent): void => { + if (event.key !== 'Escape' || event.defaultPrevented || event.isComposing + || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return + if (!(event.target instanceof Node) || !row.contains(event.target)) return + event.preventDefault() + pane.toggle?.focus() + actions.shown(paneId, false) + } + measure() + const observer = new ResizeObserver(measure) + observer.observe(row) + viewport?.addEventListener('change', measure) + if (closable) { + document.addEventListener('keydown', close) + row.addEventListener('focusout', release) + document.addEventListener('focusin', forget, true) + document.addEventListener('pointerdown', forget, true) + window.addEventListener('blur', forget) + } + navigator.addEventListener('focusin', focusin) + navigator.addEventListener('focusout', focusout) + pane.unobserve = () => { + observer.disconnect() + viewport?.removeEventListener('change', measure) + document.removeEventListener('keydown', close) + row.removeEventListener('focusout', release) + document.removeEventListener('focusin', forget, true) + document.removeEventListener('pointerdown', forget, true) + window.removeEventListener('blur', forget) + navigator.removeEventListener('focusin', focusin) + navigator.removeEventListener('focusout', focusout) + } + }, + toggleDrawn(paneId, toggle) { + if (toggle !== null) { + record(paneId).toggle = toggle + return + } + const pane = panes.get(paneId) + if (pane !== undefined) pane.toggle = undefined + drop(paneId) + }, + hooks: { canOpen }, + keyedHooks: { rowWidth: paneId => record(paneId as PaneId).width }, + } + return face } } diff --git a/packages/client/ui-sidebar-files/src/client/index.ts b/packages/client/ui-sidebar-files/src/client/index.ts index 1cb9166f85..050f59caae 100644 --- a/packages/client/ui-sidebar-files/src/client/index.ts +++ b/packages/client/ui-sidebar-files/src/client/index.ts @@ -1,15 +1,9 @@ import type {} from '@deepseek-ai/dsh-client-ui-context-menu/client' /** - * Browser half: register `files` as a right-Sidebar tab type. - * - * The public two-stage path, unmodified: the type into `ctx.sidebarRightTabs`, - * the body into the keyed `sidebar.right.pane.tab` seat and the chip title into - * the keyed `sidebar.right.pane.tab.title` seat, both under the type's `id`. - * - * The file split is this package's layering: what the type IS - * (`definition.tsx`), what it keeps (`store.ts`), how it lists (`face.ts`), what - * it draws (`FilesBody.tsx`, `FilesTitle.tsx`), what it says (`locales.ts`), - * and this module, which only wires them together. + * Browser half: register `files` as a right-Sidebar tab type, and the + * workspace navigator beside every file preview. The navigator, the page body, + * and the toggle share one store and one face per store instance. The package + * README lists the registrations. */ import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' @@ -17,21 +11,31 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' -import { FILES_ID, filesDefinition } from './definition.tsx' -import { createList, createWatch, filesFace } from './face.ts' +import type { BoundActions } from '@deepseek-ai/dsh-client-store' +import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { FILES_ID, FILES_KIND, filesDefinition } from './definition.tsx' +import { canOpenOf, createList, createWatch, filesFace, type FilesInjected } from './face.ts' +import { FileTree } from './FileTree.tsx' +import { FileNavigator } from './FileNavigator.tsx' import { FilesBody } from './FilesBody.tsx' import { FilesTitle } from './FilesTitle.tsx' import { en, ja, es, fr, de, ru, pt, zh } from './locales.ts' +import { NavigatorToggle } from './NavigatorToggle.tsx' import { createFilesStore } from './store.ts' export type { SidebarFilesKey } from './locales.ts' -export type { DirLevel, FilesState, FilesTabState, LevelState } from './store.ts' +export type { DirLevel, FilesPaneState, FilesState, FilesTreeState, LevelState } from './store.ts' export type { FilesInjected, ListWorkspaceDirectory, WorkspaceFilesListRemote } from './face.ts' export type { FilesBodyProps } from './FilesBody.tsx' +// Also keeps the Factory declaration in this entry's emitted types, which an `import type {}` would not. +export type { TreeEntry, TreeEntryMeta, TreeEntryKind, TreeDirectoryState, FileTreeInput } from './FileTree.tsx' +export type { FileNavigatorInput } from './FileNavigator.tsx' +export type { NavigatorToggleInput } from './NavigatorToggle.tsx' declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { - /** Workspace directory actions after the file tree's reload control. */ + /** Workspace directory actions after the `files` page's reload control. */ 'sidebar.right.tab.files.actions': { kind: 'list' scope: 'session' @@ -53,11 +57,67 @@ const NS = 'sidebarFiles' export const inject = ['slots', 'locale', 'sidebarRightTabs', 'sidebarRight', 'remote', 'remote.workspaceFiles'] /** - * Client plugin body: register the type, its dictionaries, its body, and its chip title. + * Client plugin body: register the type, its shortcut, its dictionaries, the + * navigator and toggle Factories, the page body, and its + * chip title. * @param ctx - client root context carrying the registry, the slots, and the Remote face. */ export function apply(ctx: ClientContext): void { const t = ctx.locale.bind(NS) + const handle = createFilesStore() + const face = filesFace(createList(ctx.remote), createWatch(ctx.remote), canOpenOf(ctx.sidebarRightTabs), (sessionId, tabId) => { + const source = ctx.sidebarRight.tabDomain.occurrence(sessionId, { id: tabId }).navigation + return { + getSnapshot: () => source.getSnapshot(), + subscribe: listener => ctx.effect(() => { + let active = true + const unsubscribe = source.subscribe(() => { + // The tab's navigation is recorded inside the layout operation; observe its committed drawing. + queueMicrotask(() => { if (active) listener() }) + }) + return () => { active = false; unsubscribe() } + }, 'ui-sidebar-files: tab navigation'), + } + }) + // A Factory runs its inject per occurrence, so each store instance gets its + // one face when it is minted. The shortcut, which acts outside any body, finds + // the face placed beside the target tab; a tab's loading and loaded drawings + // each place it, so placements are counted, and the latest face placed wins. + const faces = new WeakMap() + const beside = new Map>() + const place = (sessionId: SessionId) => (tabId: TabId, bound: FilesInjected): () => void => { + const tabs = beside.get(sessionId) ?? new Map() + beside.set(sessionId, tabs) + const held = tabs.get(tabId) + const entry = held?.face === bound ? held : { face: bound, placed: 0 } + entry.placed += 1 + tabs.set(tabId, entry) + let placed = true + return () => { + if (!placed) return + placed = false + entry.placed -= 1 + if (entry.placed > 0 || tabs.get(tabId) !== entry) return + tabs.delete(tabId) + if (tabs.size === 0) beside.delete(sessionId) + } + } + const store: typeof handle = { + ...handle, + create: (scopeKey) => { + const instance = handle.create(scopeKey) + if (scopeKey === undefined) return instance + const sessionId = scopeKey as SessionId + faces.set(instance.actions, face(sessionId, instance, place(sessionId))) + return instance + }, + } + const injectFace = (sessionId: SessionId, actions: BoundActions): FilesInjected => { + const bound = faces.get(actions) + if (bound === undefined) throw new Error(`ui-sidebar-files: Session "${sessionId}" has no files store instance`) + return bound + } + ctx.inject(['shortcuts'], (ctx) => { ctx.effect(() => ctx.shortcuts.register({ id: 'workspace.files' as ShortcutCommandId, label: () => t('guide.title'), aliases: ['workspace files', 'files'], @@ -74,19 +134,36 @@ export function apply(ctx: ClientContext): void { resolve: ({ target: element }) => { const target = ctx.sidebarRight.commandTarget(element) if (target === undefined) return { status: 'blocked', reason: t('shortcut.noSession') } - return { status: 'handled', run: () => { ctx.sidebarRight.openTabFromTarget('files', target) } } + /* jscpd:ignore-end */ + return { status: 'handled', run: () => { + // A docked tab with a navigator beside its content that can draw a tree keeps it: the navigator is shown and takes the keyboard. + const placed = target.tabId === undefined ? undefined : beside.get(target.sessionId)?.get(target.tabId) + if (target.host === 'dock' && placed !== undefined) { + if (!ctx.sidebarRight.isExpanded()) ctx.sidebarRight.toggleExpanded() + placed.face.focus(target.paneId) + return + } + // Elsewhere the files page opens or is revealed and focused; its tree always shows. + ctx.sidebarRight.openTabFromTarget(FILES_KIND, target) + } } }, - /* jscpd:ignore-end */ }), 'ui-sidebar-files: shortcut') }) ctx.effect(() => ctx.sidebarRightTabs.register(filesDefinition(t)), 'ui-sidebar-files: files type') ctx.effect(() => ctx.locale.register(NS, { zh, en, ja, es, fr, de, ru, pt }), 'ui-sidebar-files: dictionaries') - const store = createFilesStore() - const inject = filesFace(createList(ctx.remote), createWatch(ctx.remote)) + ctx.effect(() => ctx.slots.registerFactory({ + name: 'sidebar.files.tree', scope: 'root', locale: NS, slots: { accessory: { scope: 'root' } }, + }, FileTree), 'ui-sidebar-files: tree factory') + ctx.effect(() => ctx.slots.registerFactory({ + name: 'sidebar.files.navigator', scope: 'session', locale: NS, store, inject: injectFace, + }, FileNavigator), 'ui-sidebar-files: navigator factory') + ctx.effect(() => ctx.slots.registerFactory({ + name: 'sidebar.files.navigator.toggle', scope: 'session', locale: NS, store, inject: injectFace, + }, NavigatorToggle), 'ui-sidebar-files: navigator toggle factory') ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register( { - name: 'sidebar.right.pane.tab', key: FILES_ID, locale: NS, store, inject, + name: 'sidebar.right.pane.tab', key: FILES_ID, locale: NS, store, inject: injectFace, children: { 'sidebar.right.tab.files.actions': { kind: 'list', scope: 'session' } }, }, FilesBody, diff --git a/packages/client/ui-sidebar-files/src/client/locales.ts b/packages/client/ui-sidebar-files/src/client/locales.ts index f646b7ea74..065d408e65 100644 --- a/packages/client/ui-sidebar-files/src/client/locales.ts +++ b/packages/client/ui-sidebar-files/src/client/locales.ts @@ -13,7 +13,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-slots' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { - /** File-tree type name, guide entry, row states, and failure lines. */ + /** File-tree type name, guide entry, navigator controls, row states, and failure lines. */ sidebarFiles: SidebarFilesKey } } @@ -33,6 +33,10 @@ export const zh = { 'autoRefresh.enable': '开启自动刷新', 'autoRefresh.disable': '关闭自动刷新', 'entry.other': '这不是文件或目录,没法打开。', + 'entry.noViewer': '没有可以打开此文件的查看器。', + 'navigator.show': '显示文件树', + 'navigator.hide': '隐藏文件树', + 'navigator.resize': '调整文件树宽度', 'error.notFound': '这个目录不在了。可能已被移动或删除。', 'error.outsideWorkspace': '这个目录在工作区之外,侧栏不会读取它。', 'error.notDirectory': '这不是一个目录。', @@ -57,6 +61,10 @@ export const en = { 'autoRefresh.enable': 'Enable auto refresh', 'autoRefresh.disable': 'Disable auto refresh', 'entry.other': 'Not a file or a directory, so it cannot be opened.', + 'entry.noViewer': 'No viewer can open this file.', + 'navigator.show': 'Show file tree', + 'navigator.hide': 'Hide file tree', + 'navigator.resize': 'Resize file tree', 'error.notFound': 'That directory is gone. It may have been moved or deleted.', 'error.outsideWorkspace': 'That directory is outside the workspace, so the sidebar will not read it.', 'error.notDirectory': 'That is not a directory.', @@ -78,6 +86,10 @@ export const ja = { 'autoRefresh.enable': '自動更新を有効にする', 'autoRefresh.disable': '自動更新を無効にする', 'entry.other': 'ファイルでもディレクトリでもないため、開けません。', + 'entry.noViewer': 'このファイルを開けるビューアーがありません。', + 'navigator.show': 'ファイルツリーを表示', + 'navigator.hide': 'ファイルツリーを非表示', + 'navigator.resize': 'ファイルツリーの幅を調整', 'error.notFound': 'そのディレクトリは存在しません。移動または削除された可能性があります。', 'error.outsideWorkspace': 'そのディレクトリはワークスペース外のため、サイドバーでは読み取りません。', 'error.notDirectory': 'それはディレクトリではありません。', @@ -99,6 +111,10 @@ export const es = { 'autoRefresh.enable': 'Habilitar actualización automática', 'autoRefresh.disable': 'Deshabilitar la actualización automática', 'entry.other': 'No es un archivo ni un directorio, por lo que no se puede abrir.', + 'entry.noViewer': 'Ningún visor puede abrir este archivo.', + 'navigator.show': 'Mostrar árbol de archivos', + 'navigator.hide': 'Ocultar árbol de archivos', + 'navigator.resize': 'Cambiar el ancho del árbol de archivos', 'error.notFound': 'Ese directorio desapareció. Es posible que se haya movido o eliminado.', 'error.outsideWorkspace': 'Ese directorio está fuera del espacio de trabajo, por lo que la barra lateral no lo leerá.', 'error.notDirectory': 'Eso no es un directorio.', @@ -120,6 +136,10 @@ export const fr = { 'autoRefresh.enable': "Activer l'actualisation automatique", 'autoRefresh.disable': "Désactiver l'actualisation automatique", 'entry.other': "Il ne s'agit pas d'un fichier ou d'un répertoire, il ne peut donc pas être ouvert.", + 'entry.noViewer': 'Aucune visionneuse ne peut ouvrir ce fichier.', + 'navigator.show': "Afficher l'arborescence des fichiers", + 'navigator.hide': "Masquer l'arborescence des fichiers", + 'navigator.resize': "Redimensionner l'arborescence des fichiers", 'error.notFound': 'Ce répertoire a disparu. Il a peut-être été déplacé ou supprimé.', 'error.outsideWorkspace': 'Ce répertoire se trouve en dehors de l’espace de travail, donc la barre latérale ne le lira pas.', 'error.notDirectory': "Ce n'est pas un répertoire.", @@ -141,6 +161,10 @@ export const de = { 'autoRefresh.enable': 'Aktivieren Sie die automatische Aktualisierung', 'autoRefresh.disable': 'Deaktivieren Sie die automatische Aktualisierung', 'entry.other': 'Keine Datei oder kein Verzeichnis, daher kann sie nicht geöffnet werden.', + 'entry.noViewer': 'Kein Viewer kann diese Datei öffnen.', + 'navigator.show': 'Dateibaum anzeigen', + 'navigator.hide': 'Dateibaum ausblenden', + 'navigator.resize': 'Breite des Dateibaums ändern', 'error.notFound': 'Dieses Verzeichnis ist weg. Möglicherweise wurde es verschoben oder gelöscht.', 'error.outsideWorkspace': 'Dieses Verzeichnis liegt außerhalb des Arbeitsbereichs und wird daher von der Seitenleiste nicht gelesen.', 'error.notDirectory': 'Das ist kein Verzeichnis.', @@ -162,6 +186,10 @@ export const ru = { 'autoRefresh.enable': 'Включить автоматическое обновление', 'autoRefresh.disable': 'Отключить автоматическое обновление', 'entry.other': 'Это не файл и не каталог, поэтому его нельзя открыть.', + 'entry.noViewer': 'Нет средства просмотра, которое может открыть этот файл.', + 'navigator.show': 'Показать дерево файлов', + 'navigator.hide': 'Скрыть дерево файлов', + 'navigator.resize': 'Изменить ширину дерева файлов', 'error.notFound': 'Этот каталог исчез. Возможно, оно было перемещено или удалено.', 'error.outsideWorkspace': 'Этот каталог находится за пределами рабочей области, поэтому боковая панель его не читает.', 'error.notDirectory': 'Это не каталог.', @@ -183,6 +211,10 @@ export const pt = { 'autoRefresh.enable': 'Ativar atualização automática', 'autoRefresh.disable': 'Desativar atualização automática', 'entry.other': 'Não é um arquivo ou diretório, portanto não pode ser aberto.', + 'entry.noViewer': 'Nenhum visualizador pode abrir este arquivo.', + 'navigator.show': 'Mostrar árvore de arquivos', + 'navigator.hide': 'Ocultar árvore de arquivos', + 'navigator.resize': 'Redimensionar árvore de arquivos', 'error.notFound': 'Esse diretório desapareceu. Pode ter sido movido ou excluído.', 'error.outsideWorkspace': 'Esse diretório está fora da área de trabalho, portanto a barra lateral não o lerá.', 'error.notDirectory': 'Isso não é um diretório.', diff --git a/packages/client/ui-sidebar-files/src/client/navigator-layout.ts b/packages/client/ui-sidebar-files/src/client/navigator-layout.ts new file mode 100644 index 0000000000..2ec292251c --- /dev/null +++ b/packages/client/ui-sidebar-files/src/client/navigator-layout.ts @@ -0,0 +1,7 @@ +/** Width rules shared by the navigator drawing and its DOM observer. */ + +/** A default 260px tree leaves less than 140px beside it below this row width. */ +export const FILL_BELOW = 400 + +/** Matches the right Sidebar's fullscreen viewport and FilesBody.module.css. */ +export const FULLSCREEN_QUERY = '(width < 768px)' diff --git a/packages/client/ui-sidebar-files/src/client/store.ts b/packages/client/ui-sidebar-files/src/client/store.ts index 897a04a46f..182a142208 100644 --- a/packages/client/ui-sidebar-files/src/client/store.ts +++ b/packages/client/ui-sidebar-files/src/client/store.ts @@ -1,19 +1,60 @@ /** - * The file tree's view state: which directories are expanded, what each - * loaded level contains, and where the body is scrolled to. - * - * The tree is not one resource. A directory listing per level, expanded lazily, - * is state the type owns — so it lives in a Slot-standard exclusive store - * (one instance per session), bucketed by tab id because two tabs of this kind - * in one session expand independently. - * - * Writers run between `start` and `forget`: the owner's `signal` is what ends a - * bucket's life, and the face stops dispatching once it aborts. + * The file tree's view state, one record per right-Sidebar pane: which + * directories are expanded, what each loaded level contains, and how the + * navigator is shown. A Slot-standard exclusive store (one instance per + * Session) keyed by pane, so every file tab of a pane draws the same tree; + * the face decides when a pane's record is forgotten (`FilesInjected.attach`). + * Beside it live pure reads shared by the face and the drawings: path keys, + * where a tab's file sits in the listed tree, and the navigator's geometry. */ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' -import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' +import type { PaneId } from '@deepseek-ai/dsh-client-ui-dockkit' import type { WorkspaceDirectoryEntry } from '@deepseek-ai/dsh-api-workspace-files/types' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { isAbsoluteWorkspacePath, parseFileAddress } from '@deepseek-ai/dsh-util-workspace-path' + +/** The navigator's width before a pane has a preference, in CSS pixels. */ +export const DEFAULT_NAVIGATOR_WIDTH = 260 + +/** The narrowest navigator, when the row has room for it. */ +const NAVIGATOR_MINIMUM = 180 +/** What a navigator floating over the row leaves of it uncovered. */ +const ROW_REMAINDER = 120 +/** + * The row width from which a preview's navigator takes its width from the + * body instead of floating over it. Fixed, so resizing the tree never moves + * the navigator between the two layouts. + */ +export const PUSH_FROM = 560 +/** The body width a navigator that takes its width from the body leaves. */ +const PREVIEW_MINIMUM = 320 +/** The navigator card's left plus right margin in that layout (FilesBody.module.css `.push`). */ +const PUSH_MARGINS = 16 + +/** + * Fit the navigator to the row it shares with the preview. In a row at least + * `PUSH_FROM` wide the navigator sits beside the body, so it is at most the + * row less its card's margins and `PREVIEW_MINIMUM`; in a narrower row it + * floats over the body and is at most the row less `ROW_REMAINDER`. + * @param available - the row's width in CSS pixels; `undefined` before it is measured. + * @param width - the pane's width preference. + * @returns the resize bounds and the width drawn. + */ +export function navigatorGeometry(available: number | undefined, width: number): { + readonly minimum: number + readonly maximum: number + readonly width: number +} { + // Unmeasured, the preference is drawn as asked until the row reports its width. + if (available === undefined) { + const drawn = Math.max(NAVIGATOR_MINIMUM, width) + return { minimum: NAVIGATOR_MINIMUM, maximum: drawn, width: drawn } + } + const maximum = Math.max(0, available - (available >= PUSH_FROM ? PUSH_MARGINS + PREVIEW_MINIMUM : ROW_REMAINDER)) + const minimum = Math.min(NAVIGATOR_MINIMUM, maximum) + return { minimum, maximum, width: Math.min(Math.max(minimum, width), maximum) } +} /** * One directory's contents, as one expanded level of the tree. @@ -28,19 +69,26 @@ export interface DirLevel { readonly truncated: boolean } -/** What one directory level is doing right now. */ +/** + * What one directory level is doing right now. A `ready` level marked + * `refreshing` keeps its last listing until the next one settles: the listing + * in flight, or, once its directory opens again, the first listing there. An + * entry missing from it is not yet known to be absent. A directory whose + * listing fails also clears the mark on every level below it, which it does + * not list. + */ export type LevelState = | { readonly kind: 'loading' } - | { readonly kind: 'ready'; readonly level: DirLevel; readonly failure?: RemoteFailure } + | { readonly kind: 'ready'; readonly level: DirLevel; readonly failure?: RemoteFailure; readonly refreshing?: true } | { readonly kind: 'failed'; readonly failure: RemoteFailure } /** - * One tab's tree: its root, the levels it has asked for, and what is open. + * One pane's tree: its root, the levels it has asked for, and what is open. * * Every path here is absolute: the root is the session's working directory as * the Host reports it, and a child is the parent joined with the entry name. */ -export interface FilesTabState { +export interface FilesTreeState { autoRefresh: boolean /** Absolute path of the workspace root this tree is rooted at. */ root: string @@ -48,39 +96,154 @@ export interface FilesTabState { levels: Record /** Expanded absolute directory paths, root included. */ expanded: string[] - /** The body's scroll offset in px, so a remounted tree comes back where the reader was. */ - scrollTop: number } -/** Every tab's tree, keyed by tab id. */ +/** One pane's navigator: whether and how wide it is shown, and its tree once started. */ +export interface FilesPaneState { + /** + * Whether a preview's navigator is shown; `undefined`, hidden, until the + * pane's first choice. The `files` page shows its tree regardless. + */ + open: boolean | undefined + /** The reader's width preference in CSS pixels; the navigator clamps it to the room it has. */ + width: number + /** The tree, listed once the navigator is first shown. */ + tree: FilesTreeState | undefined +} + +/** Every pane's navigator, keyed by pane id. */ export interface FilesState { - byTab: Record + byPane: Record } /** - * One tab's bucket, which every writer after `start` relies on: the face only - * dispatches while the record's signal is live, and `forget` runs on its abort. - * @param state - the draft. - * @param tabId - the tab being written. - * @returns the tab's tree. + * The absolute path of one child entry. + * + * Joined with `/` whatever the parent's separators: the Host resolves mixed + * separators, and the tree only needs a stable key. + * @param parent - absolute path of the listed directory. + * @param name - the entry's basename. + * @returns the child's absolute path. */ -function bucket(state: FilesState, tabId: TabId): FilesTabState { - const tree = state.byTab[tabId] - if (tree === undefined) throw new Error(`ui-sidebar-files: no tree for tab "${tabId}"`) - return tree +export function childPath(parent: string, name: string): string { + return `${parent.replace(/[/\\]+$/, '')}/${name}` } -/** The tree store's write set; every action names the tab it writes. */ +/** Where a file sits in the listed tree. */ +export interface TreeFileLocation { + /** Listed ancestor directories, outermost first, with the parent each is listed under. */ + readonly ancestors: readonly { readonly parent: string; readonly path: string }[] + /** The file's tree key, once every level on the way is listed and holds it. */ + readonly current: string | undefined + /** A level on the way is still being listed, or is listed again and lacks the next name. */ + readonly pending: boolean +} + +/** + * Whether a root is a Windows drive or UNC path, in either separator spelling, + * where names compare regardless of case. + * @param root - the tree's root, or a prefix built from it. + * @returns `true` for a drive letter followed by a separator, or a leading double separator. + */ +function windowsRoot(root: string): boolean { + const path = root.replace(/\\/g, '/') + return /^[A-Za-z]:\//.test(path) || path.startsWith('//') +} + +/** + * Walk the listed levels to a workspace-relative file, keeping the listing's + * spelling of each name; a Windows root also matches names regardless of case. + * @param tree - the pane's tree. + * @param relativePath - the file's path under the tree's root, `/`-separated. + * @returns the ancestors found so far, the file's tree key, and whether a level is pending. + */ +export function locateFile(tree: FilesTreeState, relativePath: string): TreeFileLocation { + const caseless = windowsRoot(tree.root) + const parts = relativePath.split('/') + const ancestors: { parent: string; path: string }[] = [] + let parent = tree.root + for (const [index, part] of parts.entries()) { + const level = tree.levels[parent] + if (level?.kind !== 'ready') return { ancestors, current: undefined, pending: level === undefined || level.kind === 'loading' } + const type = index === parts.length - 1 ? 'file' : 'directory' + const candidates = level.level.entries.filter(entry => entry.type === type) + const entry = candidates.find(candidate => candidate.name === part) + ?? (caseless ? candidates.find(candidate => candidate.name.toLowerCase() === part.toLowerCase()) : undefined) + if (entry === undefined) return { ancestors, current: undefined, pending: level.refreshing === true } + const path = childPath(parent, entry.name) + if (type === 'directory') ancestors.push({ parent, path }) + parent = path + } + return { ancestors, current: parent, pending: false } +} + +/** + * The workspace-relative path of the file a tab shows, when it is a file of + * this Session under the tree's root; dot segments are not resolved here. An + * absolute path is under a Windows drive or UNC root regardless of case and + * separator spelling, and under a POSIX root only with the root's exact case. + * @param address - the tab's content address. + * @param sessionId - the Session the tree lists. + * @param root - the tree's root. + * @returns the `/`-separated relative path, or `undefined` for anything else. + */ +export function relativeFileOf(address: string, sessionId: SessionId, root: string): string | undefined { + const file = parseFileAddress(address) + if (file?.scope !== 'session' || file.sessionId !== sessionId) return undefined + const path = file.path.replace(/\\/g, '/') + const normalized = isAbsoluteWorkspacePath(path) ? underRoot(path, root) : path + if (normalized === undefined || normalized === '' || isAbsoluteWorkspacePath(normalized)) return undefined + return normalized.split('/').some(part => part === '' || part === '.' || part === '..') ? undefined : normalized +} + +/** + * An absolute `/`-separated path relative to a tree's root. + * @param path - the absolute path, `/`-separated. + * @param root - the tree's root, in either separator spelling. + * @returns the path under the root, or `undefined` outside it. + */ +function underRoot(path: string, root: string): string | undefined { + const prefix = `${root.replace(/\\/g, '/').replace(/\/+$/, '')}/` + const head = path.slice(0, prefix.length) + const caseless = windowsRoot(prefix) + return (caseless ? head.toLowerCase() === prefix.toLowerCase() : head === prefix) ? path.slice(prefix.length) : undefined +} + +/** + * One pane's record, created by its first write. + * @param state - the draft. + * @param paneId - the pane being written. + * @returns the pane's record. + */ +function pane(state: FilesState, paneId: PaneId): FilesPaneState { + state.byPane[paneId] ??= { open: undefined, width: DEFAULT_NAVIGATOR_WIDTH, tree: undefined } + return state.byPane[paneId] +} + +/** + * One pane's tree, which every writer after `start` relies on: the face only + * dispatches while it holds the pane's records, and `forget` runs as it drops them. + * @param state - the draft. + * @param paneId - the pane being written. + * @returns the pane's tree. + */ +function tree(state: FilesState, paneId: PaneId): FilesTreeState { + const started = state.byPane[paneId]?.tree + if (started === undefined) throw new Error(`ui-sidebar-files: no tree for pane "${paneId}"`) + return started +} + +/** The tree store's write set; every action names the pane it writes. */ type FilesActions = { - autoRefresh: (draft: FilesState, tabId: TabId, enabled: boolean) => void - start: (draft: FilesState, tabId: TabId, root: string) => void - loading: (draft: FilesState, tabId: TabId, path: string) => void - loaded: (draft: FilesState, tabId: TabId, path: string, level: DirLevel) => void - failed: (draft: FilesState, tabId: TabId, path: string, failure: RemoteFailure) => void - toggled: (draft: FilesState, tabId: TabId, path: string) => void - scrolled: (draft: FilesState, tabId: TabId, scrollTop: number) => void - reset: (draft: FilesState, tabId: TabId) => void - forget: (draft: FilesState, tabId: TabId) => void + autoRefresh: (draft: FilesState, paneId: PaneId, enabled: boolean) => void + start: (draft: FilesState, paneId: PaneId, root: string) => void + loading: (draft: FilesState, paneId: PaneId, path: string) => void + loaded: (draft: FilesState, paneId: PaneId, path: string, level: DirLevel) => void + failed: (draft: FilesState, paneId: PaneId, path: string, failure: RemoteFailure) => void + toggled: (draft: FilesState, paneId: PaneId, path: string) => void + shown: (draft: FilesState, paneId: PaneId, open: boolean) => void + resized: (draft: FilesState, paneId: PaneId, width: number) => void + forget: (draft: FilesState, paneId: PaneId) => void } /** @@ -92,43 +255,45 @@ type FilesActions = { */ export function createFilesStore(): EngineStoreHandle { return defineStore({ - init: (): FilesState => ({ byTab: {} }), + init: (): FilesState => ({ byPane: {} }), actions: { - autoRefresh: (d, tabId: TabId, enabled: boolean) => { bucket(d, tabId).autoRefresh = enabled }, + autoRefresh: (d, paneId: PaneId, enabled: boolean) => { tree(d, paneId).autoRefresh = enabled }, /** - * Seed one tab's tree at its workspace root, with the root expanded. + * Seed one pane's tree at its workspace root, with the root expanded. * @param d - draft state. - * @param tabId - the tab being drawn. + * @param paneId - the pane being drawn. * @param root - absolute path of the workspace root. */ - start: (d, tabId: TabId, root: string) => { - d.byTab[tabId] = { root, levels: {}, expanded: [root], scrollTop: 0, autoRefresh: true } + start: (d, paneId: PaneId, root: string) => { + pane(d, paneId).tree = { root, levels: {}, expanded: [root], autoRefresh: true } }, /** - * Mark one directory as being listed. + * Mark one directory as being listed; a listed level keeps its entries and is marked refreshing. * @param d - draft state. - * @param tabId - the tab being drawn. + * @param paneId - the pane being drawn. * @param path - absolute directory path. */ - loading: (d, tabId: TabId, path: string) => { - const state = bucket(d, tabId) - if (state.levels[path]?.kind !== 'ready') state.levels[path] = { kind: 'loading' } + loading: (d, paneId: PaneId, path: string) => { + const state = tree(d, paneId) + const level = state.levels[path] + if (level?.kind !== 'ready') state.levels[path] = { kind: 'loading' } + else if (level.refreshing !== true) state.levels[path] = { ...level, refreshing: true } }, /** * Record one directory's contents. * @param d - draft state. - * @param tabId - the tab being drawn. + * @param paneId - the pane being drawn. * @param path - absolute directory path. * @param level - the listing to show under it. */ - loaded: (d, tabId: TabId, path: string, level: DirLevel) => { - const state = bucket(d, tabId) + loaded: (d, paneId: PaneId, path: string, level: DirLevel) => { + const state = tree(d, paneId) const previous = state.levels[path] if (previous?.kind === 'ready') { const directories = new Set(level.entries.filter(entry => entry.type === 'directory').map(entry => entry.name)) for (const entry of previous.level.entries) { if (entry.type !== 'directory' || directories.has(entry.name)) continue - const removed = `${path.replace(/[/\\]+$/, '')}/${entry.name}` + const removed = childPath(path, entry.name) state.expanded = state.expanded.filter(value => value !== removed && !value.startsWith(`${removed}/`)) state.levels = Object.fromEntries(Object.entries(state.levels) .filter(([key]) => key !== removed && !key.startsWith(`${removed}/`))) @@ -137,60 +302,66 @@ export function createFilesStore(): EngineStoreHandle state.levels[path] = { kind: 'ready', level } }, /** - * Record why one directory could not be listed. + * Record why one directory could not be listed, and end the refresh of the levels below it. * @param d - draft state. - * @param tabId - the tab being drawn. + * @param paneId - the pane being drawn. * @param path - absolute directory path. * @param failure - the settled Remote failure. */ - failed: (d, tabId: TabId, path: string, failure: RemoteFailure) => { - const level = bucket(d, tabId).levels[path] - bucket(d, tabId).levels[path] = level?.kind === 'ready' - ? { ...level, failure } + failed: (d, paneId: PaneId, path: string, failure: RemoteFailure) => { + const state = tree(d, paneId) + const level = state.levels[path] + state.levels[path] = level?.kind === 'ready' + ? { kind: 'ready', level: level.level, failure } : { kind: 'failed', failure } + // A directory that failed to list lists none below it, so their kept listings are the latest known. + const below = `${path.replace(/[/\\]+$/, '')}/` + for (const [key, kept] of Object.entries(state.levels)) { + if (!key.startsWith(below) || kept.kind !== 'ready' || kept.refreshing !== true) continue + state.levels[key] = kept.failure === undefined + ? { kind: 'ready', level: kept.level } + : { kind: 'ready', level: kept.level, failure: kept.failure } + } }, /** * Open a collapsed directory, or collapse an open one. * * A collapsed level keeps what it loaded, so reopening it draws at once. * @param d - draft state. - * @param tabId - the tab being drawn. + * @param paneId - the pane being drawn. * @param path - absolute directory path. */ - toggled: (d, tabId: TabId, path: string) => { - const state = bucket(d, tabId) + toggled: (d, paneId: PaneId, path: string) => { + const state = tree(d, paneId) const at = state.expanded.indexOf(path) if (at >= 0) state.expanded.splice(at, 1) else state.expanded.push(path) }, /** - * Record where one tab's body is scrolled to. + * Show or hide one pane's navigator. * @param d - draft state. - * @param tabId - the tab being drawn. - * @param scrollTop - the body's scroll offset, in px. + * @param paneId - the pane being drawn. + * @param open - whether the navigator is shown. */ - scrolled: (d, tabId: TabId, scrollTop: number) => { - bucket(d, tabId).scrollTop = scrollTop - }, + shown: (d, paneId: PaneId, open: boolean) => { pane(d, paneId).open = open }, /** - * Drop every loaded level, keeping what is expanded. - * - * This is the reload gesture's first half: the expanded set says which - * levels to fetch again. + * Record one pane's navigator width preference. * @param d - draft state. - * @param tabId - the tab being drawn. + * @param paneId - the pane being drawn. + * @param width - the preferred width, in CSS pixels. */ - reset: (d, tabId: TabId) => { - bucket(d, tabId).levels = {} - }, + resized: (d, paneId: PaneId, width: number) => { pane(d, paneId).width = width }, /** - * Forget one tab's tree, for a tab record that is gone. + * Forget one pane's navigator and tree. * @param d - draft state. - * @param tabId - the tab that went away. + * @param paneId - the pane whose records go. */ - forget: (d, tabId: TabId) => { - d.byTab = Object.fromEntries(Object.entries(d.byTab).filter(([id]) => id !== tabId)) + forget: (d, paneId: PaneId) => { + d.byPane = Object.fromEntries(Object.entries(d.byPane).filter(([id]) => id !== paneId)) }, }, }) } + +/** One Session's live instance of the tree's store, as the face reads and writes it. */ +export type FilesStoreInstance = ReturnType['create']> diff --git a/packages/client/ui-sidebar-files/src/client/workspace-tree.ts b/packages/client/ui-sidebar-files/src/client/workspace-tree.ts new file mode 100644 index 0000000000..80eb21ae98 --- /dev/null +++ b/packages/client/ui-sidebar-files/src/client/workspace-tree.ts @@ -0,0 +1,93 @@ +/** + * The workspace navigator's input to the `sidebar.files.tree` Factory: a pane's + * listed levels as the tree's flat entries and directory states. + * + * The store keys a level by absolute path and a child by `childPath`, which + * joins the parent with `/` after dropping its trailing separators, so every + * key below the root is already a tree key. Only the root differs: its tree + * key drops its trailing separators too, so `/` becomes the empty key and a + * Windows drive root `C:\` becomes `C:`, and a top-level child's key still + * ends at its parent's last `/`. + */ +import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' +import type { TreeDirectoryState, TreeEntry } from './FileTree.tsx' +import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from './locales.ts' +import { childPath, type FilesTreeState } from './store.ts' + +/** + * Say why a directory could not be listed, in terms of the directory. + * @param t - namespace-bound translate. + * @param failure - the settled Remote failure. + * @returns the line to show under the directory. + */ +export function failureLine(t: TranslateNS<'sidebarFiles'>, failure: RemoteFailure): string { + switch (failure.code) { + case 'workspace-file/not-found': return t('error.notFound') + case 'workspace-file/outside-workspace': return t('error.outsideWorkspace') + case 'workspace-file/not-directory': return t('error.notDirectory') + // Carrier and unclassified host failures reach the reader as themselves: + // this tree knows nothing useful to add to a transport-level message. + default: return t('error.unavailable', { message: failure.message }) + } +} + +/** + * The tree key of a workspace root. + * @param root - the root's absolute path as the Host reports it. + * @returns the path without trailing separators. + */ +export function treeRootOf(root: string): string { + return root.replace(/[/\\]+$/, '') +} + +/** + * The store key of the directory a tree key's entry is listed in. + * @param tree - the pane's tree. + * @param path - an entry's tree key. + * @returns the absolute path of its parent, the root spelled as the store keeps it. + */ +export function parentOf(tree: FilesTreeState, path: string): string { + const parent = path.slice(0, path.lastIndexOf('/')) + return parent === treeRootOf(tree.root) ? tree.root : parent +} + +/** + * Every listed entry of a pane's tree, collapsed levels included, each named + * as its listing spells it. + * @param levels - the pane's level states. + * @returns the entries keyed as the tree draws them. + */ +export function workspaceEntries(levels: FilesTreeState['levels']): TreeEntry[] { + return Object.entries(levels).flatMap(([path, level]) => level.kind !== 'ready' ? [] : level.level.entries + .map(entry => ({ path: childPath(path, entry.name), name: entry.name, meta: { kind: entry.type } }))) +} + +/** + * Each directory's listing state: loading until its first listing settles, then + * its latest failure and whether its listing is truncated. + * @param tree - the pane's root, level states, and expanded directories. + * @param t - namespace-bound translate, for failure lines. + * @returns the states keyed as the tree draws them. + */ +export function workspaceDirectories( + tree: Pick, + t: TranslateNS<'sidebarFiles'>, +): Record { + const { root, levels, expanded } = tree + const directories: Record = {} + const keyOf = (path: string): string => path === root ? treeRootOf(path) : path + for (const path of expanded) directories[keyOf(path)] = { loading: true } + for (const [path, level] of Object.entries(levels)) { + const key = keyOf(path) + if (level.kind === 'loading') directories[key] = { loading: true } + else if (level.kind === 'failed') directories[key] = { failure: failureLine(t, level.failure) } + else { + const state: { failure?: string; truncated?: true } = {} + if (level.failure !== undefined) state.failure = failureLine(t, level.failure) + if (level.level.truncated) state.truncated = true + directories[key] = state + } + } + return directories +} diff --git a/packages/client/ui-sidebar-files/tests/apply.client.spec.ts b/packages/client/ui-sidebar-files/tests/apply.client.spec.ts index dfcc7e346c..632f786e7c 100644 --- a/packages/client/ui-sidebar-files/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-files/tests/apply.client.spec.ts @@ -3,30 +3,43 @@ * * The registry is real, because "registered" means what it says a type is; the * slot, locale, and Remote faces are recorders, because what matters here is - * what was handed to them — one body seat under the type's id with its store - * and face — and that every registration is gone after dispose, which is what - * makes a reload safe. + * what was handed to them — the navigator and toggle Factories, + * and the page body under the type's id, all over one store and one face per + * store instance — and that every registration is gone after dispose, which is + * what makes a reload safe. */ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ShortcutCommand } from '@deepseek-ai/dsh-client-shortcuts/client' import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/tab-registry.ts' +import type { PaneId, TabId } from '@deepseek-ai/dsh-client-ui-dockkit' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { FILES_ID, FILES_KIND } from '../src/client/definition.tsx' +import type { FilesInjected } from '../src/client/face.ts' import { apply, inject } from '../src/client/index.ts' import { apply as hostApply } from '../src/index.ts' +import { FileTree } from '../src/client/FileTree.tsx' +import { FileNavigator } from '../src/client/FileNavigator.tsx' import { FilesBody } from '../src/client/FilesBody.tsx' import { FilesTitle } from '../src/client/FilesTitle.tsx' +import { NavigatorToggle } from '../src/client/NavigatorToggle.tsx' import { en, ja, zh, es, fr, de, ru, pt } from '../src/client/locales.ts' +import type { createFilesStore } from '../src/client/store.ts' interface Recorded { name: string key: string + scope: string locale: string - store: unknown - inject: unknown + store: ReturnType | undefined + inject: ((sessionId: SessionId, actions: object) => object) | undefined component: unknown } +const SESSION = 'files-session' as SessionId +const PANE = 'pane-1' as PaneId + async function boot() { const ctx = new Context() ctx.provide('contextMenus', { open: vi.fn() } as never) @@ -39,6 +52,11 @@ async function boot() { registered.push(entry) return () => { registered.splice(registered.indexOf(entry), 1) } }), + registerFactory: vi.fn((options: Omit, component: unknown) => { + const entry: Recorded = { ...options, component } + registered.push(entry) + return () => { registered.splice(registered.indexOf(entry), 1) } + }), } const dictionaries = new Map() const locale = { @@ -50,7 +68,16 @@ async function boot() { }), } const workspaceFiles = { list: vi.fn() } - const sidebar = { commandTarget: vi.fn(), openTabFromTarget: vi.fn() } + const navigation = createSnapshotStore({ address: 'sidebar://files', params: undefined, revision: 0 }) + const source = { + getSnapshot: () => navigation.getSnapshot(), + subscribe: vi.fn((listener: () => void) => vi.fn(navigation.subscribe(listener))), + } + const sidebar = { + tabDomain: { occurrence: vi.fn(() => ({ navigation: source })) }, + commandTarget: vi.fn(), openTabFromTarget: vi.fn(), + isExpanded: vi.fn(() => true), toggleExpanded: vi.fn(), + } const commands: ShortcutCommand[] = [] ctx.provide('shortcuts', { register: (command: ShortcutCommand) => { commands.push(command) @@ -64,32 +91,156 @@ async function boot() { ctx.provide('remote.workspaceFiles', workspaceFiles as never) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - return { ctx, tabs, registered, dictionaries, fiber, sidebar, commands } + return { ctx, tabs, registered, dictionaries, fiber, sidebar, commands, slots, navigation, source } +} + +/** Run the files command for one target. */ +function run(h: Awaited>, target: object): void { + h.sidebar.commandTarget.mockReturnValue(target) + const result = h.commands[0]!.resolve({ region: 'page', modal: null, target: null }) + if (result.status !== 'handled') throw new Error('Expected files command to be available') + result.run() } describe('ui-sidebar-files apply', () => { it('opens files for the command target and refuses without a selected Session', async () => { const h = await boot() try { - const command = h.commands[0]! - expect(command.label()).toBe('guide.title') const input = { region: 'page', modal: null, target: null } as const - expect(command.resolve(input)).toEqual({ status: 'blocked', reason: 'shortcut.noSession' }) - const target = { sessionId: 'files-session' } - h.sidebar.commandTarget.mockReturnValue(target) - const result = command.resolve(input) - if (result.status !== 'handled') throw new Error('Expected files command to be available') - result.run() + expect(h.commands[0]!.resolve(input)).toEqual({ status: 'blocked', reason: 'shortcut.noSession' }) + expect(h.commands[0]!.label()).toBe('guide.title') + const target = { sessionId: SESSION, paneId: PANE, host: 'float' } + run(h, target) expect(h.sidebar.openTabFromTarget).toHaveBeenCalledWith('files', target) } finally { await h.fiber.dispose() } expect(h.commands).toEqual([]) }) + + it('shows and focuses the tree of the docked tab the face is placed beside, and elsewhere opens the page, whose tree always shows', async () => { + const h = await boot() + try { + const page = h.registered.find(entry => entry.name === 'sidebar.right.pane.tab')! + const instance = page.store!.create(SESSION) + const face = page.inject!(SESSION, instance.actions) as FilesInjected + const focus = vi.spyOn(face, 'focus') + const tab = new AbortController() + face.attach(PANE, 'tab-1' as TabId, tab.signal) + // A tab with no navigator placed beside it, as when the Session has no working directory, leaves the command to the page. + run(h, { sessionId: SESSION, paneId: PANE, host: 'dock', tabId: 'tab-1' }) + expect(h.sidebar.openTabFromTarget).toHaveBeenCalledTimes(1) + expect(focus).not.toHaveBeenCalled() + h.sidebar.openTabFromTarget.mockClear() + face.placeBeside('tab-1' as TabId) + h.sidebar.isExpanded.mockReturnValue(false) + run(h, { sessionId: SESSION, paneId: PANE, host: 'dock', tabId: 'tab-1' }) + expect(h.sidebar.openTabFromTarget).not.toHaveBeenCalled() + expect(h.sidebar.toggleExpanded).toHaveBeenCalledTimes(1) + expect(focus.mock.calls).toEqual([[PANE]]) + expect(instance.getSnapshot().byPane[PANE]?.open).toBe(true) + + h.sidebar.isExpanded.mockReturnValue(true) + run(h, { sessionId: SESSION, paneId: PANE, host: 'dock', tabId: 'tab-1' }) + expect(h.sidebar.toggleExpanded).toHaveBeenCalledTimes(1) + expect(focus).toHaveBeenCalledTimes(2) + + // A tab without the navigator beside it, the page itself, a floating tab, or another Session goes through the page open, + // which writes no shown choice: the page's tree always shows. + for (const target of [ + { sessionId: SESSION, paneId: PANE, host: 'dock', tabId: 'tab-2' }, + { sessionId: SESSION, paneId: PANE, host: 'dock', tabId: undefined }, + { sessionId: SESSION, paneId: 'float-1', host: 'float', tabId: 'tab-1' }, + { sessionId: 'other' as SessionId, paneId: PANE, host: 'dock', tabId: 'tab-1' }, + ]) { + run(h, target) + expect(h.sidebar.openTabFromTarget).toHaveBeenLastCalledWith('files', target) + } + expect(focus).toHaveBeenCalledTimes(2) + } finally { await h.fiber.dispose() } + }) + + it('finds a tab\'s face while any of its placements remains, and the latest face placed for the tab', async () => { + const h = await boot() + try { + const page = h.registered.find(entry => entry.name === 'sidebar.right.pane.tab')! + const instance = page.store!.create(SESSION) + const face = page.inject!(SESSION, instance.actions) as FilesInjected + const focus = vi.spyOn(face, 'focus') + const target = { sessionId: SESSION, paneId: PANE, host: 'dock', tabId: 'tab-1' } + face.attach(PANE, 'tab-1' as TabId, new AbortController().signal) + // The tab's loading drawing and its loaded drawing each place the face; the first going leaves the second. + const loading = face.placeBeside('tab-1' as TabId) + const loaded = face.placeBeside('tab-1' as TabId) + const sibling = face.placeBeside('tab-2' as TabId) + loading() + loading() + run(h, target) + expect(focus).toHaveBeenCalledTimes(1) + // Once the last placement goes, the command opens the page. + loaded() + run(h, target) + expect(h.sidebar.openTabFromTarget).toHaveBeenLastCalledWith('files', target) + expect(focus).toHaveBeenCalledTimes(1) + sibling() + run(h, { ...target, tabId: 'tab-2' }) + expect(h.sidebar.openTabFromTarget).toHaveBeenLastCalledWith('files', { ...target, tabId: 'tab-2' }) + // A newer instance for the Session placed beside the same tab takes it over, and the older face's release leaves it. + const older = face.placeBeside('tab-1' as TabId) + const next = page.store!.create(SESSION) + const nextFace = page.inject!(SESSION, next.actions) as FilesInjected + const nextFocus = vi.spyOn(nextFace, 'focus') + nextFace.attach(PANE, 'tab-1' as TabId, new AbortController().signal) + nextFace.placeBeside('tab-1' as TabId) + older() + run(h, target) + expect(nextFocus).toHaveBeenCalledWith(PANE) + expect(focus).toHaveBeenCalledTimes(1) + } finally { await h.fiber.dispose() } + }) + + it('serves every occurrence of one store instance with one face, and only instances its store minted', async () => { + const h = await boot() + try { + const factory = h.registered.find(entry => entry.name === 'sidebar.files.navigator')! + const page = h.registered.find(entry => entry.name === 'sidebar.right.pane.tab')! + const one = factory.store!.create(SESSION) + const two = factory.store!.create('other') + expect(factory.inject!(SESSION, one.actions)).toBe(page.inject!(SESSION, one.actions)) + expect(factory.inject!('other' as SessionId, two.actions)).not.toBe(factory.inject!(SESSION, one.actions)) + expect(factory.store!.create(undefined).getSnapshot()).toEqual({ byPane: {} }) + expect(() => factory.inject!(SESSION, {})).toThrow('Session "files-session" has no files store instance') + } finally { await h.fiber.dispose() } + }) + it('releases navigation subscriptions when the tab closes or the files plugin unloads', async () => { + const h = await boot() + const factory = h.registered.find(entry => entry.name === 'sidebar.files.navigator')! + const instance = factory.store!.create(SESSION) + const face = factory.inject!(SESSION, instance.actions) as FilesInjected + const first = new AbortController() + const second = new AbortController() + face.attach(PANE, 'first' as TabId, first.signal) + face.attach(PANE, 'second' as TabId, second.signal) + expect(h.sidebar.tabDomain.occurrence.mock.calls).toEqual([[SESSION, { id: 'first' }], [SESSION, { id: 'second' }]]) + expect(h.source.subscribe).toHaveBeenCalledTimes(2) + const unsubscribers = h.source.subscribe.mock.results.map((result) => { + if (result.type !== 'return') throw new Error('Expected navigation subscription to return a disposer') + return result.value + }) + h.navigation.set({ address: 'sidebar://files', params: undefined, revision: 1 }) + first.abort() + expect(unsubscribers[0]).toHaveBeenCalledOnce() + await h.fiber.dispose() + expect(unsubscribers[1]).toHaveBeenCalledOnce() + second.abort() + expect(unsubscribers[1]).toHaveBeenCalledOnce() + }) + it('keeps the host Loader entry inert', () => { expect(hostApply).not.toThrow() }) it('registers the type, its dictionaries, and the body and title seats under the type\'s id', async () => { - const { tabs, registered, dictionaries } = await boot() + const { tabs, registered, dictionaries, fiber, slots } = await boot() + onTestFinished(() => fiber.dispose()) const definition = tabs.get(FILES_KIND) expect(definition?.id).toBe(FILES_ID) expect(definition?.priority).toBe('builtin') @@ -99,17 +250,32 @@ describe('ui-sidebar-files apply', () => { expect(dictionaries.get('sidebarFiles')).toEqual({ zh, en, ja, es, fr, de, ru, pt }) // The seat key is the implementation's id, not the kind: an extension may // take the kind over, and the seat must still find this body. - expect(registered.map(entry => [entry.name, entry.key, entry.locale, entry.component])).toEqual([ - ['sidebar.right.pane.tab', FILES_ID, 'sidebarFiles', FilesBody], - ['sidebar.right.pane.tab.title', FILES_ID, undefined, FilesTitle], + expect(registered.map(entry => [entry.name, entry.key, entry.locale])).toEqual([ + ['sidebar.files.tree', undefined, 'sidebarFiles'], + ['sidebar.files.navigator', undefined, 'sidebarFiles'], + ['sidebar.files.navigator.toggle', undefined, 'sidebarFiles'], + ['sidebar.right.pane.tab', FILES_ID, 'sidebarFiles'], + ['sidebar.right.pane.tab.title', FILES_ID, undefined], ]) - expect(registered[0]?.store).toBeDefined() - expect(typeof registered[0]?.inject).toBe('function') + expect(slots.registerFactory.mock.calls.map(([options]) => [options.name, options.scope])) + .toEqual([['sidebar.files.tree', 'root'], ['sidebar.files.navigator', 'session'], ['sidebar.files.navigator.toggle', 'session']]) + const [tree, factory, toggle, body, title] = registered + expect([factory?.component, toggle?.component, body?.component, title?.component]) + .toEqual([FileNavigator, NavigatorToggle, FilesBody, FilesTitle]) + expect(tree?.component).toBe(FileTree) + expect(slots.registerFactory.mock.calls[0]?.[0]).toMatchObject({ slots: { accessory: { scope: 'root' } } }) + // One store handle for the Factory, the toggle and the page. + expect(toggle?.store).toBe(factory?.store) + expect(toggle?.inject).toBe(factory?.inject) + expect(body?.store).toBe(factory?.store) + expect(body?.inject).toBe(factory?.inject) }) it('takes every registration back when the plugin is disposed', async () => { const { tabs, registered, dictionaries, fiber } = await boot() + expect(registered.some(entry => entry.name === 'sidebar.files.tree')).toBe(true) await fiber.dispose() + expect(registered.some(entry => entry.name === 'sidebar.files.tree')).toBe(false) expect(tabs.get(FILES_KIND)).toBeUndefined() expect(registered).toEqual([]) expect(dictionaries.size).toBe(0) diff --git a/packages/client/ui-sidebar-files/tests/face.client.spec.ts b/packages/client/ui-sidebar-files/tests/face.client.spec.ts index 29de3825cc..8502a8d348 100644 --- a/packages/client/ui-sidebar-files/tests/face.client.spec.ts +++ b/packages/client/ui-sidebar-files/tests/face.client.spec.ts @@ -1,42 +1,70 @@ /** Directory subscriptions and read settlements through the face's real store actions. */ import { describe, expect, it, onTestFinished, vi } from 'vitest' +import type { Mock } from 'vitest' import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkspaceDirectoryListing } from '@deepseek-ai/dsh-api-workspace-files/types' -import { childPath, createList, filesFace } from '../src/client/face.ts' -import type { WorkspaceFilesListRemote } from '../src/client/face.ts' +import { canOpenOf, createList, filesFace } from '../src/client/face.ts' +import type { FilesInjected, TabTypeRegistry, WorkspaceFilesListRemote } from '../src/client/face.ts' +import { childPath } from '../src/client/store.ts' import { createFilesStore } from '../src/client/store.ts' import type { DirLevel } from '../src/client/store.ts' import { DirectoryNode } from '../src/client/directory-node.ts' import { scriptedList } from './scripted-list.client.ts' -import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' +import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' +import type { PaneId, TabId } from '@deepseek-ai/dsh-client-ui-dockkit' const SESSION = 's-1' as SessionId const ROOT = '/work/app' +const PANE = 'pane-1' as PaneId const TAB = 'tab-1' as TabId const LEVEL: DirLevel = { entries: [{ name: 'src', type: 'directory' }], truncated: false } +/** Every address opens. */ +const CAN_OPEN = { getSnapshot: () => () => true, subscribe: () => () => {} } + function mount() { const instance = createFilesStore().create() const script = scriptedList() - const face = filesFace(script.list, script.watch)(SESSION, instance.actions) + /** The tabs the face placed itself beside, with each placement's release. */ + const placed: { readonly tabId: TabId; readonly face: FilesInjected; readonly release: Mock<() => void> }[] = [] + const face = filesFace(script.list, script.watch, CAN_OPEN, () => ({ getSnapshot: () => ({ address: '', params: undefined, revision: 0 }), subscribe: () => () => {} }))(SESSION, instance, (tabId, bound) => { + const release = vi.fn<() => void>() + placed.push({ tabId, face: bound, release }) + return release + }) + // The lifetime of the tab the navigator is drawn for. const controller = new AbortController() + let release: (() => void) | undefined + /** A navigator drawn beside the tab's content in the pane. */ + const attach = (): void => { release = face.attach(PANE, TAB, controller.signal) } + /** The navigator goes and its tab closes; the pane is forgotten once the task ends. */ + const leave = async (): Promise => { + release?.() + controller.abort() + await Promise.resolve() + } onTestFinished(async () => { + release?.() controller.abort() await script.dispose() }) - return { ...script, face, controller, instance, actions: instance.actions, snapshot: () => instance.getSnapshot().byTab[TAB] } + return { + ...script, face, controller, attach, leave, instance, placed, actions: instance.actions, + snapshot: () => instance.getSnapshot().byPane[PANE]?.tree, + } } describe('filesFace', () => { it.each(['/', 'C:/', 'C:\\', '/work/app/'])('expands a child of the exact root key %s', async (root) => { const h = mount() const child = childPath(root, 'src') - h.face.start(TAB, root, h.controller.signal) + h.attach() + h.face.hold(PANE, root) await h.watches.ready(root) await h.settle({ ok: true, value: LEVEL }) - h.face.toggle(TAB, root, child, [root], h.controller.signal) + h.face.toggle(PANE, root, child, [root]) const stream = await h.watches.ready(child) expect(h.snapshot()?.expanded).toEqual([root, child]) expect(h.list).toHaveBeenLastCalledWith(SESSION, child, stream.signal) @@ -44,13 +72,14 @@ describe('filesFace', () => { }) it('subscribes to the root before listing it and waits for ready', async () => { - const { face, list, watches, settle, snapshot, controller } = mount() - face.start(TAB, ROOT, controller.signal) + const { face, list, watches, settle, snapshot, attach } = mount() + attach() + face.hold(PANE, ROOT) const stream = await watches.forPath(ROOT) expect(stream.sessionId).toBe(SESSION) expect(stream.signal.aborted).toBe(false) expect(list).not.toHaveBeenCalled() - expect(snapshot()).toEqual({ root: ROOT, expanded: [ROOT], levels: {}, scrollTop: 0, autoRefresh: true }) + expect(snapshot()).toEqual({ root: ROOT, expanded: [ROOT], levels: {}, autoRefresh: true }) await stream.deliver('ready') expect(list).toHaveBeenCalledWith(SESSION, ROOT, stream.signal) expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'loading' }) @@ -59,8 +88,9 @@ describe('filesFace', () => { }) it('records a failed listing under its level', async () => { - const { face, watches, settle, snapshot, controller } = mount() - face.start(TAB, ROOT, controller.signal) + const { face, watches, settle, snapshot, attach } = mount() + attach() + face.hold(PANE, ROOT) await watches.ready(ROOT) const error = new RemoteError('workspace-file/not-directory', 'not a directory', { path: ROOT, kind: 'file' }) await settle({ ok: false, error }) @@ -68,26 +98,27 @@ describe('filesFace', () => { }) it('subscribes on expansion, releases on collapse, and rereads a reopened directory after ready', async () => { - const { face, list, watches, settle, snapshot, controller } = mount() - const { signal } = controller + const { face, list, watches, settle, snapshot, attach } = mount() const child = `${ROOT}/src` - face.start(TAB, ROOT, signal) + attach() + face.hold(PANE, ROOT) const rootStream = await watches.ready(ROOT) await settle({ ok: true, value: LEVEL }) - face.toggle(TAB, ROOT, child, [ROOT], signal) + face.toggle(PANE, ROOT, child, [ROOT]) expect(snapshot()!.expanded).toEqual([ROOT, child]) expect(list).toHaveBeenCalledTimes(1) const childStream = await watches.ready(child) expect(list).toHaveBeenLastCalledWith(SESSION, child, childStream.signal) await settle({ ok: true, value: LEVEL }) - face.toggle(TAB, ROOT, child, [ROOT, child], signal) + face.toggle(PANE, ROOT, child, [ROOT, child]) expect(childStream.signal.aborted).toBe(true) await childStream.released.promise expect(rootStream.signal.aborted).toBe(false) expect(snapshot()!.expanded).toEqual([ROOT]) expect(list).toHaveBeenCalledTimes(2) - face.toggle(TAB, ROOT, child, [ROOT], signal) - expect(snapshot()!.levels[child]).toEqual({ kind: 'ready', level: LEVEL }) + face.toggle(PANE, ROOT, child, [ROOT]) + // Reopened, the kept level draws at once and is marked until its listing settles. + expect(snapshot()!.levels[child]).toEqual({ kind: 'ready', level: LEVEL, refreshing: true }) expect(list).toHaveBeenCalledTimes(2) const reopened = await watches.ready(child, 1) expect(reopened.signal).not.toBe(childStream.signal) @@ -109,9 +140,10 @@ describe('filesFace', () => { const componentsLevel: DirLevel = { entries: [{ name: 'nested', type: 'directory' }], truncated: false } const empty: DirLevel = { entries: [], truncated: false } const toggle = (parent: string, path: string): void => { - h.face.toggle(TAB, parent, path, h.snapshot()!.expanded, h.controller.signal) + h.face.toggle(PANE, parent, path, h.snapshot()!.expanded) } - h.face.start(TAB, ROOT, h.controller.signal) + h.attach() + h.face.hold(PANE, ROOT) await h.watches.ready(ROOT) await h.settle({ ok: true, value: LEVEL }) for (const [parent, path, level] of [[ROOT, src, srcLevel], [src, components, componentsLevel], [components, nested, empty]] as const) { @@ -145,11 +177,12 @@ describe('filesFace', () => { } }) - it('abort forgets the bucket and a late settlement writes nothing', async () => { - const { face, watches, settle, snapshot, controller } = mount() - face.start(TAB, ROOT, controller.signal) + it('forgets the pane once its last tab closes, and a late settlement writes nothing', async () => { + const { face, watches, settle, snapshot, attach, leave } = mount() + attach() + face.hold(PANE, ROOT) const stream = await watches.ready(ROOT) - controller.abort() + await leave() expect(stream.signal.aborted).toBe(true) expect(snapshot()).toBeUndefined() await settle({ ok: true, value: LEVEL }) @@ -157,38 +190,179 @@ describe('filesFace', () => { expect(snapshot()).toBeUndefined() }) - it('abort before ready closes the subscription without starting a listing', async () => { - const { face, list, watches, snapshot, controller } = mount() - face.start(TAB, ROOT, controller.signal) + it('a pane forgotten before ready closes the subscription without starting a listing', async () => { + const { face, list, watches, snapshot, attach, leave } = mount() + attach() + face.hold(PANE, ROOT) const stream = await watches.forPath(ROOT) - controller.abort() + await leave() await stream.released.promise expect(stream.signal.aborted).toBe(true) expect(list).not.toHaveBeenCalled() expect(snapshot()).toBeUndefined() }) - it('makes no request for a record that already ended', () => { - const { face, list, controller } = mount() + it('starts nothing for a pane that was never attached or whose tab already closed', () => { + const { face, watches, snapshot, controller, instance } = mount() + face.hold(PANE, ROOT)() controller.abort() - face.load(TAB, ROOT, controller.signal) - expect(list).not.toHaveBeenCalled() + face.attach(PANE, TAB, controller.signal)() + face.hold(PANE, ROOT) + face.scrolled(PANE, 40) + expect(watches.opened).toEqual([]) + expect(snapshot()).toBeUndefined() + expect(instance.getSnapshot().byPane).toEqual({}) + }) + + it('shows and focuses only a pane with a drawing recorded, even one its navigator has not attached yet', async () => { + const { face, instance } = mount() + face.focus(PANE) + expect(instance.getSnapshot().byPane).toEqual({}) + // A tab that just moved draws in its new pane before that drawing's effects attach it. + const moved = 'pane-moved' as PaneId + face.toggleDrawn(moved, {} as HTMLElement) + face.focus(moved) + // The store records only attached panes, so the navigator shows once those effects attach it. + expect(instance.getSnapshot().byPane[moved]).toBeUndefined() + const tab = new AbortController() + onTestFinished(() => { tab.abort() }) + face.attach(moved, 'tab-moved' as TabId, tab.signal) + expect(instance.getSnapshot().byPane[moved]?.open).toBe(true) + // The tree drawn there next takes the keyboard. + const body = { scrollTop: 0, focus: vi.fn() } + face.bodyDrawn(moved, `${ROOT}/a.ts`, body as Partial as HTMLElement) + await Promise.resolve() + expect(body.focus).toHaveBeenCalledTimes(1) + face.hide('pane-never' as PaneId) + expect(instance.getSnapshot().byPane['pane-never' as PaneId]).toBeUndefined() + }) + + it('ends a reveal still listing an ancestor when the reader collapses that ancestor', async () => { + const h = mount() + const src = `${ROOT}/src` + const address = sessionFileAddress(SESSION, 'src/a.ts') + const body = { scrollTop: 0, contains: () => false, ownerDocument: {} as Document } as Partial as HTMLElement + h.attach() + h.face.hold(PANE, ROOT) + await h.watches.ready(ROOT) + await h.settle({ ok: true, value: LEVEL }) + h.face.bodyDrawn(PANE, address, body) + await h.watches.ready(src) + expect(h.outstanding()).toEqual([src]) + h.face.toggle(PANE, ROOT, src, h.snapshot()!.expanded) + expect(h.snapshot()!.expanded).toEqual([ROOT]) + await h.settle({ ok: true, value: { entries: [{ name: 'a.ts', type: 'file' }], truncated: false } }) + // The tree drawn again for the same file does not reveal it again either. + h.face.bodyDrawn(PANE, address, null) + h.face.bodyDrawn(PANE, address, body) + expect(h.snapshot()!.expanded).toEqual([ROOT]) + expect(h.list).toHaveBeenCalledTimes(2) + expect(h.watches.opened.filter(stream => stream.path === src)).toHaveLength(1) + }) + + it('keeps one tree for repeated holds at its root and replaces it at another root', async () => { + const h = mount() + h.attach() + // A second navigator for the same tab in the same pane changes nothing, and releases once. + const again = h.face.attach(PANE, TAB, h.controller.signal) + again() + again() + h.face.hold(PANE, ROOT) + const first = await h.watches.ready(ROOT) + await h.settle({ ok: true, value: LEVEL }) + h.face.hold(PANE, ROOT) + expect(h.watches.opened).toHaveLength(1) + expect(h.snapshot()!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL }) + + const other = '/work/other' + h.face.hold(PANE, other) + await first.released.promise + expect(h.snapshot()).toEqual({ root: other, levels: {}, expanded: [other], autoRefresh: true }) + const second = await h.watches.ready(other) + await h.settle({ ok: true, value: LEVEL }) + expect(h.snapshot()!.levels[other]).toEqual({ kind: 'ready', level: LEVEL }) + await h.leave() + await second.released.promise + }) + + it('closes the listing once no hold remains at the end of the task, and lists the kept tree again in place', async () => { + const h = mount() + const child = `${ROOT}/src` + h.attach() + const release = h.face.hold(PANE, ROOT) + const rootStream = await h.watches.ready(ROOT) + await h.settle({ ok: true, value: LEVEL }) + h.face.toggle(PANE, ROOT, child, [ROOT]) + const childStream = await h.watches.ready(child) + await h.settle({ ok: true, value: LEVEL }) + h.face.setAutoRefresh(PANE, false) + + // A release followed by a hold in the same task, as a tab switch does, keeps the listing. + release() + release() + const next = h.face.hold(PANE, ROOT) + await Promise.resolve() + expect(rootStream.signal.aborted).toBe(false) + next() + await Promise.resolve() + expect(rootStream.signal.aborted).toBe(true) + expect(childStream.signal.aborted).toBe(true) + const kept = h.snapshot()! + expect(kept).toMatchObject({ expanded: [ROOT, child], autoRefresh: false }) + + // The kept levels draw at once and are marked until they are listed again. + h.face.hold(PANE, ROOT) + expect(h.snapshot()!.levels).toEqual({ + [ROOT]: { ...kept.levels[ROOT], refreshing: true }, + [child]: { ...kept.levels[child], refreshing: true }, + }) + await h.watches.ready(ROOT, 1) + await h.settle({ ok: true, value: LEVEL }) + await h.watches.ready(child, 1) + await h.settle({ ok: true, value: LEVEL }) + expect(h.snapshot()).toMatchObject({ expanded: [ROOT, child], autoRefresh: false }) + expect(h.list).toHaveBeenCalledTimes(4) + }) + + it('writes nothing for a refresh still listing when the pane leaves the layout', async () => { + const refreshTree = vi.spyOn(DirectoryNode.prototype, 'refreshTree') + onTestFinished(() => { refreshTree.mockRestore() }) + const h = mount() + const child = `${ROOT}/src` + h.attach() + h.face.hold(PANE, ROOT) + await h.watches.ready(ROOT) + await h.settle({ ok: true, value: LEVEL }) + h.face.toggle(PANE, ROOT, child, [ROOT]) + await h.watches.ready(child) + await h.settle({ ok: true, value: LEVEL }) + h.face.refresh(PANE) + await h.waitForList(2) + await h.leave() + // The root's listing settles after the tree is forgotten; its open child must not list or write. + await h.settle({ ok: true, value: LEVEL }) + const refreshing = refreshTree.mock.results[0] + if (refreshing?.type !== 'return') throw new Error('expected the refresh to start') + await expect(refreshing.value).resolves.toBeUndefined() + expect(h.snapshot()).toBeUndefined() + expect(h.list).toHaveBeenCalledTimes(3) }) it('ignores expansion after cancellation or when its parent is not active', async () => { const h = mount() - h.face.toggle(TAB, ROOT, `${ROOT}/src`, [ROOT], h.controller.signal) + h.face.toggle(PANE, ROOT, `${ROOT}/src`, [ROOT]) expect(h.snapshot()).toBeUndefined() - h.face.start(TAB, ROOT, h.controller.signal) + h.attach() + h.face.hold(PANE, ROOT) const stream = await h.watches.ready(ROOT) await h.settle({ ok: true, value: LEVEL }) const before = h.snapshot() - h.face.toggle(TAB, `${ROOT}/src`, `${ROOT}/src/nested`, [ROOT], h.controller.signal) + h.face.toggle(PANE, `${ROOT}/src`, `${ROOT}/src/nested`, [ROOT]) expect(h.snapshot()).toBe(before) expect(h.watches.opened.map(watch => watch.path)).toEqual([ROOT]) - h.controller.abort() + await h.leave() await stream.released.promise - h.face.toggle(TAB, ROOT, `${ROOT}/src`, [ROOT], h.controller.signal) + h.face.toggle(PANE, ROOT, `${ROOT}/src`, [ROOT]) expect(h.snapshot()).toBeUndefined() expect(h.list).toHaveBeenCalledTimes(1) expect(h.watches.opened).toHaveLength(1) @@ -202,7 +376,8 @@ describe('filesFace', () => { if (level?.kind === 'ready' && level.failure !== undefined) reported.resolve(undefined) }) onTestFinished(unsubscribe) - h.face.start(TAB, ROOT, h.controller.signal) + h.attach() + h.face.hold(PANE, ROOT) const stream = await h.watches.forPath(ROOT) stream.fail(failure) expect((await h.waitForList(0)).path).toBe(ROOT) @@ -215,72 +390,192 @@ describe('filesFace', () => { await stream.released.promise }) - it('does not publish a watch failure when the tab closes during its fallback list', async () => { + it('does not publish a watch failure when the pane closes during its fallback list', async () => { const close = vi.spyOn(DirectoryNode.prototype, 'close') onTestFinished(() => { close.mockRestore() }) const h = mount() - h.face.start(TAB, ROOT, h.controller.signal) + h.attach() + h.face.hold(PANE, ROOT) const stream = await h.watches.forPath(ROOT) stream.fail(new Error('watch disconnected')) await h.waitForList(0) - h.controller.abort() + await h.leave() const closing = close.mock.results[0] - if (closing?.type !== 'return') throw new Error('expected the tab to close its directory tree') + if (closing?.type !== 'return') throw new Error('expected the pane to close its directory tree') await h.settle({ ok: true, value: LEVEL }) await expect(closing.value).resolves.toBeUndefined() expect(h.snapshot()).toBeUndefined() expect(h.watches.opened).toHaveLength(1) }) - it('lets the latest listing of a level win, whichever settles first', async () => { - const { face, list, watches, settle, settleLatest, snapshot, outstanding, controller } = mount() - const { signal } = controller - const older: DirLevel = { entries: [{ name: 'old.txt', type: 'file' }], truncated: false } - face.start(TAB, ROOT, signal) - await watches.ready(ROOT) - face.load(TAB, ROOT, signal) - expect(list).toHaveBeenCalledTimes(2) - expect(outstanding()).toEqual([ROOT, ROOT]) - await settleLatest({ ok: true, value: LEVEL }) - expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL }) - await settle({ ok: true, value: older }) - expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL }) - face.load(TAB, ROOT, signal) - face.load(TAB, ROOT, signal) - await settleLatest({ ok: true, value: LEVEL }) - await settle({ ok: false, error: new RemoteError('workspace-file/not-found', 'gone', { path: ROOT }) }) - expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL }) - }) - - it('manually refreshes expanded nodes while preserving cached levels, scroll, and the automatic setting', async () => { - const { face, watches, settle, waitForList, snapshot, controller, actions } = mount() + it('manually refreshes expanded nodes while preserving cached levels and the automatic setting', async () => { + const { face, watches, settle, waitForList, snapshot, attach, actions } = mount() const child = `${ROOT}/src` const collapsed = `${ROOT}/docs` - face.start(TAB, ROOT, controller.signal) + attach() + face.hold(PANE, ROOT) await watches.ready(ROOT) await settle({ ok: true, value: LEVEL }) - face.toggle(TAB, ROOT, child, snapshot()!.expanded, controller.signal) + face.toggle(PANE, ROOT, child, snapshot()!.expanded) await watches.ready(child) await settle({ ok: true, value: LEVEL }) - actions.loaded(TAB, collapsed, LEVEL) - actions.scrolled(TAB, 120) - face.setAutoRefresh(TAB, false) + actions.loaded(PANE, collapsed, LEVEL) + face.setAutoRefresh(PANE, false) const cached = snapshot()! - face.refresh(TAB) + face.refresh(PANE) expect((await waitForList(2)).path).toBe(ROOT) - expect(snapshot()!.levels).toEqual(cached.levels) + expect(snapshot()!.levels).toEqual({ ...cached.levels, [ROOT]: { ...cached.levels[ROOT], refreshing: true } }) await settle({ ok: true, value: LEVEL }) expect((await waitForList(3)).path).toBe(child) - expect(snapshot()!.levels[child]).toEqual(cached.levels[child]) + expect(snapshot()!.levels[child]).toEqual({ ...cached.levels[child], refreshing: true }) await settle({ ok: true, value: { entries: [], truncated: false } }) expect(snapshot()!.levels[collapsed]).toEqual(cached.levels[collapsed]) - expect(snapshot()).toMatchObject({ expanded: [ROOT, child], scrollTop: 120, autoRefresh: false }) + expect(snapshot()).toMatchObject({ expanded: [ROOT, child], autoRefresh: false }) expect(watches.opened.map(stream => stream.path)).toEqual([ROOT, child]) expect(watches.opened.every(stream => !stream.signal.aborted)).toBe(true) }) }) +describe('filesFace pane records follow the tabs that drew them', () => { + const OTHER = 'pane-2' as PaneId + const B = 'tab-2' as TabId + + it('keeps a pane while a tab whose navigator was there is open, and forgets it when that tab closes', async () => { + const h = mount() + const tab = new AbortController() + const release = h.face.attach(PANE, TAB, tab.signal) + h.face.hold(PANE, ROOT)() + expect(h.snapshot()).toBeDefined() + // The pane shows another tab: the navigator goes, its tab stays open. + release() + await Promise.resolve() + expect(h.snapshot()).toBeDefined() + tab.abort() + await Promise.resolve() + expect(h.instance.getSnapshot().byPane[PANE]).toBeUndefined() + }) + + it('keeps a pane whose tab is replaced in place, as the replacement draws in the same task', async () => { + const h = mount() + const page = new AbortController() + const release = h.face.attach(PANE, TAB, page.signal) + h.face.hold(PANE, ROOT)() + // The page closes as the commit replacing it is planned, before its navigator goes. + page.abort() + release() + h.face.attach(PANE, B, new AbortController().signal) + await Promise.resolve() + expect(h.snapshot()).toBeDefined() + }) + + it('moves a tab to the pane where its navigator draws next, forgetting the pane it leaves', async () => { + const h = mount() + const tab = new AbortController() + h.face.attach(PANE, TAB, tab.signal)() + h.face.focus(PANE) + h.face.attach(OTHER, TAB, tab.signal) + await Promise.resolve() + // The pane it left is forgotten, and the pane it arrived in starts without a record. + expect(h.instance.getSnapshot().byPane).toEqual({}) + h.face.focus(OTHER) + expect(h.instance.getSnapshot().byPane[OTHER]?.open).toBe(true) + tab.abort() + await Promise.resolve() + // Still drawn there, so the pane stays until its navigator goes. + expect(h.instance.getSnapshot().byPane[OTHER]).toBeDefined() + }) + + it('places itself beside a tab through its owner, independently of the pane records', async () => { + const h = mount() + const tab = new AbortController() + const release = h.face.attach(PANE, TAB, tab.signal) + h.face.hold(PANE, ROOT)() + const unplace = h.face.placeBeside(TAB) + expect(h.placed.map(entry => [entry.tabId, entry.face])).toEqual([[TAB, h.face]]) + unplace() + expect(h.placed[0]!.release).toHaveBeenCalledTimes(1) + await Promise.resolve() + expect(h.snapshot()).toBeDefined() + release() + }) + + it('forgets a record that only drawings made, once those drawings go with no navigator attached', async () => { + const h = mount() + const width = h.face.keyedHooks.rowWidth(PANE) + expect(h.face.keyedHooks.rowWidth(PANE)).toBe(width) + // The tab closed before the drawing's effects attached its navigator. + h.controller.abort() + h.face.attach(PANE, TAB, h.controller.signal)() + await Promise.resolve() + h.face.focus(PANE) + expect(h.instance.getSnapshot().byPane).toEqual({}) + const fresh = h.face.keyedHooks.rowWidth(PANE) + expect(fresh).not.toBe(width) + const row = { contains: () => false, ownerDocument: {} as Document, parentElement: null } as Partial as HTMLElement + const drawings: readonly ((drawn: boolean) => void)[] = [ + (drawn) => { h.face.toggleDrawn(PANE, drawn ? row : null) }, + (drawn) => { h.face.currentDrawn(PANE, drawn ? row : null) }, + (drawn) => { h.face.navigatorDrawn(PANE, drawn ? row : null, true) }, + (drawn) => { h.face.bodyDrawn(PANE, sessionFileAddress(SESSION, 'a.ts'), drawn ? row : null) }, + ] + let previous = fresh + for (const drawing of drawings) { + drawing(true) + expect(h.face.keyedHooks.rowWidth(PANE)).toBe(previous) + drawing(false) + await Promise.resolve() + const next = h.face.keyedHooks.rowWidth(PANE) + expect(next).not.toBe(previous) + previous = next + } + }) + + it('records nothing for drawings that go after their pane was forgotten', async () => { + const h = mount() + h.attach() + h.face.hold(PANE, ROOT)() + await h.leave() + h.face.bodyDrawn(PANE, 'dsh-resource://file/session/s-1/a.ts', null) + h.face.currentDrawn(PANE, null) + h.face.navigatorDrawn(PANE, null, true) + h.face.toggleDrawn(PANE, null) + h.face.scrolled(PANE, 40) + expect(h.face.hold(PANE, ROOT)).toBeTypeOf('function') + expect(h.watches.opened).toHaveLength(1) + expect(h.instance.getSnapshot().byPane).toEqual({}) + }) +}) + +describe('canOpenOf', () => { + it('answers whether a tab type opens an address, with a new predicate only when registrations change', () => { + let entries: readonly never[] = [] + const listeners = new Set<() => void>() + const registry: TabTypeRegistry = { + entries: () => entries, + candidates: address => address.endsWith('.ts') ? entries : [], + subscribe: (listener) => { + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + } + const canOpen = canOpenOf(registry) + const first = canOpen.getSnapshot() + expect(canOpen.getSnapshot()).toBe(first) + expect(first('a.ts')).toBe(false) + const heard = vi.fn() + const unsubscribe = canOpen.subscribe(heard) + entries = [{} as never] + for (const listener of listeners) listener() + expect(heard).toHaveBeenCalledTimes(1) + const next = canOpen.getSnapshot() + expect(next).not.toBe(first) + expect([next('a.ts'), next('a.png')]).toEqual([true, false]) + unsubscribe() + expect(listeners.size).toBe(0) + }) +}) + describe('createList', () => { it('passes the session, the absolute path, and the signal through, and keeps entries and truncation', async () => { const listing: WorkspaceDirectoryListing = { diff --git a/packages/client/ui-sidebar-files/tests/file-tree-factory.client.spec.tsx b/packages/client/ui-sidebar-files/tests/file-tree-factory.client.spec.tsx new file mode 100644 index 0000000000..5bfdc07fba --- /dev/null +++ b/packages/client/ui-sidebar-files/tests/file-tree-factory.client.spec.tsx @@ -0,0 +1,59 @@ +// @vitest-environment jsdom +/** Real Factory composition, local Component identity, locale, and missing-definition fallback. */ +import { useState } from 'react' +import { act, fireEvent } from '@testing-library/react' +import { expect, it, onTestFinished, vi } from 'vitest' +import { makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import type { FactoryLocalComponentPropsOf, PropsRenderFactories } from '@deepseek-ai/dsh-client-ui-slots' +import { FileTree } from '../src/client/FileTree.tsx' +import type { FileTreeInput } from '../src/client/FileTree.tsx' +import { en } from '../src/client/locales.ts' + +function CounterAccessory({ entry }: FactoryLocalComponentPropsOf<'sidebar.files.tree', 'accessory'>) { + const [count, setCount] = useState(0) + return +} + +function EmptyAccessory() { return null } + +it('composes independent local accessories and preserves their state across input updates', async () => { + const runtime = await SlotTestRuntime.create() + onTestFinished(() => runtime.dispose()) + const revision = { revision: 0 } + runtime.slots.installLocale({ + getSnapshot: () => revision, subscribe: () => () => {}, bind: () => makeTranslate(en), + }) + const unregister = runtime.slots.registerFactory({ + name: 'sidebar.files.tree', scope: 'root', locale: 'sidebarFiles', slots: { accessory: { scope: 'root' } }, + }, FileTree) + const onOpen = vi.fn() + let input: FileTreeInput = { + root: 'pr', entries: [{ path: 'pr/a', name: 'a' }], expanded: [], onToggle: vi.fn(), onOpen, + } + let rerender = () => {} + await runtime.root.declare({}, function Root({ renderFactorySlot }: PropsRenderFactories) { + const [, update] = useState(0) + rerender = () => { update(value => value + 1) } + return <> +
    {renderFactorySlot('sidebar.files.tree', input, { slots: { accessory: CounterAccessory }, fallback:

    Unavailable

    })}
    +
    {renderFactorySlot('sidebar.files.tree', input)}
    +
    {renderFactorySlot('sidebar.files.tree', input, { slots: { accessory: EmptyAccessory } })}
    + + }) + const view = runtime.renderRoot() + fireEvent.click(view.getByRole('button', { name: 'pr/a: 0' })) + expect(view.getByRole('button', { name: 'pr/a: 1' })).toBeTruthy() + expect(onOpen).not.toHaveBeenCalled() + for (const variant of ['default', 'empty']) { + expect(view.container.querySelector(`[data-case="${variant}"] [data-files-path] button`)?.nextElementSibling?.childNodes).toHaveLength(0) + } + input = { ...input, entries: [{ path: 'pr/a', name: 'renamed' }] } + act(() => { rerender() }) + expect(view.getByRole('button', { name: 'pr/a: 1' })).toBeTruthy() + input = { ...input, entries: [] } + act(() => { rerender() }) + expect(view.getAllByText(en.empty)).toHaveLength(3) + await act(async () => { unregister() }) + expect(view.getByText('Unavailable')).toBeTruthy() + expect(view.container.querySelector('[data-case="default"]')?.textContent).toBe('') +}) diff --git a/packages/client/ui-sidebar-files/tests/file-tree-interactions.client.spec.tsx b/packages/client/ui-sidebar-files/tests/file-tree-interactions.client.spec.tsx new file mode 100644 index 0000000000..8c8aea1289 --- /dev/null +++ b/packages/client/ui-sidebar-files/tests/file-tree-interactions.client.spec.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom +/** Interactive accessories stay outside the primary action and its disabled scope. */ +import { afterEach, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { TreeFixture as FileTree } from './tree-fixture.client.tsx' +import type { Accessory } from './tree-fixture.client.tsx' +import type { FileTreeInput, TreeEntry, TreeEntryKind } from '../src/client/FileTree.tsx' + +afterEach(() => { cleanup() }) + +type TestInput = FileTreeInput & { accessory?: Accessory | undefined } + +function ActionAccessory({ entry }: { entry: TreeEntry }) { + return +} +const actions = new Map void>() +afterEach(() => { actions.clear() }) + +const file: TreeEntry = { path: 'pr/src/a', name: 'First.txt', meta: { kind: 'file', size: 1 } } + +function draw(overrides: Partial = {}) { + const onOpen = vi.fn() + const props: TestInput = { root: 'pr', entries: [file], expanded: ['pr/src'], onOpen, onToggle: vi.fn(), ...overrides } + const view = render() + return { view, onOpen } +} + +it.each(['file', 'directory', 'other'])('keeps a %s accessory button outside the primary action and its disabled scope', (kind) => { + const accessory = vi.fn() + const onToggle = vi.fn() + const diagnostic = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const entry = { ...file, meta: { kind } } + actions.set(entry.path, accessory) + const onOpen = vi.fn() + const view = render() + const action = view.getByRole('button', { name: 'Action' }) + fireEvent.click(action) + expect(accessory).toHaveBeenCalledTimes(1) + expect(onOpen).not.toHaveBeenCalled() + expect(onToggle).not.toHaveBeenCalled() + expect(view.container.querySelector('button button')).toBeNull() + expect(action.closest('[aria-disabled="true"]')).toBeNull() + expect(diagnostic).not.toHaveBeenCalled() + if (kind !== 'other') { + fireEvent.click(view.getByRole('button', { name: file.name })) + if (kind === 'file') expect(onOpen).toHaveBeenCalledWith(entry) + else expect(onToggle).toHaveBeenCalledWith(entry, true) + } + } finally { + diagnostic.mockRestore() + } +}) + +it('leaves an accessory enabled when the caller refuses to open its file', () => { + const accessory = vi.fn() + actions.set(file.path, accessory) + const { view, onOpen } = draw({ root: 'pr/src', canOpen: () => false, accessory: ActionAccessory }) + const action = view.getByRole('button', { name: 'Action' }) + expect(action.closest('[aria-disabled="true"]')).toBeNull() + fireEvent.click(action) + expect(accessory).toHaveBeenCalledTimes(1) + expect(onOpen).not.toHaveBeenCalled() +}) diff --git a/packages/client/ui-sidebar-files/tests/file-tree.client.spec.tsx b/packages/client/ui-sidebar-files/tests/file-tree.client.spec.tsx new file mode 100644 index 0000000000..aaebe5e72b --- /dev/null +++ b/packages/client/ui-sidebar-files/tests/file-tree.client.spec.tsx @@ -0,0 +1,290 @@ +// @vitest-environment jsdom +/** The file tree over data that is not a workspace listing: a pull request's changed files. */ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { TreeFixture as FileTree } from './tree-fixture.client.tsx' +import type { Accessory } from './tree-fixture.client.tsx' +import { en } from '../src/client/locales.ts' +import type { FileTreeInput, TreeEntry } from '../src/client/FileTree.tsx' + +afterEach(() => { cleanup() }) + +const labels = { + loading: en.loading, empty: en.empty, truncated: en.truncated, noViewer: en['entry.noViewer'], other: en['entry.other'], +} + +type TestInput = FileTreeInput & { accessory?: Accessory | undefined } + +/** Caller-owned status data stays outside tree entries. */ +const statuses: Readonly> = { 'pr/src/file10.ts': 'added' } +function ChangeAccessory({ entry }: { entry: TreeEntry }) { + return statuses[entry.path] === undefined ? null : {statuses[entry.path]} +} +function EmptyAccessory() { return null } +function ZeroAccessory() { return 0 } + +/** A pull request's changed files, flattened by the caller and listed in no particular order. */ +const CHANGES: readonly TreeEntry[] = [ + { path: 'pr/src/file10.ts', name: 'file10.ts', meta: { kind: 'file' } }, + { path: 'pr/README.md', name: 'README.md', meta: { kind: 'file' } }, + { path: 'pr/src', name: 'src', meta: { kind: 'directory' } }, + { path: 'pr/src/file2.ts', name: 'file2.ts', meta: { kind: 'file' } }, + { path: 'pr/src/util', name: 'util', meta: { kind: 'directory' } }, + { path: 'pr/src/util/a.ts', name: 'a.ts', meta: { kind: 'file' } }, + { path: 'pr/Assets', name: 'Assets', meta: { kind: 'directory' } }, + { path: 'pr/link', name: 'link', meta: { kind: 'other' } }, +] + +/** Draw the tree with recording callbacks; `rerender` redraws it with changed input. */ +function draw(input: Partial & Pick) { + const onToggle = vi.fn() + const onOpen = vi.fn() + const props = (next: Partial): TestInput => ({ + root: 'pr', expanded: [], onToggle, onOpen, ...input, ...next, + }) + const view = render() + const rerender = (next: Partial): void => { view.rerender() } + return { view, onToggle, onOpen, rerender } +} + +/** The drawn rows' keys, in document order. */ +function paths(root: HTMLElement): string[] { + return [...root.querySelectorAll('[data-files-entry]')].map(row => row.getAttribute('data-files-path')!) +} + +/** One drawn row's pressable or listed element. */ +function row(root: HTMLElement, path: string): HTMLElement { + const item = [...root.querySelectorAll('[data-files-path]')].find(candidate => candidate.getAttribute('data-files-path') === path) + return item!.firstElementChild!.firstElementChild as HTMLElement +} + +/** The drawn rows' names and kinds, in document order. */ +function rows(root: HTMLElement): string[] { + return [...root.querySelectorAll('[data-files-entry]')] + .map(item => `${item.getAttribute('data-files-entry')} ${item.querySelector('span')!.textContent}`) +} + +describe('FileTree', () => { + it('draws the root level, and a directory\'s level only while it is expanded, directories first, then in natural name order', () => { + const { view, rerender } = draw({ entries: CHANGES }) + expect(paths(view.container)).toEqual(['pr/Assets', 'pr/src', 'pr/link', 'pr/README.md']) + expect(row(view.container, 'pr/Assets').getAttribute('aria-expanded')).toBe('false') + rerender({ expanded: ['pr/src', 'pr/src/util'] }) + expect(paths(view.container)).toEqual(['pr/Assets', 'pr/src', 'pr/src/util', 'pr/src/util/a.ts', 'pr/src/file2.ts', 'pr/src/file10.ts', 'pr/link', 'pr/README.md']) + expect(row(view.container, 'pr/src').getAttribute('aria-expanded')).toBe('true') + }) + + it('asks the caller to expand a collapsed directory and to collapse an expanded one, with its entry', () => { + const { view, onToggle } = draw({ entries: CHANGES, expanded: ['pr/src'] }) + fireEvent.click(row(view.container, 'pr/Assets')) + fireEvent.click(row(view.container, 'pr/src')) + expect(onToggle.mock.calls).toEqual([[CHANGES[6], true], [CHANGES[2], false]]) + expect(onToggle.mock.calls[0]![0]).toBe(CHANGES[6]) + }) + + it('hands a pressed file to the caller, and lists anything else without opening it', () => { + const { view, onOpen } = draw({ entries: CHANGES }) + fireEvent.click(row(view.container, 'pr/README.md')) + expect(onOpen).toHaveBeenCalledWith(CHANGES[1]) + const other = row(view.container, 'pr/link') + expect(other.getAttribute('aria-disabled')).toBe('true') + expect(other.title).toBe(labels.other) + fireEvent.click(other) + expect(onOpen).toHaveBeenCalledTimes(1) + }) + + it('dims a file the caller refuses and says why', () => { + const canOpen = vi.fn((entry: TreeEntry) => entry.name !== 'README.md') + const { view, onOpen } = draw({ entries: CHANGES, canOpen, expanded: ['pr/src'] }) + const refused = row(view.container, 'pr/README.md') + expect(refused.getAttribute('aria-disabled')).toBe('true') + expect(refused.title).toBe(labels.noViewer) + expect(refused.querySelector('svg')).not.toBeNull() + fireEvent.click(refused) + expect(onOpen).not.toHaveBeenCalled() + expect(row(view.container, 'pr/src/file2.ts').tagName).toBe('BUTTON') + expect(canOpen).not.toHaveBeenCalledWith(expect.objectContaining({ meta: { kind: 'directory' } })) + }) + + it('leaves the browser context menu available without caller-supplied handlers', () => { + const { view } = draw({ entries: CHANGES }) + const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true }) + act(() => { row(view.container, 'pr/README.md').dispatchEvent(event) }) + expect(event.defaultPrevented).toBe(false) + expect(view.queryByRole('menuitem')).toBeNull() + }) + + it('draws each directory\'s state: loading in place of its entries, then failure, empty, and truncation notes', () => { + const { view, rerender } = draw({ entries: CHANGES, expanded: ['pr/src', 'pr/Assets'], directories: { 'pr/src': { loading: true } } }) + const notes = (): (string | null)[][] => [...view.container.querySelectorAll('[data-files-row]')] + .map(note => [note.getAttribute('data-files-row'), note.textContent]) + expect(notes()).toEqual([['empty', labels.empty], ['loading', labels.loading]]) + rerender({ + expanded: ['pr/src', 'pr/Assets'], + directories: { 'pr': { truncated: true }, 'pr/src': { failure: 'Gone', truncated: true }, 'pr/Assets': { failure: 'Denied' } }, + }) + expect(notes()).toEqual([['failed', 'Denied'], ['failed', 'Gone'], ['truncated', labels.truncated], ['truncated', labels.truncated]]) + expect(paths(view.container)).toContain('pr/src/file2.ts') + rerender({ root: 'empty', entries: [] }) + expect(notes()).toEqual([['empty', labels.empty]]) + }) + + it('orders only drawn levels, and orders a drawn level again only when its names or directories change', () => { + // Every ordering compares names through the collator, so its comparisons count the sorts. + const compare = vi.spyOn(Intl.Collator.prototype, 'compare', 'get') + try { + const { view, rerender } = draw({ entries: CHANGES, expanded: ['pr/src'] }) + expect(compare).toHaveBeenCalled() + compare.mockClear() + // New entry objects with changed metadata must reach callbacks without another sort. + const relisted = CHANGES.map(entry => ({ ...entry, meta: { ...entry.meta, size: 2 } })) + const onOpen = vi.fn() + rerender({ expanded: ['pr/src'], entries: relisted, directories: { 'pr/src': { truncated: true } }, onOpen }) + expect(compare).not.toHaveBeenCalled() + fireEvent.click(row(view.container, 'pr/src/file10.ts')) + expect(onOpen.mock.calls[0]![0]).toBe(relisted[0]) + const added: TreeEntry = { path: 'pr/src/util/b.ts', name: 'b.ts' } + rerender({ expanded: ['pr/src'], entries: [...CHANGES, added] }) + expect(compare).not.toHaveBeenCalled() + rerender({ expanded: ['pr/src', 'pr/src/util'], entries: [...CHANGES, added] }) + expect(compare).toHaveBeenCalledTimes(1) + expect(paths(view.container)).toContain('pr/src/util/b.ts') + compare.mockClear() + rerender({ expanded: ['pr/src', 'pr/src/util'], entries: [...CHANGES, { ...added, name: 'c.ts' }] }) + expect(compare).toHaveBeenCalledTimes(1) + } finally { + compare.mockRestore() + } + }) + + it.each(['src', ''])('omits the root entry for root %j even when its key is expanded', (root) => { + const directory = root === '' ? 'nested' : `${root}/nested` + const file = `${directory}/a.ts` + const { view, rerender } = draw({ + root, + entries: [ + { path: root, name: 'Root', meta: { kind: 'directory' } }, + { path: file, name: 'a.ts' }, + ], + }) + expect(paths(view.container)).toEqual([directory]) + expect(view.queryByRole('button', { name: 'Root' })).toBeNull() + rerender({ expanded: [root, directory] }) + expect(paths(view.container)).toEqual([directory, file]) + expect(view.queryByRole('button', { name: 'Root' })).toBeNull() + }) + + it('puts an entry whose path has no slash under the root, whatever the root', () => { + const { view, rerender } = draw({ entries: [ + { path: 'README.md', name: 'README.md' }, + { path: 'pr/docs', name: 'docs', meta: { kind: 'directory' } }, + ] }) + expect(paths(view.container)).toEqual(['pr/docs', 'README.md']) + rerender({ root: '' }) + expect(paths(view.container)).toEqual(['pr', 'README.md']) + }) + + it('draws an empty root with entries at the top level given by name alone or after a slash, adding each implied directory once', () => { + const { view } = draw({ + root: '', + entries: [ + { path: 'docs/guide.md', name: 'guide.md' }, + { path: 'docs', name: 'Documentation', meta: { kind: 'directory' } }, + { path: 'README.md', name: 'README.md' }, + { path: '/etc/hosts', name: 'hosts' }, + { path: '/etc/ssh/config', name: 'config' }, + ], + expanded: ['docs', '/etc', '/etc/ssh'], + }) + expect(paths(view.container)).toEqual(['docs', 'docs/guide.md', '/etc', '/etc/ssh', '/etc/ssh/config', '/etc/hosts', 'README.md']) + expect(rows(view.container)).toEqual([ + 'directory Documentation', 'file guide.md', 'directory etc', 'directory ssh', 'file config', 'file hosts', 'file README.md', + ]) + }) + + it('adds the directories a path implies, and keeps the caller\'s own directory entry', () => { + const own: TreeEntry = { path: 'pr/src', name: 'Sources', meta: { kind: 'directory', hidden: true } } + const { view, onToggle } = draw({ + entries: [{ path: 'pr/src/lib/a.ts', name: 'a.ts' }, own], + expanded: ['pr/src', 'pr/src/lib'], + }) + expect(rows(view.container)).toEqual(['directory Sources', 'directory lib', 'file a.ts']) + fireEvent.click(row(view.container, 'pr/src/lib')) + fireEvent.click(row(view.container, 'pr/src')) + expect(onToggle.mock.calls).toEqual([[{ path: 'pr/src/lib', name: 'lib', meta: { kind: 'directory' } }, false], [own, false]]) + }) + + it('takes an entry without a kind as a directory when entries lie below it, and as a file otherwise', () => { + const { view } = draw({ entries: [ + { path: 'pr/lib', name: 'lib' }, + { path: 'pr/lib/a.ts', name: 'a.ts' }, + { path: 'pr/notes', name: 'notes' }, + { path: 'pr/empty', name: 'empty', meta: { kind: 'directory' } }, + ], expanded: ['pr/empty'] }) + expect(rows(view.container)).toEqual(['directory empty', 'directory lib', 'file notes']) + expect(view.container.querySelector('[data-files-row="empty"]')?.textContent).toBe(labels.empty) + }) + + it('draws the caller\'s name, which need not end the path, and splits paths only at a slash', () => { + const { view } = draw({ + root: 'C:\\PROGRA~1\\repo', + entries: [ + { path: 'C:\\PROGRA~1\\repo/LONGFO~1', name: 'Long folder name', meta: { kind: 'directory' } }, + { path: 'C:\\PROGRA~1\\repo/LONGFO~1/a.ts', name: 'a.ts' }, + { path: 'C:\\PROGRA~1\\repo/back\\slash/b.ts', name: 'b.ts' }, + ], + expanded: ['C:\\PROGRA~1\\repo/LONGFO~1'], + }) + expect(rows(view.container)).toEqual(['directory back\\slash', 'directory Long folder name', 'file a.ts']) + view.rerender( {}} onOpen={() => {}} />) + expect(rows(view.container)).toEqual(['file Data']) + }) + + it('draws no entry outside the root and expanded directories, never adds the empty key, and draws a repeated path once', () => { + const { view } = draw({ entries: [ + { path: '/elsewhere', name: 'elsewhere' }, { path: 'pr/a.ts', name: 'a.ts' }, { path: 'pr/a.ts', name: 'again.ts' }, + ] }) + expect(rows(view.container)).toEqual(['file a.ts']) + }) + + it('draws a local accessory from caller-owned data by path and subdues hidden rows', () => { + const { view } = draw({ + entries: [...CHANGES, { path: 'pr/.env', name: '.env', meta: { hidden: true } }], + accessory: ChangeAccessory, expanded: ['pr/src'], + }) + expect(row(view.container, 'pr/src/file10.ts').parentElement!.textContent).toBe('file10.tsadded') + expect(row(view.container, 'pr/.env').className).not.toBe(row(view.container, 'pr/README.md').className) + }) + + it.each<[string, Accessory | undefined]>([['missing', undefined], ['empty', EmptyAccessory]])( + 'keeps %s wrappers empty and CSS-hidden until content (including 0) renders', (_name, accessory) => { + const { view, rerender } = draw({ entries: CHANGES, canOpen: entry => entry.name !== 'file2.ts', expanded: ['pr/src'], accessory }) + const examples = ['pr/src', 'pr/README.md', 'pr/link', 'pr/src/file2.ts'] + for (const path of examples) expect(row(view.container, path).nextElementSibling?.childNodes).toHaveLength(0) + rerender({ expanded: ['pr/src'], accessory: ZeroAccessory }) + for (const path of examples) expect(row(view.container, path).nextElementSibling?.textContent).toBe('0') + // jsdom does not install CSS Modules; pin the rule that removes the empty wrapper from layout. + const sheet = readFileSync(resolve(import.meta.dirname, '../src/client/FileTree.module.css'), 'utf8') + expect(/\.accessory:empty \{\s*display: none;\s*\}/.test(sheet)).toBe(true) + }, + ) + + it('subdues hidden rows with the secondary ink, which stays readable in both themes', () => { + const sheet = readFileSync(resolve(import.meta.dirname, '../src/client/FileTree.module.css'), 'utf8') + expect(/\.hidden \{\s*color: var\(--dsw-alias-label-secondary\);\s*\}/.test(sheet)).toBe(true) + }) + + it('marks the current file and hands its row to the caller', () => { + const currentRef = vi.fn<(row: HTMLElement | null) => void>() + const { view, rerender } = draw({ entries: CHANGES, expanded: ['pr/src'], current: 'pr/src/file2.ts', currentRef }) + const current = row(view.container, 'pr/src/file2.ts') + expect(current.getAttribute('aria-current')).toBe('true') + expect(view.container.querySelectorAll('[aria-current]')).toHaveLength(1) + expect(currentRef).toHaveBeenLastCalledWith(current) + rerender({ expanded: [], current: 'pr/src/file2.ts', currentRef }) + expect(currentRef).toHaveBeenLastCalledWith(null) + }) +}) diff --git a/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx b/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx index aca573dcac..ddb3dd2dfa 100644 --- a/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx +++ b/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx @@ -1,14 +1,14 @@ // @vitest-environment jsdom -/** File-tree presentation over controlled directory watches and deferred listings. */ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, fireEvent } from '@testing-library/react' +/** The files page and its tree, over controlled directory watches and deferred listings. */ +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { act, cleanup, fireEvent, within } from '@testing-library/react' import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path' -import { failureLine, orderEntries } from '../src/client/FilesBody.tsx' +import { failureLine } from '../src/client/workspace-tree.ts' import type { DirLevel } from '../src/client/store.ts' import { zh } from '../src/client/locales.ts' -import { mountBody, ROOT, SESSION, TAB } from './mount.client.tsx' +import { mountBody, PANE, ROOT, SESSION } from './mount.client.tsx' const ROOT_LEVEL: DirLevel = { entries: [ @@ -29,8 +29,8 @@ function names(root: HTMLElement): string[] { describe('FilesBody', () => { it('displays the effective file-tree refresh accelerator', async () => { - const { view, script } = mountBody(ROOT, { id: 'page.refresh' as never, label: 'Refresh', aliases: [], - binding: null, keys: ['Ctrl', 'R'], aria: 'Control+R', modified: true, conflicts: [], issue: null }) + const { view, script } = mountBody(ROOT, { refreshShortcut: { id: 'page.refresh' as never, label: 'Refresh', aliases: [], + binding: null, keys: ['Ctrl', 'R'], aria: 'Control+R', modified: true, conflicts: [], issue: null } }) await act(() => script.watches.ready(ROOT)) await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) expect(view.getByRole('button', { name: zh.reload }).getAttribute('aria-keyshortcuts')).toBe('Control+R') @@ -41,6 +41,36 @@ describe('FilesBody', () => { expect(script.list).not.toHaveBeenCalled() }) + it('heads the page with the root and reload, and fills the rest with a headerless tree that stays shown', async () => { + const viewport = { matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() } + vi.stubGlobal('matchMedia', () => viewport) + onTestFinished(() => { vi.unstubAllGlobals() }) + const { view, script, instance, tabActions } = mountBody() + expect(viewport.addEventListener).not.toHaveBeenCalled() + await act(() => script.watches.ready(ROOT)) + const page = view.container.querySelector('[data-files-page]')! + const header = page.firstElementChild! + expect(header.querySelector('[data-files-path]')?.getAttribute('title')).toBe(ROOT) + expect([...header.querySelectorAll('button:not([data-files-auto-refresh])')].map(button => button.getAttribute('aria-label'))) + .toEqual([zh.reload]) + const content = page.querySelector('[data-files-content]')! + expect(content.children).toHaveLength(1) + const navigator = content.firstElementChild! + expect(navigator.getAttribute('data-files-navigator')).toBe(PANE) + expect(navigator.querySelector('[data-files-reload], [data-files-path]:not(li)')).toBeNull() + // The tree fills the page: it neither floats nor resizes. + expect(navigator.hasAttribute('data-files-navigator-mode')).toBe(false) + expect(view.queryByRole('separator')).toBeNull() + expect(view.container.querySelector('[data-files-navigator-toggle]')).toBeNull() + // Escape and a hidden choice for the pane leave the page's tree shown. + fireEvent.keyDown(navigator, { key: 'Escape' }) + act(() => { instance.actions.shown(PANE, false) }) + expect(view.container.querySelector('[data-files-navigator]')).not.toBeNull() + expect(view.getByRole('button', { name: zh.reload }).hasAttribute('disabled')).toBe(false) + const bound = tabActions.bindCommands.mock.lastCall?.[0] + expect(typeof bound?.refresh).toBe('function') + }) + it('lists the root on mount, heads it with its path split at the last segment, and draws directories first with dotfiles kept', async () => { const { view, script } = mountBody() expect(script.list).not.toHaveBeenCalled() @@ -69,12 +99,12 @@ describe('FilesBody', () => { it('marks the root path clipped while its text is wider than its box, re-reading on resize', async () => { class FakeResizeObserver implements ResizeObserver { - static latest: FakeResizeObserver | undefined + static readonly instances: FakeResizeObserver[] = [] readonly observe = vi.fn() readonly unobserve = vi.fn() readonly disconnect = vi.fn() constructor(private readonly callback: ResizeObserverCallback) { - FakeResizeObserver.latest = this + FakeResizeObserver.instances.push(this) } fire(): void { @@ -94,7 +124,8 @@ describe('FilesBody', () => { const path = view.container.querySelector('[data-files-path]') const text = path?.firstElementChild expect(path?.hasAttribute('data-path-clipped')).toBe(false) - const observer = FakeResizeObserver.latest + // The navigator observes its row too; the path label observes itself. + const observer = FakeResizeObserver.instances.find(candidate => candidate.observe.mock.calls.some(([target]) => target === path)) if (observer === undefined) throw new Error('expected the path to observe its size') expect(observer.observe).toHaveBeenCalledWith(path) expect(observer.observe).toHaveBeenCalledWith(text) @@ -121,7 +152,7 @@ describe('FilesBody', () => { const { view, script } = mountBody() await act(() => script.watches.ready(ROOT)) await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) - const dir = view.container.querySelector(`[data-files-path="${ROOT}/src"] > button`)! + const dir = within(view.container.querySelector(`[data-files-path="${ROOT}/src"]`)!).getByRole('button', { name: 'src' }) act(() => { fireEvent.click(dir) }) expect(script.list).toHaveBeenCalledTimes(1) const childStream = await act(() => script.watches.ready(`${ROOT}/src`)) @@ -145,14 +176,18 @@ describe('FilesBody', () => { expect(names(view.container)).not.toContain(`${ROOT}/src/a.ts`) }) - it('a file click opens its session-scoped file: address through the owner; an other entry offers no button', async () => { - const { view, script, tabActions } = mountBody() + it.each([false, true])('opens a file without replacing the page (floating: %s)', async (isFloating) => { + const { view, script, tabActions, instance } = mountBody(ROOT, { isFloating }) await act(() => script.watches.ready(ROOT)) await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) - fireEvent.click(view.container.querySelector(`[data-files-path="${ROOT}/README.md"] > button`)!) + fireEvent.click(within(view.container.querySelector(`[data-files-path="${ROOT}/README.md"]`)!).getByRole('button', { name: 'README.md' })) // Every row sits under the tree's root, so the address is the path relative to it. - expect(tabActions.openResource).toHaveBeenCalledWith(fileAddressFor(SESSION, ROOT, `${ROOT}/README.md`)) - expect(tabActions.openResource).toHaveBeenCalledWith('dsh-resource://file/session/s-test/README.md') + const address = fileAddressFor(SESSION, ROOT, `${ROOT}/README.md`) + if (isFloating) expect(tabActions.openResource).toHaveBeenCalledExactlyOnceWith(address) + else expect(tabActions.openResource).toHaveBeenCalledExactlyOnceWith(address, { paneId: PANE }) + // The page writes no tree choice for its pane, where the file it appends draws. + expect(instance.getSnapshot().byPane[PANE]?.open).toBeUndefined() + expect(view.container.querySelector('[data-files-navigator]')).not.toBeNull() const other = view.container.querySelector(`[data-files-path="${ROOT}/pipe"]`)! expect(other.querySelector('button')).toBeNull() expect(other.querySelector('[aria-disabled="true"]')?.getAttribute('title')).toBe(zh['entry.other']) @@ -163,22 +198,21 @@ describe('FilesBody', () => { await act(() => script.watches.ready(ROOT)) await act(() => script.settle({ ok: true, value: { entries: [{ name: 'd', type: 'directory' }], truncated: true } })) expect(view.container.querySelector('[data-files-row="truncated"]')?.textContent).toBe(zh.truncated) - act(() => { fireEvent.click(view.container.querySelector(`[data-files-path="${ROOT}/d"] > button`)!) }) + act(() => { fireEvent.click(within(view.container.querySelector(`[data-files-path="${ROOT}/d"]`)!).getByRole('button', { name: 'd' })) }) await act(() => script.watches.ready(`${ROOT}/d`)) await act(() => script.settle({ ok: true, value: { entries: [], truncated: false } })) expect(view.container.querySelector('[data-files-row="empty"]')?.textContent).toBe(zh.empty) }) - it('shows a failed level under its directory with the failure code', async () => { + it('shows a failed level under its directory in terms of the directory', async () => { const { view, script } = mountBody() await act(() => script.watches.ready(ROOT)) await act(() => script.settle({ ok: false, error: new RemoteError('workspace-file/not-found', 'gone', { path: ROOT }), })) - const failed = view.container.querySelector('[data-files-row="failed"]') - expect(failed?.getAttribute('data-files-code')).toBe('workspace-file/not-found') - expect(failed?.textContent).toBe(zh['error.notFound']) + expect(view.container.querySelector('[data-files-row="failed"]')?.textContent).toBe(zh['error.notFound']) + expect(view.container.querySelector('[data-files-row="empty"]')).toBeNull() }) it('shows a refresh failure beside the existing directory rows and clears it on retry', async () => { @@ -205,11 +239,11 @@ describe('FilesBody', () => { const collapsed = `${ROOT}/docs` const rootStream = await act(() => script.watches.ready(ROOT)) await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) - act(() => { fireEvent.click(view.container.querySelector(`[data-files-path="${child}"] > button`)!) }) + act(() => { fireEvent.click(within(view.container.querySelector(`[data-files-path="${child}"]`)!).getByRole('button', { name: 'src' })) }) await act(() => script.watches.ready(child)) await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) - act(() => { instance.actions.loaded(TAB, collapsed, ROOT_LEVEL) }) - const cached = instance.getSnapshot().byTab[TAB]! + act(() => { instance.actions.loaded(PANE, collapsed, ROOT_LEVEL) }) + const cached = instance.getSnapshot().byPane[PANE]!.tree! const childRow = view.container.querySelector(`[data-files-path="${child}"]`) const body = view.container.querySelector('[data-files-body]')! fireEvent.scroll(body, { target: { scrollTop: 120 } }) @@ -220,18 +254,21 @@ describe('FilesBody', () => { }) expect(script.list.mock.calls.slice(2).map(call => call[1])).toEqual([ROOT]) expect(script.list).toHaveBeenLastCalledWith(SESSION, ROOT, rootStream.signal) - expect(instance.getSnapshot().byTab[TAB]!.levels).toEqual(cached.levels) + // A level listed again keeps its entries, marked as refreshing. + expect(instance.getSnapshot().byPane[PANE]!.tree!.levels) + .toEqual({ ...cached.levels, [ROOT]: { ...cached.levels[ROOT], refreshing: true } }) expect(view.container.querySelector('[data-files-row="loading"]')).toBeNull() await act(async () => { await script.settle({ ok: true, value: ROOT_LEVEL }) await script.waitForList(3) }) expect(script.list.mock.calls.slice(2).map(call => call[1])).toEqual([ROOT, child]) - expect(instance.getSnapshot().byTab[TAB]!.levels).toEqual(cached.levels) + expect(instance.getSnapshot().byPane[PANE]!.tree!.levels) + .toEqual({ ...cached.levels, [child]: { ...cached.levels[child], refreshing: true } }) await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) expect(view.container.querySelector(`[data-files-path="${child}"]`)).toBe(childRow) expect(body.scrollTop).toBe(120) - expect(instance.getSnapshot().byTab[TAB]!.expanded).toEqual([ROOT, child]) + expect(instance.getSnapshot().byPane[PANE]!.tree!.expanded).toEqual([ROOT, child]) expect(script.watches.opened.map(stream => stream.path)).toEqual([ROOT, child]) expect(view.container.querySelector('[data-files-reload]')?.getAttribute('aria-label')).toBe(zh.reload) }) @@ -252,63 +289,9 @@ describe('FilesBody', () => { act(() => { fireEvent.click(view.getByRole('button', { name: zh.reload })) }) await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) expect(script.list).toHaveBeenCalledTimes(2) - expect(instance.getSnapshot().byTab[TAB]!.autoRefresh).toBe(false) + expect(instance.getSnapshot().byPane[PANE]!.tree!.autoRefresh).toBe(false) expect(automatic.getAttribute('aria-pressed')).toBe('false') }) - - it('captures the body scroll offset on unmount and restores it when a tab switch remounts the tree', async () => { - const { view, script, instance, remount } = mountBody() - await act(() => script.watches.ready(ROOT)) - await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) - const body = view.container.querySelector('[data-files-body]')! - fireEvent.scroll(body, { target: { scrollTop: 120 } }) - // Scrolling writes nothing yet: the store hears the offset once, on unmount. - expect(instance.getSnapshot().byTab[TAB]!.scrollTop).toBe(0) - view.unmount() - expect(instance.getSnapshot().byTab[TAB]!.scrollTop).toBe(120) - const back = remount() - expect(back.container.querySelector('[data-files-body]')!.scrollTop).toBe(120) - }) - - it('a scroll before the owner aborts is not written to a forgotten bucket', async () => { - const { view, script, controller, instance } = mountBody() - await act(() => script.watches.ready(ROOT)) - await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) - fireEvent.scroll(view.container.querySelector('[data-files-body]')!, { target: { scrollTop: 80 } }) - act(() => { controller.abort() }) - view.unmount() - expect(instance.getSnapshot().byTab[TAB]).toBeUndefined() - }) - - it('an aborted record is forgotten and not seeded again while the body is still mounted', async () => { - const { view, script, controller, instance } = mountBody() - await act(() => script.watches.ready(ROOT)) - await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) - act(() => { controller.abort() }) - expect(instance.getSnapshot().byTab[TAB]).toBeUndefined() - expect(view.container.querySelector('[data-files-state="tree"]')).toBeNull() - expect(script.list).toHaveBeenCalledTimes(1) - }) -}) - -describe('orderEntries', () => { - it('puts directories first and orders each group by name, numbers included', () => { - const ordered = orderEntries([ - { name: 'file10.txt', type: 'file' }, - { name: 'zeta', type: 'directory' }, - { name: 'file2.txt', type: 'file' }, - { name: '.env', type: 'file' }, - { name: 'Alpha', type: 'directory' }, - { name: 'sock', type: 'other' }, - ]) - expect(ordered.map(entry => entry.name)).toEqual(['Alpha', 'zeta', '.env', 'file2.txt', 'file10.txt', 'sock']) - }) - - it('leaves the endpoint\'s array untouched', () => { - const entries = [{ name: 'b', type: 'file' as const }, { name: 'a', type: 'file' as const }] - orderEntries(entries) - expect(entries.map(entry => entry.name)).toEqual(['b', 'a']) - }) }) describe('failureLine', () => { diff --git a/packages/client/ui-sidebar-files/tests/files-navigator.client.spec.tsx b/packages/client/ui-sidebar-files/tests/files-navigator.client.spec.tsx new file mode 100644 index 0000000000..baaa8e2d53 --- /dev/null +++ b/packages/client/ui-sidebar-files/tests/files-navigator.client.spec.tsx @@ -0,0 +1,846 @@ +// @vitest-environment jsdom +/** + * The navigator beside a file preview: hidden until the pane asks for it, one + * tree per pane that a remount or a hide finds as it was, a listing that lives + * only while the tree is drawn, the current file located, clicks that open or + * reveal tabs, keyboard focus carried to the pane's next drawing of the tree, + * the card that sits beside the preview in a row at least 560px wide, floats + * over it in a row at least 400px wide, and fills a narrower row or a phone's, + * and its resize bounds. + */ +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { act, cleanup, fireEvent, within } from '@testing-library/react' +import { Context } from '@deepseek-ai/cordis' +import { createSidebarRightController } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/service.ts' +import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/tab-registry.ts' +import { createSidebarRightStore } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/stores.ts' +import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import { DEFAULT_NAVIGATOR_WIDTH } from '../src/client/store.ts' +import type { DirLevel, FilesTreeState } from '../src/client/store.ts' +import { locateFile, navigatorGeometry, relativeFileOf } from '../src/client/store.ts' +import { zh } from '../src/client/locales.ts' +import { mountNavigator, PANE, ROOT, SESSION, TAB } from './mount.client.tsx' +import type { Mounted } from './mount.client.tsx' + +const ROOT_LEVEL: DirLevel = { + entries: [{ name: 'src', type: 'directory' }, { name: 'README.md', type: 'file', size: 1 }], + truncated: false, +} +const SRC_LEVEL: DirLevel = { entries: [{ name: 'a.ts', type: 'file', size: 1 }, { name: 'b.ts', type: 'file', size: 1 }], truncated: false } +const A = sessionFileAddress(SESSION, 'src/a.ts') +const B = sessionFileAddress(SESSION, 'src/b.ts') + +// jsdom lays nothing out and has no scrolling into view; the spec records the request. +beforeEach(() => { Element.prototype.scrollIntoView = vi.fn() }) +afterEach(() => { + cleanup() + Reflect.deleteProperty(Element.prototype, 'scrollIntoView') +}) + +/** Show the navigator and answer the root and `src` listings, as revealing `src/…` asks for them. */ +async function revealSrc(h: Mounted): Promise { + act(() => { fireEvent.click(h.view.getByRole('button', { name: zh['navigator.show'] })) }) + await act(() => h.script.watches.ready(ROOT)) + await act(() => h.script.settle({ ok: true, value: ROOT_LEVEL })) + await act(() => h.script.watches.ready(`${ROOT}/src`)) + await act(() => h.script.settle({ ok: true, value: SRC_LEVEL })) +} + +/** Let the task end, and with it what a drawing that went away left for the same task. */ +async function endTask(): Promise { + await act(async () => { await Promise.resolve() }) +} + +/** The path of the row holding keyboard focus. */ +function focusedPath(): string | null | undefined { + return document.activeElement?.closest('[data-files-path]')?.getAttribute('data-files-path') +} + +describe('FileNavigator beside a preview', () => { + it('stays hidden and lists nothing until the toggle shows it', async () => { + const h = mountNavigator(A) + expect(h.view.container.querySelector('[data-files-navigator]')).toBeNull() + const toggle = h.view.getByRole('button', { name: zh['navigator.show'] }) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(h.script.watches.opened).toEqual([]) + act(() => { fireEvent.click(toggle) }) + expect(h.instance.getSnapshot().byPane[PANE]?.open).toBe(true) + expect(h.view.getByRole('button', { name: zh['navigator.hide'] }).getAttribute('aria-expanded')).toBe('true') + await act(() => h.script.watches.ready(ROOT)) + expect(h.view.container.querySelector('[data-files-navigator]')).not.toBeNull() + }) + + it('reveals the previewed file once, marks its row, and scrolls it into view', async () => { + const scrolled = vi.fn() + Element.prototype.scrollIntoView = scrolled + const h = mountNavigator(A) + await revealSrc(h) + const current = h.view.container.querySelector('[aria-current="true"]') + expect(current?.closest('[data-files-path]')?.getAttribute('data-files-path')).toBe(`${ROOT}/src/a.ts`) + expect(scrolled).toHaveBeenCalledTimes(1) + expect(scrolled.mock.contexts[0]).toBe(current) + // The reader collapses the directory; a remount of the same file does not reopen it. + act(() => { fireEvent.click(within(h.view.container.querySelector(`[data-files-path="${ROOT}/src"]`)!).getByRole('button', { name: 'src' })) }) + h.view.unmount() + const back = h.remount() + expect(within(back.container.querySelector(`[data-files-path="${ROOT}/src"]`)!).getByRole('button', { name: 'src' })?.getAttribute('aria-expanded')).toBe('false') + expect(scrolled).toHaveBeenCalledTimes(1) + }) + + it('keeps the tree, its width and its scroll when another file\'s tab of the pane remounts it', async () => { + const h = mountNavigator(A) + await revealSrc(h) + act(() => { h.instance.actions.resized(PANE, 300) }) + fireEvent.scroll(h.view.container.querySelector('[data-files-body]')!, { target: { scrollTop: 64 } }) + h.view.unmount() + h.placement.contentId = B + const back = h.remount() + expect(back.container.querySelector('[data-files-navigator]')?.style.getPropertyValue('--files-navigator-width')).toBe('300px') + expect(back.container.querySelector('[data-files-body]')?.scrollTop).toBe(64) + expect(back.container.querySelector('[aria-current="true"]')?.closest('[data-files-path]')?.getAttribute('data-files-path')) + .toBe(`${ROOT}/src/b.ts`) + expect(h.script.list).toHaveBeenCalledTimes(2) + expect(h.script.watches.opened.map(stream => stream.path)).toEqual([ROOT, `${ROOT}/src`]) + }) + + it('locates the file of the tab switched to: expands its collapsed ancestors, marks its row, and scrolls it into view', async () => { + const scrolled = vi.fn() + Element.prototype.scrollIntoView = scrolled + const h = mountNavigator(A) + await revealSrc(h) + const src = (view: typeof h.view): Element | null | undefined => within(view.container.querySelector(`[data-files-path="${ROOT}/src"]`)!).getByRole('button', { name: 'src' }) + act(() => { fireEvent.click(src(h.view)!) }) + expect(src(h.view)?.getAttribute('aria-expanded')).toBe('false') + h.view.unmount() + h.placement.contentId = B + const back = h.remount() + expect(src(back)?.getAttribute('aria-expanded')).toBe('true') + const current = back.container.querySelector('[aria-current="true"]') + expect(current?.closest('[data-files-path]')?.getAttribute('data-files-path')).toBe(`${ROOT}/src/b.ts`) + expect(scrolled.mock.contexts.at(-1)).toBe(current) + // A tab outside the workspace marks nothing and leaves the tree as it is. + back.unmount() + h.placement.contentId = sessionFileAddress(SESSION, '/elsewhere/c.ts') + const outside = h.remount() + expect(outside.container.querySelector('[aria-current]')).toBeNull() + expect(src(outside)?.getAttribute('aria-expanded')).toBe('true') + }) + + it('keeps the scroll offset when the tree is hidden and shown again', async () => { + const h = mountNavigator(A) + await revealSrc(h) + fireEvent.scroll(h.view.container.querySelector('[data-files-body]')!, { target: { scrollTop: 64 } }) + act(() => { fireEvent.click(h.view.getByRole('button', { name: zh['navigator.hide'] })) }) + act(() => { fireEvent.click(h.view.getByRole('button', { name: zh['navigator.show'] })) }) + expect(h.view.container.querySelector('[data-files-body]')?.scrollTop).toBe(64) + }) + + it('closes the listing while no tab draws the tree, and lists the kept tree again in place', async () => { + const h = mountNavigator(A) + await revealSrc(h) + const streams = [...h.script.watches.opened] + act(() => { fireEvent.click(h.view.getByRole('button', { name: zh['navigator.hide'] })) }) + await endTask() + expect(streams.every(stream => stream.signal.aborted)).toBe(true) + act(() => { fireEvent.click(h.view.getByRole('button', { name: zh['navigator.show'] })) }) + // The kept rows draw at once while the listing reopens. + expect(h.view.container.querySelector(`[data-files-path="${ROOT}/src/b.ts"]`)).not.toBeNull() + await act(() => h.script.watches.ready(ROOT, 1)) + await act(() => h.script.settle({ ok: true, value: ROOT_LEVEL })) + await act(() => h.script.watches.ready(`${ROOT}/src`, 1)) + await act(() => h.script.settle({ ok: true, value: SRC_LEVEL })) + expect(h.script.list).toHaveBeenCalledTimes(4) + expect(h.view.container.querySelector('[aria-current="true"]')?.closest('[data-files-path]')?.getAttribute('data-files-path')) + .toBe(`${ROOT}/src/a.ts`) + }) + + it('forwards only the clicked address to its owner and offers no separate new-tab command', async () => { + const h = mountNavigator(A) + await revealSrc(h) + const row = within(h.view.container.querySelector(`[data-files-path="${ROOT}/src/b.ts"]`)!).getByRole('button', { name: 'b.ts' }) + fireEvent.click(row) + expect(h.onOpen).toHaveBeenCalledExactlyOnceWith(B) + expect(h.tabActions.openResource).not.toHaveBeenCalled() + fireEvent.contextMenu(row) + expect(h.view.queryByRole('menuitem')).toBeNull() + expect(h.onOpen).toHaveBeenCalledTimes(1) + }) + + it.each([false, true])('replaces the preview with a file not yet open, in its strip slot or its floating panel (floating: %s)', async (floating) => { + const h = mountNavigator(A) + await revealSrc(h) + const registry = new SidebarRightTabRegistry(new Context()) + registry.register({ id: 'text', kind: 'text', patterns: ['dsh-resource://file/**'], title: address => address }) + const sidebar = createSidebarRightController(registry, () => {}, { + autoFullscreen: () => false, + openWithFocus: (_session, open) => { open() }, + closeWithFocus: (_session, _pane, close) => { close() }, + }) + const store = createSidebarRightStore(() => ({ kind: 'guide', title: 'Start' })).create() + onTestFinished(sidebar.adopt(SESSION, store)) + sidebar.show(SESSION) + sidebar.controller.openResource(sessionFileAddress(SESSION, 'other.ts')) + sidebar.controller.openResource(A) + const layout = () => store.getSnapshot().bySession[SESSION]!.layout + const from = Object.values(layout().tabs).find(tab => tab.contentId === A)! + if (floating) sidebar.controller.float(from.id) + const paneOf = (tabId: string) => Object.values(layout().nodes).find(node => node.kind === 'pane' && node.tabs.includes(tabId as never)) + const host = paneOf(from.id)! + const before = floating ? undefined : host + const tabActions = sidebar.controller.tabDomain.occurrence(SESSION, { id: from.id }).tabActions + h.onOpen.mockImplementation((address) => { tabActions.openResource(address, { replaceTab: true }) }) + fireEvent.click(within(h.view.container.querySelector(`[data-files-path="${ROOT}/src/b.ts"]`)!).getByRole('button', { name: 'b.ts' })) + expect(h.onOpen).toHaveBeenLastCalledWith(B) + expect(layout().tabs[from.id]).toBeUndefined() + const replaced = Object.values(layout().tabs).find(tab => tab.contentId === B)! + const landed = paneOf(replaced.id)! + expect(landed.id).toBe(host.id) + if (before?.kind === 'pane') expect(landed.kind === 'pane' && landed.tabs.indexOf(replaced.id)).toBe(before.tabs.indexOf(from.id)) + expect(Object.keys(layout().tabs)).toHaveLength(2) + }) + + it.each([ + ['a narrow row', 399, false], + ['a wide row', 900, false], + ['a fullscreen viewport', 400, true], + ] as const)('keeps the source tree unchanged when requesting an open from %s', async (_label, width, fullscreen) => { + const h = mountNavigator(A) + h.row.width = width + await revealSrc(h) + if (fullscreen) act(() => { h.viewport.report(true) }) + const file = within(h.view.container.querySelector(`[data-files-path="${ROOT}/src/b.ts"]`)!).getByRole('button', { name: 'b.ts' }) + file.focus() + fireEvent.click(file) + expect(h.onOpen).toHaveBeenLastCalledWith(B) + expect(h.instance.getSnapshot().byPane[PANE]?.open).toBe(true) + expect(document.activeElement).toBe(file) + }) + + it('locates nothing for a file outside the workspace', async () => { + const scrolled = vi.fn() + Element.prototype.scrollIntoView = scrolled + const outside = sessionFileAddress(SESSION, '/elsewhere/a.ts') + const h = mountNavigator(outside) + act(() => { fireEvent.click(h.view.getByRole('button', { name: zh['navigator.show'] })) }) + await act(() => h.script.watches.ready(ROOT)) + await act(() => h.script.settle({ ok: true, value: ROOT_LEVEL })) + expect(h.view.container.querySelector('[aria-current]')).toBeNull() + expect(scrolled).not.toHaveBeenCalled() + expect(h.script.watches.opened).toHaveLength(1) + }) + + it('sits beside the preview from a 560px row, floats over it from a 400px row, and Escape anywhere in the row closes it, handing focus to the toggle', async () => { + const aside = (): HTMLElement | null => h.view.container.querySelector('[data-files-navigator]') + const h = mountNavigator(A) + // A 560px row leaves room for a 224px card beside a 320px body. + for (const [width, mode, drawn] of [[900, 'push', 260], [560, 'push', 224], [559, 'overlay', 260], [400, 'overlay', 260]] as const) { + h.row.width = width + act(() => { h.instance.actions.shown(PANE, true) }) + await act(() => h.script.watches.ready(ROOT)) + expect(aside()?.getAttribute('data-files-navigator-mode')).toBe(mode) + expect(aside()?.className).toMatch(mode) + expect(h.view.getByRole('separator', { name: zh['navigator.resize'] })).not.toBeNull() + expect(aside()?.style.getPropertyValue('--files-navigator-width')).toBe(`${drawn}px`) + // A modified Escape belongs to someone else. + fireEvent.keyDown(aside()!, { key: 'Escape', shiftKey: true }) + expect(aside()).not.toBeNull() + // The preview's part of the row closes it as well. + act(() => { fireEvent.keyDown(h.view.container.querySelector('[data-test-row]')!, { key: 'Escape' }) }) + expect(aside()).toBeNull() + expect(h.instance.getSnapshot().byPane[PANE]?.open).toBe(false) + expect(document.activeElement?.hasAttribute('data-files-navigator-toggle')).toBe(true) + } + }) + + it('stays shown after a click outside it and after a file opened from it', async () => { + const h = mountNavigator(A) + await revealSrc(h) + fireEvent.pointerDown(h.view.container.querySelector('[data-test-row]')!) + fireEvent.click(document.body) + fireEvent.click(within(h.view.container.querySelector(`[data-files-path="${ROOT}/src/b.ts"]`)!).getByRole('button', { name: 'b.ts' })) + expect(h.onOpen).toHaveBeenLastCalledWith(B) + expect(h.instance.getSnapshot().byPane[PANE]?.open).toBe(true) + expect(h.view.container.querySelector('[data-files-navigator]')).not.toBeNull() + }) + + it('fills a narrow row without the resize edge, and uses an overlay at 400px', async () => { + const h = mountNavigator(A) + h.row.width = 399 + await revealSrc(h) + const aside = (): HTMLElement | null => h.view.container.querySelector('[data-files-navigator]') + expect(aside()?.getAttribute('data-files-navigator-mode')).toBe('fill') + expect(h.view.queryByRole('separator')).toBeNull() + act(() => { fireEvent.click(h.view.getByRole('button', { name: zh['navigator.hide'] })) }) + h.row.width = 400 + act(() => { fireEvent.click(h.view.getByRole('button', { name: zh['navigator.show'] })) }) + expect(aside()?.getAttribute('data-files-navigator-mode')).toBe('overlay') + expect(h.view.getByRole('separator', { name: zh['navigator.resize'] })).not.toBeNull() + }) + + it.each(['row', 'viewport'] as const)('hands covered content focus to the open tree only when the %s starts filling', async (source) => { + const h = mountNavigator(A) + const removed = vi.spyOn(h.viewport, 'removeEventListener') + onTestFinished(() => { removed.mockRestore() }) + await revealSrc(h) + const row = h.view.container.querySelector('[data-test-row]')! + const content = document.createElement('div') + const button = document.createElement('button') + const frame = document.createElement('iframe') + content.append(button, frame) + row.prepend(content) + const resize = async (fills: boolean): Promise => { + act(() => { + if (source === 'row') { + h.row.report(fills ? 399 : 900) + } else { + h.viewport.report(fills) + } + }) + await endTask() + } + const navigator = h.view.container.querySelector('[data-files-navigator]')! + const current = navigator.querySelector('[aria-current="true"]')! + const focused = vi.spyOn(current, 'focus') + for (const focus of [button, frame]) { + act(() => { focus.focus() }) + focused.mockClear() + await resize(true) + expect(h.instance.getSnapshot().byPane[PANE]?.open).toBe(true) + expect(navigator.contains(document.activeElement)).toBe(true) + expect(focused).toHaveBeenCalledTimes(1) + // jsdom permits focus in covered content; another measurement is not a new transition. + act(() => { focus.focus() }) + await resize(true) + expect(document.activeElement).toBe(focus) + expect(focused).toHaveBeenCalledTimes(1) + await resize(false) + } + const outside = document.createElement('button') + document.body.append(outside) + onTestFinished(() => { outside.remove() }) + for (const focus of [navigator.querySelector('[data-files-body]')!, outside, document.body]) { + act(() => { + if (focus === document.body) outside.blur() + else focus.focus() + }) + await resize(true) + expect(h.instance.getSnapshot().byPane[PANE]?.open).toBe(true) + expect(document.activeElement).toBe(focus) + await resize(false) + } + h.view.unmount() + expect(removed).toHaveBeenCalledWith('change', expect.any(Function)) + expect(h.row.observers.size).toBe(0) + }) + + it.each(['row', 'viewport'] as const)('hands covered content that the fill blurred before the %s reported it to the open tree, and nothing the reader moved since', async (source) => { + const h = mountNavigator(A) + await revealSrc(h) + const row = h.view.container.querySelector('[data-test-row]')! + const content = document.createElement('div') + const button = document.createElement('button') + const frame = document.createElement('iframe') + content.append(button, frame) + row.prepend(content) + const outside = document.createElement('button') + document.body.append(outside) + onTestFinished(() => { outside.remove() }) + const report = (fills: boolean): void => { + if (source === 'row') { + h.row.report(fills ? 399 : 900) + } else { + h.viewport.report(fills) + } + } + const resize = async (fills: boolean): Promise => { + act(() => { report(fills) }) + await endTask() + } + // The browser applies the fill's `visibility: hidden` and blurs the focused + // content, which leaves the keyboard on the body, before the observer or + // the media query reports the new width. jsdom applies neither, so the + // spec does both in that order. + const coverAndBlur = (focus: HTMLElement): void => { + act(() => { focus.focus() }) + content.style.visibility = 'hidden' + act(() => { focus.blur() }) + expect(document.activeElement).toBe(document.body) + } + const uncover = async (): Promise => { + content.style.visibility = '' + await resize(false) + } + const navigator = h.view.container.querySelector('[data-files-navigator]')! + const current = navigator.querySelector('[aria-current="true"]')! + const focused = vi.spyOn(current, 'focus') + for (const focus of [frame, button]) { + coverAndBlur(focus) + focused.mockClear() + await resize(true) + expect(document.activeElement).toBe(current) + expect(focused).toHaveBeenCalledTimes(1) + // Another measurement at the same width is not a new transition. + act(() => { current.blur() }) + await resize(true) + expect(document.activeElement).toBe(document.body) + expect(focused).toHaveBeenCalledTimes(1) + await uncover() + } + // Keyboard the reader moved after the fill blurred the content stays where it went. + const moves: ReadonlyArray void, Element]> = [ + ['another control', () => { outside.focus() }, outside], + ['a click on nothing focusable', () => { fireEvent.pointerDown(document.body) }, document.body], + ['the window losing focus', () => { fireEvent.blur(window) }, document.body], + ] + for (const [, move, expected] of moves) { + coverAndBlur(frame) + act(move) + focused.mockClear() + await resize(true) + expect(document.activeElement).toBe(expected) + expect(focused).not.toHaveBeenCalled() + act(() => { outside.blur() }) + await uncover() + } + // A blur while the content was still shown is the reader's, not the fill's. + act(() => { button.focus() }) + act(() => { button.blur() }) + content.style.visibility = 'hidden' + await resize(true) + expect(document.activeElement).toBe(document.body) + expect(focused).not.toHaveBeenCalled() + await uncover() + // A measurement that is not a fill ends the blurred content's claim. + coverAndBlur(frame) + await resize(false) + await resize(true) + expect(document.activeElement).toBe(document.body) + expect(focused).not.toHaveBeenCalled() + await uncover() + // A reader who closes the tree before the fill is reported keeps it closed. + coverAndBlur(frame) + act(() => { + h.instance.actions.shown(PANE, false) + report(true) + }) + await endTask() + expect(h.instance.getSnapshot().byPane[PANE]?.open).toBe(false) + expect(focused).not.toHaveBeenCalled() + expect(document.activeElement).toBe(document.body) + }) + + it('releases the listeners it adds for covered content with its drawing', async () => { + // A spy on EventTarget.prototype does not see jsdom's window listeners. + const spies = [EventTarget.prototype, window].map(target => ({ + add: vi.spyOn(target, 'addEventListener'), + remove: vi.spyOn(target, 'removeEventListener'), + })) + onTestFinished(() => { + for (const spy of spies) { + spy.add.mockRestore() + spy.remove.mockRestore() + } + }) + const calls = (which: 'add' | 'remove') => spies.flatMap(spy => spy[which].mock.calls.map(([type, listener], index) => [spy[which].mock.contexts[index], type, listener] as const)) + const h = mountNavigator(A) + act(() => { h.instance.actions.shown(PANE, true) }) + await act(() => h.script.watches.ready(ROOT)) + const row = h.view.container.querySelector('[data-test-row]')! + const added = calls('add') + const watched = (target: EventTarget, type: string) => { + const listeners = added.filter(([at, name]) => at === target && name === type).map(([, , listener]) => listener) + return [...new Set(listeners)].map(listener => [target, type, listener] as const) + } + const covering = [...watched(row, 'focusout'), ...watched(document, 'focusin'), ...watched(document, 'pointerdown'), ...watched(window, 'blur')] + expect(covering).toHaveLength(4) + h.view.unmount() + const removed = calls('remove') + for (const [target, type, listener] of covering) { + expect(removed.some(([at, name, gone]) => at === target && name === type && gone === listener)).toBe(true) + } + }) + + it.each(['show', 'shortcut'] as const)('hands body focus to the tree once on its first narrow drawing through %s', async (source) => { + const h = mountNavigator(A) + h.row.width = 399 + const row = h.view.container.querySelector('[data-test-row]')! + const button = document.createElement('button') + row.prepend(button) + act(() => { button.focus() }) + // A layout read can blur a now-hidden body before returning its new width. + const measure = vi.spyOn(row, 'getBoundingClientRect').mockImplementation(() => { + button.blur() + return { x: 0, y: 0, top: 0, left: 0, bottom: 0, right: 399, width: 399, height: 0, toJSON: () => ({}) } + }) + onTestFinished(() => { measure.mockRestore() }) + act(() => { + if (source === 'shortcut') h.face.focus(PANE) + else h.instance.actions.shown(PANE, true) + }) + const tree = h.view.container.querySelector('[data-files-body]')! + const focused = vi.spyOn(tree, 'focus') + await endTask() + expect(h.instance.getSnapshot().byPane[PANE]?.open).toBe(true) + expect(document.activeElement).toBe(tree) + expect(focused).toHaveBeenCalledTimes(1) + }) + + it('moves the keyboard from the resize edge to the tree when a new row width removes or hides the edge', async () => { + const h = mountNavigator(A) + await revealSrc(h) + const resized = async (width: number): Promise => { + act(() => { h.row.report(width) }) + await endTask() + } + // Queried by attribute: the accessibility query skips the edge while it is hidden. + const edge = (): HTMLElement => h.view.container.querySelector('[data-files-navigator-resize]')! + // A width that keeps the edge drawn leaves the keyboard on it. + act(() => { edge().focus() }) + await resized(800) + expect(document.activeElement).toBe(edge()) + // A width at which the tree fills the row removes the edge. + await resized(399) + expect(h.view.queryByRole('separator')).toBeNull() + expect(focusedPath()).toBe(`${ROOT}/src/a.ts`) + // The fullscreen viewport hides the edge with CSS; jsdom applies no media query, so the style stands in. + await resized(900) + act(() => { edge().focus() }) + edge().style.display = 'none' + await resized(900) + expect(focusedPath()).toBe(`${ROOT}/src/a.ts`) + // A browser blurs an edge that CSS hides before the row reports a new width. + act(() => { edge().focus() }) + edge().style.display = 'none' + act(() => { edge().blur() }) + await endTask() + expect(focusedPath()).toBe(`${ROOT}/src/a.ts`) + // A blur that leaves the edge drawn leaves the keyboard where it went. + edge().style.display = '' + act(() => { edge().focus() }) + act(() => { edge().blur() }) + await endTask() + expect(document.activeElement).toBe(document.body) + // That blur ends the edge's hold, so a later width that removes the edge leaves the keyboard alone. + await resized(399) + expect(document.activeElement).toBe(document.body) + await resized(900) + // Keyboard moved elsewhere in the meantime stays there. + edge().style.display = '' + act(() => { edge().focus() }) + act(() => { h.row.report(399) }) + act(() => { h.view.getByRole('button', { name: zh['navigator.hide'] }).focus() }) + await endTask() + expect(document.activeElement?.hasAttribute('data-files-navigator-toggle')).toBe(true) + }) + + it('keeps the face beside its tab while any of the tab\'s drawings remains, and focuses the drawing that replaced another', async () => { + const h = mountNavigator(A) + await revealSrc(h) + expect(h.beside(TAB)).toBe(true) + // A loading drawing and its loaded successor each place the face; the first going leaves it placed. + h.remount().unmount() + expect(h.beside(TAB)).toBe(true) + h.view.unmount() + const next = h.remount() + act(() => { h.face.focus(PANE) }) + await endTask() + expect(next.container.contains(document.activeElement)).toBe(true) + next.unmount() + expect(h.beside(TAB)).toBe(false) + }) + + it('resizes from its left edge by keyboard and primary-button pointer, leaving the 900px row\'s body at least 320px', async () => { + const h = mountNavigator(A) + act(() => { h.instance.actions.shown(PANE, true) }) + await act(() => h.script.watches.ready(ROOT)) + const edge = h.view.getByRole('separator', { name: zh['navigator.resize'] }) + const width = (): number | undefined => h.instance.getSnapshot().byPane[PANE]?.width + expect(edge.getAttribute('aria-valuenow')).toBe(String(DEFAULT_NAVIGATOR_WIDTH)) + expect(edge.getAttribute('aria-valuemin')).toBe('180') + expect(edge.getAttribute('aria-valuemax')).toBe('564') + fireEvent.keyDown(edge, { key: 'ArrowLeft' }) + expect(width()).toBe(DEFAULT_NAVIGATOR_WIDTH + 20) + fireEvent.keyDown(edge, { key: 'ArrowRight' }) + fireEvent.keyDown(edge, { key: 'Enter' }) + expect(width()).toBe(DEFAULT_NAVIGATOR_WIDTH) + fireEvent.keyDown(edge, { key: 'End' }) + expect(width()).toBe(564) + fireEvent.keyDown(edge, { key: 'Home' }) + expect(width()).toBe(180) + act(() => { h.instance.actions.resized(PANE, DEFAULT_NAVIGATOR_WIDTH) }) + + const capture = vi.fn() + const release = vi.fn() + edge.setPointerCapture = capture + edge.releasePointerCapture = release + fireEvent.pointerMove(edge, { clientX: 0 }) + expect(width()).toBe(DEFAULT_NAVIGATOR_WIDTH) + // A secondary press does not start a drag. + fireEvent.pointerDown(edge, { clientX: 500, pointerId: 1, button: 2 }) + fireEvent.pointerMove(edge, { clientX: 400 }) + expect(capture).not.toHaveBeenCalled() + expect(width()).toBe(DEFAULT_NAVIGATOR_WIDTH) + fireEvent.pointerDown(edge, { clientX: 500, pointerId: 1, button: 0 }) + expect(h.view.container.querySelector('[data-files-navigator]')?.className).toMatch(/dragging/) + fireEvent.pointerMove(edge, { clientX: 400 }) + expect(width()).toBe(DEFAULT_NAVIGATOR_WIDTH + 100) + fireEvent.pointerMove(edge, { clientX: -1000 }) + expect(width()).toBe(564) + fireEvent.pointerUp(edge, { pointerId: 1 }) + expect(release).toHaveBeenCalledWith(1) + fireEvent(edge, new Event('lostpointercapture', { bubbles: true })) + expect(h.view.container.querySelector('[data-files-navigator]')?.className).not.toMatch(/dragging/) + }) + + it('carries the tree\'s keyboard focus to the pane\'s next drawing of it in the same task only', async () => { + const h = mountNavigator(A) + await revealSrc(h) + act(() => { within(h.view.container.querySelector(`[data-files-path="${ROOT}/src/b.ts"]`)!).getByRole('button', { name: 'b.ts' }).focus() }) + h.view.unmount() + h.placement.contentId = B + const back = h.remount() + await endTask() + expect(focusedPath()).toBe(`${ROOT}/src/b.ts`) + // A drawing that comes back after the task ended, as after a tab without the tree, leaves the keyboard alone. + back.unmount() + await endTask() + h.remount() + await endTask() + expect(document.activeElement).toBe(document.body) + }) + + it('gives the carried focus to the drawing in place when the task ends, after the tab draws the tree again', async () => { + const h = mountNavigator(A) + await revealSrc(h) + act(() => { within(h.view.container.querySelector(`[data-files-path="${ROOT}/src/a.ts"]`)!).getByRole('button', { name: 'a.ts' }).focus() }) + h.view.unmount() + // A loading preview draws the tree, and its loaded state draws it again in the same task. + h.remount().unmount() + const loaded = h.remount() + await endTask() + expect(focusedPath()).toBe(`${ROOT}/src/a.ts`) + expect(loaded.container.contains(document.activeElement)).toBe(true) + // A drawing gone before the task ends takes nothing. + act(() => { (document.activeElement as HTMLElement).blur() }) + act(() => { h.face.focus(PANE) }) + loaded.unmount() + await endTask() + expect(document.activeElement).toBe(document.body) + }) + + it('takes keyboard focus when asked, at its next drawing or at once', async () => { + const h = mountNavigator('sidebar://files') + act(() => { h.face.focus(PANE) }) + await act(() => h.script.watches.ready(ROOT)) + expect(document.activeElement?.hasAttribute('data-files-body')).toBe(true) + act(() => { (document.activeElement as HTMLElement).blur() }) + act(() => { h.face.focus(PANE) }) + await endTask() + expect(document.activeElement?.hasAttribute('data-files-body')).toBe(true) + }) + + it('keeps the pane\'s tree when a preview is replaced in place by the file it opened', async () => { + const h = mountNavigator(A) + await revealSrc(h) + expect(h.beside(TAB)).toBe(true) + act(() => { h.instance.actions.resized(PANE, 300) }) + fireEvent.scroll(h.view.container.querySelector('[data-files-body]')!, { target: { scrollTop: 64 } }) + fireEvent.click(within(h.view.container.querySelector(`[data-files-path="${ROOT}/src/b.ts"]`)!).getByRole('button', { name: 'b.ts' })) + expect(h.onOpen).toHaveBeenLastCalledWith(B) + // The new tab draws in the same pane, in the task the opener's tab closes in. + const next = new AbortController() + onTestFinished(() => { next.abort() }) + h.view.unmount() + h.controller.abort() + Object.assign(h.placement, { tabId: 'tab-2', signal: next.signal, contentId: B }) + const back = h.remount() + await endTask() + expect(back.container.querySelector('[data-files-navigator]')?.style.getPropertyValue('--files-navigator-width')).toBe('300px') + expect(back.container.querySelector('[data-files-body]')?.scrollTop).toBe(64) + expect(back.container.querySelector('[aria-current="true"]')?.closest('[data-files-path]')?.getAttribute('data-files-path')) + .toBe(`${ROOT}/src/b.ts`) + expect(h.script.list).toHaveBeenCalledTimes(2) + expect(h.beside(TAB)).toBe(false) + }) + + it('leaves an Escape to a handler in the row that kept it, and to anything outside the row', async () => { + const h = mountNavigator(A) + act(() => { h.instance.actions.shown(PANE, true) }) + await act(() => h.script.watches.ready(ROOT)) + const aside = h.view.container.querySelector('[data-files-navigator]')! + const keep = (event: KeyboardEvent): void => { event.preventDefault() } + aside.addEventListener('keydown', keep) + fireEvent.keyDown(aside, { key: 'Escape' }) + aside.removeEventListener('keydown', keep) + fireEvent.keyDown(document.body, { key: 'Escape' }) + expect(h.view.container.querySelector('[data-files-navigator]')).not.toBeNull() + expect(h.instance.getSnapshot().byPane[PANE]?.open).toBe(true) + }) + + it('lists files no tab type can open without opening them, and opens them once one can', async () => { + const h = mountNavigator(A) + await revealSrc(h) + act(() => { h.viewers.set(false) }) + const row = (): HTMLElement => h.view.container.querySelector(`[data-files-path="${ROOT}/src/b.ts"]`)! + expect(row().querySelector('button')).toBeNull() + expect(row().querySelector('[aria-disabled="true"]')?.getAttribute('title')).toBe(zh['entry.noViewer']) + // Directories still open. + expect(within(h.view.container.querySelector(`[data-files-path="${ROOT}/src"]`)!).getByRole('button', { name: 'src' })).not.toBeNull() + act(() => { h.viewers.set(true) }) + fireEvent.click(row().querySelector('button')!) + expect(h.onOpen).toHaveBeenLastCalledWith(B) + }) + + it('waits for a kept tree\'s fresh listing before finishing a reveal, so a directory created while the tree was hidden opens to the file', async () => { + const scrolled = vi.fn() + Element.prototype.scrollIntoView = scrolled + const h = mountNavigator(A) + await revealSrc(h) + act(() => { fireEvent.click(h.view.getByRole('button', { name: zh['navigator.hide'] })) }) + await endTask() + h.view.unmount() + h.placement.contentId = sessionFileAddress(SESSION, 'lib/c.ts') + const back = h.remount() + act(() => { fireEvent.click(back.getByRole('button', { name: zh['navigator.show'] })) }) + // The kept root level lacks `lib` until it is listed again. + expect(h.instance.getSnapshot().byPane[PANE]?.tree?.levels[ROOT]).toMatchObject({ kind: 'ready', refreshing: true }) + expect(back.container.querySelector(`[data-files-path="${ROOT}/lib"]`)).toBeNull() + await act(() => h.script.watches.ready(ROOT, 1)) + await act(() => h.script.settle({ ok: true, value: { entries: [{ name: 'lib', type: 'directory' }, ...ROOT_LEVEL.entries], truncated: false } })) + expect(within(back.container.querySelector(`[data-files-path="${ROOT}/lib"]`)!).getByRole('button', { name: 'lib' })?.getAttribute('aria-expanded')).toBe('true') + await act(() => h.script.watches.ready(`${ROOT}/lib`)) + await act(() => h.script.settle({ ok: true, value: { entries: [{ name: 'c.ts', type: 'file', size: 1 }], truncated: false } })) + const current = back.container.querySelector('[aria-current="true"]') + expect(current?.closest('[data-files-path]')?.getAttribute('data-files-path')).toBe(`${ROOT}/lib/c.ts`) + expect(scrolled.mock.contexts.at(-1)).toBe(current) + await act(() => h.script.watches.ready(`${ROOT}/src`, 1)) + await act(() => h.script.settle({ ok: true, value: SRC_LEVEL })) + expect(h.instance.getSnapshot().byPane[PANE]?.tree?.levels[`${ROOT}/src`]).toEqual({ kind: 'ready', level: SRC_LEVEL }) + }) + + it('finishes a reveal on the kept listings below a directory that fails to list again, so its ancestors stay collapsible', async () => { + const h = mountNavigator(A) + await revealSrc(h) + act(() => { fireEvent.click(h.view.getByRole('button', { name: zh['navigator.hide'] })) }) + await endTask() + h.view.unmount() + h.placement.contentId = sessionFileAddress(SESSION, 'src/new.ts') + const back = h.remount() + act(() => { fireEvent.click(back.getByRole('button', { name: zh['navigator.show'] })) }) + expect(h.instance.getSnapshot().byPane[PANE]?.tree?.levels[`${ROOT}/src`]).toMatchObject({ refreshing: true }) + await act(() => h.script.watches.ready(ROOT, 1)) + await act(() => h.script.settle({ ok: false, error: new RemoteError('workspace-file/not-found', 'gone', { path: ROOT }) })) + expect(h.instance.getSnapshot().byPane[PANE]?.tree?.levels[`${ROOT}/src`]).toEqual({ kind: 'ready', level: SRC_LEVEL }) + const src = (): HTMLElement => within(back.container.querySelector(`[data-files-path="${ROOT}/src"]`)!).getByRole('button', { name: 'src' }) + act(() => { fireEvent.click(src()) }) + expect(src().getAttribute('aria-expanded')).toBe('false') + expect(back.container.querySelector('[aria-current="true"]')).toBeNull() + }) + + it('places the face beside its tab only with a workspace, and a tree shown once one appears takes no focus', async () => { + const h = mountNavigator(A, {}, null) + expect(h.beside(TAB)).toBe(false) + h.placement.cwd = ROOT + h.rerender() + expect(h.beside(TAB)).toBe(true) + act(() => { h.instance.actions.shown(PANE, true) }) + await act(() => h.script.watches.ready(ROOT)) + await endTask() + expect(h.view.container.querySelector('[data-files-navigator]')).not.toBeNull() + expect(document.activeElement).toBe(document.body) + h.placement.cwd = null + h.rerender() + expect(h.beside(TAB)).toBe(false) + expect(h.instance.getSnapshot().byPane[PANE]?.open).toBe(true) + }) + + it('draws neither control without a workspace', () => { + const h = mountNavigator(A, {}, null) + act(() => { h.instance.actions.shown(PANE, true) }) + expect(h.view.container.querySelector('[data-files-navigator-toggle]')).toBeNull() + expect(h.view.container.querySelector('[data-files-navigator]')).toBeNull() + expect(h.script.watches.opened).toEqual([]) + }) +}) + +describe('navigatorGeometry', () => { + it('beside the body from a 560px row, leaves its 16px of card margins and 320px of body', () => { + expect(navigatorGeometry(560, 260)).toEqual({ minimum: 180, maximum: 224, width: 224 }) + expect(navigatorGeometry(800, 260)).toEqual({ minimum: 180, maximum: 464, width: 260 }) + expect(navigatorGeometry(1200, 260)).toEqual({ minimum: 180, maximum: 864, width: 260 }) + expect(navigatorGeometry(900, 100)).toMatchObject({ width: 180 }) + // A preference wider than the row allows draws at the bound, so dragging never narrows the body past 320px. + for (const row of [560, 800, 1200]) expect(row - 16 - navigatorGeometry(row, 10_000).width).toBe(320) + }) + + it('floating over the body below a 560px row, leaves 120px of it uncovered', () => { + expect(navigatorGeometry(559, 600)).toEqual({ minimum: 180, maximum: 439, width: 439 }) + expect(navigatorGeometry(400, 260)).toEqual({ minimum: 180, maximum: 280, width: 260 }) + expect(navigatorGeometry(399, 260)).toEqual({ minimum: 180, maximum: 279, width: 260 }) + expect(navigatorGeometry(391, 260)).toEqual({ minimum: 180, maximum: 271, width: 260 }) + expect(navigatorGeometry(250, 260)).toEqual({ minimum: 130, maximum: 130, width: 130 }) + expect(navigatorGeometry(100, 260)).toEqual({ minimum: 0, maximum: 0, width: 0 }) + }) + + it('draws the preference beside an unmeasured row', () => { + expect(navigatorGeometry(undefined, 260)).toEqual({ minimum: 180, maximum: 260, width: 260 }) + expect(navigatorGeometry(undefined, 100)).toEqual({ minimum: 180, maximum: 180, width: 180 }) + }) +}) + +describe('relativeFileOf', () => { + it('reads this Session\'s files under the root, by relative or contained absolute path', () => { + expect(relativeFileOf(sessionFileAddress(SESSION, 'src/a.ts'), SESSION, ROOT)).toBe('src/a.ts') + expect(relativeFileOf(sessionFileAddress(SESSION, `${ROOT}/src/a.ts`), SESSION, ROOT)).toBe('src/a.ts') + expect(relativeFileOf(sessionFileAddress(SESSION, 'C:/work/app/src/a.ts'), SESSION, 'C:\\work\\app')).toBe('src/a.ts') + }) + + it('contains a Windows drive or UNC path regardless of case and separators, and a POSIX path only in the root\'s case', () => { + expect(relativeFileOf(sessionFileAddress(SESSION, 'c:/work/app/src/a.ts'), SESSION, 'C:\\Work\\App')).toBe('src/a.ts') + expect(relativeFileOf(sessionFileAddress(SESSION, 'C:\\WORK\\APP\\src\\a.ts'), SESSION, 'c:/work/app/')).toBe('src/a.ts') + expect(relativeFileOf(sessionFileAddress(SESSION, '\\\\Host\\Share\\src\\a.ts'), SESSION, '\\\\host\\share')).toBe('src/a.ts') + expect(relativeFileOf(sessionFileAddress(SESSION, '//host/share/src/a.ts'), SESSION, '\\\\HOST\\SHARE')).toBe('src/a.ts') + expect(relativeFileOf(sessionFileAddress(SESSION, '/Work/App/src/a.ts'), SESSION, '/work/app')).toBeUndefined() + }) + + it('reads nothing for another Session, another scope, a path outside the root, or dot segments', () => { + expect(relativeFileOf(sessionFileAddress('s-other', 'src/a.ts'), SESSION, ROOT)).toBeUndefined() + expect(relativeFileOf('sidebar://files', SESSION, ROOT)).toBeUndefined() + expect(relativeFileOf(sessionFileAddress(SESSION, '/elsewhere/a.ts'), SESSION, ROOT)).toBeUndefined() + expect(relativeFileOf(sessionFileAddress(SESSION, ''), SESSION, ROOT)).toBeUndefined() + expect(relativeFileOf(sessionFileAddress(SESSION, 'src/../a.ts'), SESSION, ROOT)).toBeUndefined() + }) +}) + +describe('locateFile', () => { + const tree = (root: string, levels: FilesTreeState['levels']): FilesTreeState => ({ root, levels, expanded: [root], autoRefresh: true }) + + it('walks listed levels to the file and waits on a level still listing', () => { + expect(locateFile(tree(ROOT, {}), 'src/a.ts')).toEqual({ ancestors: [], current: undefined, pending: true }) + const listed = tree(ROOT, { [ROOT]: { kind: 'ready', level: ROOT_LEVEL }, [`${ROOT}/src`]: { kind: 'loading' } }) + expect(locateFile(listed, 'src/a.ts')).toEqual({ ancestors: [{ parent: ROOT, path: `${ROOT}/src` }], current: undefined, pending: true }) + const full = tree(ROOT, { [ROOT]: { kind: 'ready', level: ROOT_LEVEL }, [`${ROOT}/src`]: { kind: 'ready', level: SRC_LEVEL } }) + expect(locateFile(full, 'src/a.ts')).toEqual({ ancestors: [{ parent: ROOT, path: `${ROOT}/src` }], current: `${ROOT}/src/a.ts`, pending: false }) + }) + + it('stops at a failed level or a missing name, and matches case only under a Windows root', () => { + const failed = tree(ROOT, { [ROOT]: { kind: 'failed', failure: { code: 'workspace-file/not-found', message: 'gone' } as never } }) + expect(locateFile(failed, 'src/a.ts')).toEqual({ ancestors: [], current: undefined, pending: false }) + const listed = tree(ROOT, { [ROOT]: { kind: 'ready', level: ROOT_LEVEL } }) + expect(locateFile(listed, 'readme.md')).toEqual({ ancestors: [], current: undefined, pending: false }) + // A level listed again may still gain the name. + const refreshing = tree(ROOT, { [ROOT]: { kind: 'ready', level: ROOT_LEVEL, refreshing: true } }) + expect(locateFile(refreshing, 'readme.md')).toEqual({ ancestors: [], current: undefined, pending: true }) + expect(locateFile(refreshing, 'README.md')).toEqual({ ancestors: [], current: `${ROOT}/README.md`, pending: false }) + const windows = tree('C:\\work', { 'C:\\work': { kind: 'ready', level: ROOT_LEVEL } }) + expect(locateFile(windows, 'readme.md').current).toBe('C:\\work/README.md') + const unc = tree('\\\\host\\share', { '\\\\host\\share': { kind: 'ready', level: ROOT_LEVEL } }) + expect(locateFile(unc, 'readme.md').current).toBe('\\\\host\\share/README.md') + // A UNC root spelled with forward slashes is a Windows root too, as containment reads it. + const slashed = tree('//host/share', { '//host/share': { kind: 'ready', level: ROOT_LEVEL } }) + expect(locateFile(slashed, 'readme.md').current).toBe('//host/share/README.md') + const drive = tree('c:/work', { 'c:/work': { kind: 'ready', level: ROOT_LEVEL } }) + expect(locateFile(drive, 'readme.md').current).toBe('c:/work/README.md') + }) +}) diff --git a/packages/client/ui-sidebar-files/tests/mount.client.tsx b/packages/client/ui-sidebar-files/tests/mount.client.tsx index d891a39c4e..35a660f264 100644 --- a/packages/client/ui-sidebar-files/tests/mount.client.tsx +++ b/packages/client/ui-sidebar-files/tests/mount.client.tsx @@ -1,11 +1,13 @@ /** - * Mount the body over a real store instance and a scripted listing. + * Mount the files page or a preview's navigator over a real store instance, + * a real face, and a scripted listing. * - * The component reads a handful of its props; the rest of the standard kit is - * framework-injected and never touched here, so one documented cast keeps the - * harness to what is actually exercised. + * Unused Factory framework props throw if called. The row the navigator + * measures reports a width the spec sets, because jsdom lays nothing out. */ import { useSyncExternalStore } from 'react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { ReactNode } from 'react' import { render } from '@testing-library/react' import type { RenderResult } from '@testing-library/react' import { onTestFinished, vi } from 'vitest' @@ -13,20 +15,27 @@ import type { Mock } from 'vitest' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import { contextMenuHook, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { SidebarRightTabActions } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import type { SidebarRightTabActions, SidebarRightTabInfo } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import { TreeFixture } from './tree-fixture.client.tsx' +import type { FileTreeInput } from '../src/client/FileTree.tsx' +import type { FactoryComponentPropsOf } from '@deepseek-ai/dsh-client-ui-slots' +import type { PaneId, TabId } from '@deepseek-ai/dsh-client-ui-dockkit' import { filesFace } from '../src/client/face.ts' import type { FilesInjected } from '../src/client/face.ts' +import { FileNavigator } from '../src/client/FileNavigator.tsx' import { FilesBody } from '../src/client/FilesBody.tsx' import type { FilesBodyProps } from '../src/client/FilesBody.tsx' +import { NavigatorToggle } from '../src/client/NavigatorToggle.tsx' import { zh } from '../src/client/locales.ts' import { createFilesStore } from '../src/client/store.ts' +import type { FilesStoreInstance } from '../src/client/store.ts' import { scriptedList } from './scripted-list.client.ts' import type { ScriptedList } from './scripted-list.client.ts' -import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' export const SESSION = 's-test' as SessionId export const ROOT = '/work/app' export const TAB = 'tab-1' as TabId +export const PANE = 'pane-1' as PaneId /** Test-local selector hook over a framework-neutral store instance. */ function hookOf(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) { @@ -35,9 +44,6 @@ function hookOf(inst: { subscribe: (fn: () => void) => () => void; getSnapsho } } -/** A live instance of the tree's store, as the framework would mint one per session. */ -type FilesStoreInstance = ReturnType['create']> - /** The owner's tab actions as recording mocks. */ interface MockedTabActions { readonly bindCommands: Mock @@ -46,6 +52,19 @@ interface MockedTabActions { readonly close: Mock } +/** What the tab and its pane look like to the components; a spec may change it and rerender. */ +export interface Placement { + contentId: string + isFloating?: boolean + /** The tab drawing; a spec changes it, with `signal`, to replace the tab in the same pane. */ + tabId: TabId + /** The drawing tab's lifetime. */ + signal: AbortSignal + refreshShortcut?: SidebarRightTabInfo['tab']['refreshShortcut'] + /** The session's working directory as `useSessions` reports it; `null` for a session without one. */ + cwd: string | null +} + /** What a spec holds after mounting: the rendered view and every hand on the tree. */ export interface Mounted { readonly shared: ReturnType['shared'] @@ -53,18 +72,54 @@ export interface Mounted { readonly instance: FilesStoreInstance readonly script: ScriptedList readonly face: FilesInjected + /** The first tab's lifetime. */ readonly controller: AbortController + /** Whether a registered tab type opens files; `set` it and every drawing hears it. */ + readonly viewers: { readonly set: (open: boolean) => void } readonly tabActions: MockedTabActions - /** Render a fresh body over the same store and face, as a tab switch remounts it. */ + readonly onOpen: Mock<(address: string) => void> + /** The tab's address and the pane's presentation, read on every render. */ + readonly placement: Placement + /** The width the navigator's row reports. */ + readonly row: ReturnType['row'] + /** The fullscreen media query, notified independently of row measurements. */ + readonly viewport: ReturnType['viewport'] + /** Whether a navigator that can draw a tree has the face placed beside a tab's content. */ + readonly beside: (tabId: TabId) => boolean + /** Render again in place, after changing `placement` or `row`. */ + readonly rerender: () => void + /** Render a fresh drawing over the same store and face, as a tab switch remounts it. */ readonly remount: () => RenderResult } /** One store instance, one face, one owner share. */ -function harness(cwd: string | null, refreshShortcut?: ReturnType['tab']['refreshShortcut']) { +function harness(placement: Placement, controller: AbortController) { const instance = createFilesStore().create() const script = scriptedList() - const face = filesFace(script.list, script.watch)(SESSION, instance.actions) - const controller = new AbortController() + // Whether some tab type opens files: one predicate per answer, as registrations change it. + const listeners = new Set<() => void>() + let check = (_address: string): boolean => true + const viewers = { + set: (open: boolean): void => { + check = () => open + for (const listener of listeners) listener() + }, + } + const canOpen = { + getSnapshot: () => check, + subscribe: (listener: () => void) => { + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + } + // The owner's count of drawings that placed the face beside each tab. + const placed = new Map() + const navigation = createSnapshotStore({ address: placement.contentId, params: undefined, revision: 0 }) + const face = filesFace(script.list, script.watch, canOpen, () => navigation)(SESSION, instance, (tabId) => { + placed.set(tabId, (placed.get(tabId) ?? 0) + 1) + return () => { placed.set(tabId, placed.get(tabId)! - 1) } + }) + const beside = (tabId: TabId): boolean => (placed.get(tabId) ?? 0) > 0 onTestFinished(async () => { controller.abort() await script.dispose() @@ -75,40 +130,205 @@ function harness(cwd: string | null, refreshShortcut?: ReturnType(), close: vi.fn(), } - const sessions = { byId: cwd === null ? {} : { [SESSION]: { cwd } } } as unknown as SessionListState + const sessionsAt = (cwd: string | null): SessionListState => + ({ byId: cwd === null ? {} : { [SESSION]: { cwd } } } as unknown as SessionListState) + const useTabInfo = (): SidebarRightTabInfo => ({ + sidebar: { expanded: true, fullscreen: false }, + panel: { id: PANE, isFloating: placement.isFloating ?? false }, + tab: { + id: placement.tabId, kind: 'files', contentId: placement.contentId, title: zh['type.label'], visible: true, + navigation: { address: placement.contentId, params: undefined, revision: 1 }, + signal: placement.signal, + actions: tabActions, refreshShortcut: placement.refreshShortcut, + }, + }) + const { hooks, keyedHooks, ...members } = face + const t = makeTranslate(zh) + const onOpen = vi.fn<(address: string) => void>() const openMenu = vi.fn() const shared = { - // A page tab's address is the shell's to mint; the body never reads it. - useTabInfo: () => ({ - sidebar: { expanded: true, fullscreen: false }, - panel: { id: 'pane-1' }, - tab: { - id: TAB, kind: 'files', contentId: 'files', title: zh['type.label'], visible: true, - navigation: { address: 'files', params: undefined, revision: 1 }, - signal: controller.signal, - actions: tabActions, refreshShortcut, - }, - }), + onOpen, + useTabInfo, renderSlot: () => null, openMenu, useContextMenu: contextMenuHook(openMenu), + renderFactorySlot: (name: string, input: object) => name === 'sidebar.files.tree' ? : null, sessionId: SESSION, - useSessions: (sel: (s: SessionListState) => S) => sel(sessions), + useSessions: (sel: (s: SessionListState) => S) => sel(sessionsAt(placement.cwd)), useStore: hookOf(instance), actions: instance.actions, - ...face, - t: makeTranslate(zh), + ...members, + useRowWidth: (paneId: string) => { + const source = keyedHooks.rowWidth(paneId) + return useSyncExternalStore(listener => source.subscribe(listener), () => source.getSnapshot()) + }, + useCanOpen: (sel: (check: (address: string) => boolean) => S) => + sel(useSyncExternalStore(listener => hooks.canOpen.subscribe(listener), () => hooks.canOpen.getSnapshot())), + t, } - return { instance, script, face, controller, tabActions, shared } + return { instance, script, face, controller, viewers, tabActions, onOpen, beside, shared } } /** - * Mount the body. - * @param cwd - the session's working directory as `useSessions` reports it; `null` for a session without one. - * @param refreshShortcut - effective binding advertised by the tab owner. + * Report the spec's row width for the rows a navigator measures: the + * preview's row this harness draws, and the files page's content row. + * @param recordObservers - capture navigator notifications; page specs may provide their own observers. + * @returns independently controlled row measurements and fullscreen media changes. */ -export function mountBody(cwd: string | null = ROOT, refreshShortcut?: ReturnType['tab']['refreshShortcut']): Mounted { - const { shared, ...hands } = harness(cwd, refreshShortcut) - const view = render() - return { ...hands, shared, view, remount: () => render() } +function measureRows(recordObservers: boolean) { + const callbacks = new Set<() => void>() + class RecordingResizeObserver implements ResizeObserver { + private readonly report: () => void + constructor(callback: ResizeObserverCallback) { + this.report = () => { callback([], this) } + callbacks.add(this.report) + } + observe(): void {} + unobserve(): void {} + disconnect(): void { callbacks.delete(this.report) } + } + const row = { + width: 900, + observers: callbacks, + report(width: number): void { + row.width = width + for (const callback of callbacks) callback() + }, + } + const viewport = Object.assign(new EventTarget(), { + matches: false, + report(matches: boolean): void { + viewport.matches = matches + viewport.dispatchEvent(new Event('change')) + }, + }) + if (recordObservers) { + vi.stubGlobal('ResizeObserver', RecordingResizeObserver) + vi.stubGlobal('matchMedia', () => viewport) + onTestFinished(() => { vi.unstubAllGlobals() }) + } + // jsdom lays nothing out, so every other box is empty as it would be anyway. + const spy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + const width = this.matches('[data-test-row], [data-files-content]') ? row.width : 0 + return { x: 0, y: 0, top: 0, left: 0, bottom: 0, right: width, width, height: 0, toJSON: () => ({}) } + }) + onTestFinished(() => { spy.mockRestore() }) + return { row, viewport } +} + +function unusedFrameworkProp(): never { + throw new Error('Unexpected Factory framework prop call') +} + +const unusedFactoryProps = { + useFactorySlot: unusedFrameworkProp, + useSession: unusedFrameworkProp, + useProjection: unusedFrameworkProp, + useAccess: unusedFrameworkProp, + useSessionStatus: unusedFrameworkProp, + useSessionRetainInfo: unusedFrameworkProp, + useSessionAccess: unusedFrameworkProp, + useWorkspaces: unusedFrameworkProp, + useWorkspaceAccess: unusedFrameworkProp, + useRemoteAccess: unusedFrameworkProp, + usePanelInfo: unusedFrameworkProp, + useResource: unusedFrameworkProp, + useConversation: unusedFrameworkProp, + useInput: unusedFrameworkProp, + useChat: unusedFrameworkProp, + useTrajectory: unusedFrameworkProp, + inputActions: { + insertQuote: unusedFrameworkProp, + captureInsertion: unusedFrameworkProp, + insertText: unusedFrameworkProp, + setDraft: unusedFrameworkProp, + persistDraft: unusedFrameworkProp, + addAttachments: unusedFrameworkProp, + removeAttachment: unusedFrameworkProp, + pruneAttachments: unusedFrameworkProp, + submit: unusedFrameworkProp, + }, +} + +type NavigatorProps = FactoryComponentPropsOf<'sidebar.files.navigator'> + +/** + * Complete the shared Factory props without the occurrence's `page` input. + * @param shared - the harness's typed data, hooks, actions, and copy. + * @returns props shared by the navigator and its toggle. + */ +function factoryProps( + shared: Omit, +) { + return { ...unusedFactoryProps, ...shared } satisfies Omit +} + +/** + * The framework-injected kit the files page receives, of which it reads only + * the part `harness` builds. + * @param shared - the harness props with the page's Factory renderer. + * @returns the page's declared props. + */ +function bodyProps(shared: object): FilesBodyProps { + return shared as unknown as FilesBodyProps +} + +/** + * Mount one drawing and wire its rerender and remount. + * @param placement - the tab's address, the pane's presentation, and the session's working directory. + * @param draw - the drawing over the shared props. + * @param recordObservers - capture navigator measurement notifications. + * @returns the mounted hands. + */ +function mount( + given: Omit, + draw: (shared: ReturnType['shared']) => ReactNode, + recordObservers = false, +): Mounted { + const controller = new AbortController() + const placement: Placement = { ...given, tabId: TAB, signal: controller.signal } + const { shared, ...hands } = harness(placement, controller) + const { row, viewport } = measureRows(recordObservers) + const element = (): ReactNode =>
    {draw(shared)}
    + const view = render(element()) + return { + ...hands, shared, view, placement, row, viewport, + rerender: () => { view.rerender(element()) }, + remount: () => render(element()), + } +} + +/** + * Mount the `files` page, whose Factory slot draws the real navigator. + * @param cwd - the session's working directory; `null` for a session without one. + * @param placement - the pane's presentation and the tab's refresh binding. + * @returns the mounted hands. + */ +export function mountBody(cwd: string | null = ROOT, placement: Partial = {}): Mounted { + return mount({ contentId: 'sidebar://files', ...placement, cwd }, (shared) => { + // The Factory occurrence receives the same store and face as the page. + const renderFactorySlot = (name: string, input: Pick): ReactNode => name === 'sidebar.files.navigator' + ? + : null + return + }) +} + +/** + * Mount a preview's navigator and its toggle, as the document preview renders their Factories. + * @param contentId - the previewed file's address. + * @param placement - the pane's presentation. + * @param cwd - the session's working directory; `null` for a session without one. + * @returns the mounted hands. + */ +export function mountNavigator(contentId: string, placement: Partial = {}, cwd: string | null = ROOT): Mounted { + return mount({ contentId, ...placement, cwd }, shared => ( + <> + + {/* The slot renderer wraps an occurrence without a box of its own. */} +
    + +
    + + ), true) } diff --git a/packages/client/ui-sidebar-files/tests/navigation.client.spec.tsx b/packages/client/ui-sidebar-files/tests/navigation.client.spec.tsx new file mode 100644 index 0000000000..7aee76c1fc --- /dev/null +++ b/packages/client/ui-sidebar-files/tests/navigation.client.spec.tsx @@ -0,0 +1,354 @@ +// @vitest-environment jsdom +/** Target-tab navigation through the real files face, store, and snapshot sources. */ +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { PaneId, TabId } from '@deepseek-ai/dsh-client-ui-dockkit' +import type { SidebarRightTabNavigation } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' +import { filesFace } from '../src/client/face.ts' +import type { ListWorkspaceDirectory, WatchWorkspaceDirectory } from '../src/client/face.ts' +import { createFilesStore } from '../src/client/store.ts' + +const SESSION = 'navigation-session' as SessionId +const LEFT = 'pane-left' as PaneId +const RIGHT = 'pane-right' as PaneId +const A = 'tab-a' as TabId +const B = 'tab-b' as TabId + +interface TestTab { + readonly id: TabId + readonly address: string + readonly controller: AbortController + readonly navigation: SnapshotStore + readonly listeners: Set<() => void> +} + +/** Own DOM, browser measurement substitutes, and tab lifetimes within one test. */ +function harness() { + const instance = createFilesStore().create(SESSION) + const tabs = new Map() + const releases: (() => void)[] = [] + const drawings: (() => void)[] = [] + const observers = new Set() + class MeasuredRow implements ResizeObserver { + readonly targets = new Set() + constructor(private readonly callback: ResizeObserverCallback) {} + observe(target: Element): void { this.targets.add(target); observers.add(this) } + unobserve(target: Element): void { this.targets.delete(target) } + disconnect(): void { this.targets.clear(); observers.delete(this) } + measure(target: Element): void { if (this.targets.has(target)) this.callback([], this) } + } + const viewport = Object.assign(new EventTarget(), { matches: false }) + vi.stubGlobal('ResizeObserver', MeasuredRow) + vi.stubGlobal('matchMedia', () => viewport) + const root = document.createElement('div') + document.body.append(root) + onTestFinished(async () => { + try { + for (const release of releases) release() + for (const remove of drawings) remove() + for (const tab of tabs.values()) tab.controller.abort() + root.remove() + // The face settles forgotten panes and focus handoffs at the task boundary. + await Promise.resolve() + } finally { + vi.restoreAllMocks() + vi.unstubAllGlobals() + } + }) + const list = vi.fn(() => { throw new Error('Navigation must not list directories') }) + const watch = vi.fn(() => { throw new Error('Navigation must not open directory watches') }) + const canOpen = createSnapshotStore((_address: string) => true) + const face = filesFace(list, watch, canOpen, (sessionId, tabId) => { + expect(sessionId).toBe(SESSION) + const tab = tabs.get(tabId) + if (tab === undefined) throw new Error(`Unknown test tab: ${tabId}`) + return { + getSnapshot: () => tab.navigation.getSnapshot(), + subscribe: (listener) => { + tab.listeners.add(listener) + const unsubscribe = tab.navigation.subscribe(listener) + return () => { unsubscribe(); tab.listeners.delete(listener) } + }, + } + })(SESSION, instance, () => () => {}) + + const tab = (id: TabId, revision = 0): TestTab => { + const address = sessionFileAddress(SESSION, `${id}.ts`) + const value: TestTab = { + id, address, controller: new AbortController(), listeners: new Set(), + navigation: createSnapshotStore({ address, params: undefined, revision }), + } + tabs.set(id, value) + return value + } + const attach = (paneId: PaneId, tab: TestTab): (() => void) => { + const release = face.attach(paneId, tab.id, tab.controller.signal) + releases.push(release) + return release + } + const draw = (paneId: PaneId, tab: TestTab, width: number, options: { closable?: boolean; focusContent?: boolean } = {}) => { + const shell = document.createElement('section') + const toggle = document.createElement('button') + toggle.textContent = `Toggle ${paneId}` + const row = document.createElement('div') + const content = document.createElement('button') + content.textContent = `Content ${tab.id}` + const navigator = document.createElement('aside') + const body = document.createElement('div') + body.tabIndex = -1 + const file = document.createElement('button') + file.textContent = tab.address + body.append(file) + navigator.append(body) + row.append(content, navigator) + shell.append(toggle, row) + root.append(shell) + let currentWidth = width + vi.spyOn(row, 'getBoundingClientRect').mockImplementation(() => new DOMRect(0, 0, currentWidth, 300)) + if (options.focusContent) content.focus() + const closable = options.closable ?? true + // Child refs run before the navigator ref; the attachment effect follows both. + face.toggleDrawn(paneId, toggle) + face.bodyDrawn(paneId, tab.address, body) + face.navigatorDrawn(paneId, navigator, closable) + let removed = false + const remove = (): void => { + if (removed) return + removed = true + face.navigatorDrawn(paneId, null, closable) + face.bodyDrawn(paneId, tab.address, null) + face.toggleDrawn(paneId, null) + shell.remove() + } + drawings.push(remove) + return { + row, navigator, body, file, content, toggle, remove, + resize: (next: number): void => { + currentWidth = next + for (const observer of [...observers]) observer.measure(row) + }, + } + } + return { + instance, face, viewport, tab, attach, draw, + shown: (paneId: PaneId, open: boolean): void => { instance.actions.shown(paneId, open) }, + state: (paneId: PaneId) => instance.getSnapshot().byPane[paneId], + navigate: (tab: TestTab, revision: number, params?: SidebarRightTabNavigation['params']): void => { + tab.navigation.set({ address: tab.address, params, revision }) + }, + } +} + +describe('files navigator target navigation', () => { + it.each([ + ['restored revision zero', 0, true], + ['newly opened revision one', 1, false], + ] as const)('applies the initial open choice for %s', (_name, revision, expectedOpen) => { + const h = harness() + const tab = h.tab(A, revision) + h.shown(LEFT, true) + h.draw(LEFT, tab, 399) + h.attach(LEFT, tab) + expect(h.state(LEFT)?.open).toBe(expectedOpen) + }) + + it('closes its own filled tree on every reveal, returning tree focus to its toggle', async () => { + const h = harness() + const tab = h.tab(A) + h.shown(LEFT, true) + const drawing = h.draw(LEFT, tab, 399) + h.attach(LEFT, tab) + for (const revision of [1, 2]) { + h.shown(LEFT, true) + drawing.file.focus() + h.navigate(tab, revision, revision === 1 ? undefined : { line: 12 }) + expect(h.state(LEFT)?.open).toBe(false) + await Promise.resolve() + expect(document.activeElement).toBe(drawing.toggle) + } + }) + + it.each([false, true])('changes only target B while source A is open=%s', async (sourceOpen) => { + const h = harness() + const a = h.tab(A) + const b = h.tab(B) + h.shown(LEFT, sourceOpen) + h.shown(RIGHT, true) + h.instance.actions.resized(LEFT, 287) + const source = h.draw(LEFT, a, 399) + h.draw(RIGHT, b, 399) + h.attach(LEFT, a) + h.attach(RIGHT, b) + const focus = sourceOpen ? source.file : source.toggle + focus.focus() + const before = h.state(LEFT) + h.navigate(b, 1) + expect(h.state(RIGHT)?.open).toBe(false) + expect(h.state(LEFT)).toEqual(before) + expect(h.state(LEFT)?.width).toBe(287) + await Promise.resolve() + expect(document.activeElement).toBe(focus) + }) + + it.each([ + [800, 399, false], + [399, 800, true], + ] as const)('waits for inactive B to draw at its new width (%s -> %s)', async (oldWidth, newWidth, expectedOpen) => { + const h = harness() + const b = h.tab(B) + h.shown(LEFT, true) + const previous = h.draw(LEFT, b, oldWidth) + const detach = h.attach(LEFT, b) + detach() + previous.remove() + const a = h.tab(A) + const source = h.draw(LEFT, a, oldWidth) + h.attach(LEFT, a) + source.file.focus() + h.navigate(b, 1) + expect(h.state(LEFT)?.open).toBe(true) + expect(document.activeElement).toBe(source.file) + h.shown(RIGHT, true) + h.draw(RIGHT, b, newWidth) + h.attach(RIGHT, b) + expect(h.state(RIGHT)?.open).toBe(expectedOpen) + expect(h.state(LEFT)?.open).toBe(true) + await Promise.resolve() + expect(document.activeElement).toBe(source.file) + }) + + it('consumes navigation while hidden, so manually showing the tree does not answer it again', () => { + const h = harness() + const tab = h.tab(A, 1) + h.shown(LEFT, false) + h.attach(LEFT, tab) + h.navigate(tab, 2) + expect(h.state(LEFT)?.open).toBe(false) + h.shown(LEFT, true) + const drawing = h.draw(LEFT, tab, 399) + expect(h.state(LEFT)?.open).toBe(true) + drawing.resize(300) + expect(h.state(LEFT)?.open).toBe(true) + h.navigate(tab, 3) + expect(h.state(LEFT)?.open).toBe(false) + }) + + it.each(['remount', 'tab switch', 'pane move'] as const)('does not repeat a consumed revision after %s', (transition) => { + const h = harness() + const tab = h.tab(A, 1) + h.shown(LEFT, true) + const previous = h.draw(LEFT, tab, 800) + const detach = h.attach(LEFT, tab) + expect(h.state(LEFT)?.open).toBe(true) + detach() + previous.remove() + if (transition === 'tab switch') { + const other = h.tab(B) + const drawing = h.draw(LEFT, other, 399) + const release = h.attach(LEFT, other) + expect(h.state(LEFT)?.open).toBe(true) + release() + drawing.remove() + } + const destination = transition === 'pane move' ? RIGHT : LEFT + h.shown(destination, true) + h.draw(destination, tab, 399) + h.attach(destination, tab) + expect(h.state(destination)?.open).toBe(true) + h.navigate(tab, 2) + expect(h.state(destination)?.open).toBe(false) + }) + + it.each([400, 560, 900])('does not close for navigation at row width %s or replay it on a later resize', (width) => { + const h = harness() + const tab = h.tab(A) + h.shown(LEFT, true) + const drawing = h.draw(LEFT, tab, width) + h.attach(LEFT, tab) + for (const revision of [1, 2, 3]) { + h.navigate(tab, revision) + expect(h.state(LEFT)?.open).toBe(true) + } + drawing.resize(399) + expect(h.state(LEFT)?.open).toBe(true) + h.navigate(tab, 4) + expect(h.state(LEFT)?.open).toBe(false) + }) + + it('closes at a fullscreen viewport even when the row is wide', () => { + const h = harness() + const tab = h.tab(A, 1) + h.viewport.matches = true + h.shown(LEFT, true) + h.draw(LEFT, tab, 900) + h.attach(LEFT, tab) + expect(h.state(LEFT)?.open).toBe(false) + }) + + it('leaves a non-closable files page open despite navigation and a filled row', () => { + const h = harness() + const tab = h.tab(A, 1) + h.viewport.matches = true + h.shown(LEFT, true) + h.draw(LEFT, tab, 399, { closable: false }) + h.attach(LEFT, tab) + expect(h.state(LEFT)?.open).toBe(true) + h.navigate(tab, 2) + expect(h.state(LEFT)?.open).toBe(true) + }) + + it('waits for the first current row rather than consuming revision one without a drawing', () => { + const h = harness() + const tab = h.tab(A, 1) + h.shown(LEFT, true) + h.attach(LEFT, tab) + expect(h.state(LEFT)?.open).toBe(true) + h.draw(LEFT, tab, 399) + expect(h.state(LEFT)?.open).toBe(false) + }) + + it('does not move content focus into a tree closed by its first pending navigation', async () => { + const h = harness() + const tab = h.tab(A, 1) + h.shown(LEFT, true) + h.attach(LEFT, tab) + const drawing = h.draw(LEFT, tab, 399, { focusContent: true }) + expect(h.state(LEFT)?.open).toBe(false) + await Promise.resolve() + expect(document.activeElement).toBe(drawing.content) + }) + + it('unsubscribes on tab abort and leaves its surviving drawing and focus unchanged', async () => { + const h = harness() + const tab = h.tab(A) + h.shown(LEFT, true) + const drawing = h.draw(LEFT, tab, 399) + h.attach(LEFT, tab) + drawing.file.focus() + expect(tab.listeners.size).toBe(1) + tab.controller.abort() + expect(tab.listeners.size).toBe(0) + h.navigate(tab, 1) + expect(h.state(LEFT)?.open).toBe(true) + await Promise.resolve() + expect(document.activeElement).toBe(drawing.file) + }) + it('waits for a navigator without a containing row before answering navigation', () => { + const h = harness() + const tab = h.tab(B) + h.attach(RIGHT, tab) + h.shown(RIGHT, true) + const navigator = document.createElement('aside') + const body = navigator.appendChild(document.createElement('div')) + h.face.bodyDrawn(RIGHT, tab.address, body) + h.face.navigatorDrawn(RIGHT, navigator, true) + h.navigate(tab, 1) + expect(h.state(RIGHT)?.open).toBe(true) + h.draw(RIGHT, tab, 399) + expect(h.state(RIGHT)?.open).toBe(false) + }) + +}) diff --git a/packages/client/ui-sidebar-files/tests/store.client.spec.ts b/packages/client/ui-sidebar-files/tests/store.client.spec.ts index 660e3bc3dd..cadf73fe6e 100644 --- a/packages/client/ui-sidebar-files/tests/store.client.spec.ts +++ b/packages/client/ui-sidebar-files/tests/store.client.spec.ts @@ -1,12 +1,13 @@ -/** Tab-local directory caches, expansion preferences, and refresh settings. */ +/** Pane-local directory caches, expansion preferences, refresh settings, and navigator choices. */ import { describe, expect, it } from 'vitest' import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { createFilesStore } from '../src/client/store.ts' +import { DEFAULT_NAVIGATOR_WIDTH } from '../src/client/store.ts' import type { DirLevel } from '../src/client/store.ts' -import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' +import type { PaneId } from '@deepseek-ai/dsh-client-ui-dockkit' const ROOT = '/work/app' -const TAB = 'tab-1' as TabId +const PANE = 'pane-1' as PaneId const LEVEL: DirLevel = { entries: [{ name: 'src', type: 'directory' }, { name: 'README.md', type: 'file', size: 12 }], @@ -17,47 +18,79 @@ describe('createFilesStore', () => { it('mints an independent instance per call', () => { const first = createFilesStore().create() const second = createFilesStore().create() - first.actions.start(TAB, ROOT) - expect(second.getSnapshot().byTab[TAB]).toBeUndefined() + first.actions.start(PANE, ROOT) + expect(second.getSnapshot().byPane[PANE]).toBeUndefined() }) - it('seeds a tab at its root with the root expanded and nothing loaded', () => { + it('seeds a pane at its root with the root expanded and nothing loaded', () => { const store = createFilesStore().create() const { actions } = store const getSnapshot = (): ReturnType => store.getSnapshot() - actions.start(TAB, ROOT) - expect(getSnapshot().byTab[TAB]).toEqual({ root: ROOT, levels: {}, expanded: [ROOT], scrollTop: 0, autoRefresh: true }) + actions.start(PANE, ROOT) + expect(getSnapshot().byPane[PANE]).toEqual({ + open: undefined, width: DEFAULT_NAVIGATOR_WIDTH, + tree: { root: ROOT, levels: {}, expanded: [ROOT], autoRefresh: true }, + }) }) it('shows a failed initial listing and replaces it with a successful retry', () => { const store = createFilesStore().create() const { actions } = store const getSnapshot = (): ReturnType => store.getSnapshot() - actions.start(TAB, ROOT) - actions.loading(TAB, ROOT) - expect(getSnapshot().byTab[TAB]!.levels[ROOT]).toEqual({ kind: 'loading' }) + actions.start(PANE, ROOT) + actions.loading(PANE, ROOT) + expect(getSnapshot().byPane[PANE]!.tree!.levels[ROOT]).toEqual({ kind: 'loading' }) const failure = new RemoteError('workspace-file/not-found', 'gone', { path: ROOT }) - actions.failed(TAB, ROOT, failure) - expect(getSnapshot().byTab[TAB]!.levels[ROOT]).toEqual({ kind: 'failed', failure }) - actions.loading(TAB, ROOT) - expect(getSnapshot().byTab[TAB]!.levels[ROOT]).toEqual({ kind: 'loading' }) - actions.loaded(TAB, ROOT, LEVEL) - expect(getSnapshot().byTab[TAB]!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL }) + actions.failed(PANE, ROOT, failure) + expect(getSnapshot().byPane[PANE]!.tree!.levels[ROOT]).toEqual({ kind: 'failed', failure }) + actions.loading(PANE, ROOT) + expect(getSnapshot().byPane[PANE]!.tree!.levels[ROOT]).toEqual({ kind: 'loading' }) + actions.loaded(PANE, ROOT, LEVEL) + expect(getSnapshot().byPane[PANE]!.tree!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL }) }) - it('retains a loaded level during refresh and beside a refresh failure', () => { + it('retains a loaded level marked as refreshing while it is listed again, until the listing settles or fails', () => { const store = createFilesStore().create() const { actions } = store - actions.start(TAB, ROOT) - actions.loaded(TAB, ROOT, LEVEL) - const cached = store.getSnapshot().byTab[TAB]!.levels[ROOT] - actions.loading(TAB, ROOT) - expect(store.getSnapshot().byTab[TAB]!.levels[ROOT]).toBe(cached) + const level = () => store.getSnapshot().byPane[PANE]!.tree!.levels[ROOT] + actions.start(PANE, ROOT) + actions.loaded(PANE, ROOT, LEVEL) + actions.loading(PANE, ROOT) + expect(level()).toEqual({ kind: 'ready', level: LEVEL, refreshing: true }) + // Marking it again changes nothing. + const refreshing = store.getSnapshot() + actions.loading(PANE, ROOT) + expect(store.getSnapshot()).toBe(refreshing) const failure = new RemoteError('workspace-file/not-found', 'gone', { path: ROOT }) - actions.failed(TAB, ROOT, failure) - expect(store.getSnapshot().byTab[TAB]!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL, failure }) - actions.loaded(TAB, ROOT, LEVEL) - expect(store.getSnapshot().byTab[TAB]!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL }) + actions.failed(PANE, ROOT, failure) + expect(level()).toEqual({ kind: 'ready', level: LEVEL, failure }) + actions.loading(PANE, ROOT) + expect(level()).toEqual({ kind: 'ready', level: LEVEL, failure, refreshing: true }) + actions.loaded(PANE, ROOT, LEVEL) + expect(level()).toEqual({ kind: 'ready', level: LEVEL }) + }) + + it('ends the refresh of every level below a directory whose listing fails, and of none beside it', () => { + const store = createFilesStore().create() + const { actions } = store + const levels = () => store.getSnapshot().byPane[PANE]!.tree!.levels + const src = `${ROOT}/src` + const deep = `${src}/lib` + const docs = `${src}/docs` + const sibling = `${ROOT}/srcs` + const failure = new RemoteError('workspace-file/not-found', 'gone', { path: deep }) + actions.start(PANE, ROOT) + for (const path of [ROOT, src, deep, docs, sibling]) actions.loaded(PANE, path, LEVEL) + actions.failed(PANE, deep, failure) + for (const path of [ROOT, src, deep, sibling]) actions.loading(PANE, path) + actions.loading(PANE, `${src}/new`) + actions.failed(PANE, src, failure) + expect(levels()[src]).toEqual({ kind: 'ready', level: LEVEL, failure }) + expect(levels()[deep]).toEqual({ kind: 'ready', level: LEVEL, failure }) + expect(levels()[docs]).toEqual({ kind: 'ready', level: LEVEL }) + expect(levels()[`${src}/new`]).toEqual({ kind: 'loading' }) + expect(levels()[ROOT]).toEqual({ kind: 'ready', level: LEVEL, refreshing: true }) + expect(levels()[sibling]).toEqual({ kind: 'ready', level: LEVEL, refreshing: true }) }) it('toggles a directory in and out of the expanded set without touching its level', () => { @@ -65,40 +98,25 @@ describe('createFilesStore', () => { const { actions } = store const getSnapshot = (): ReturnType => store.getSnapshot() const child = `${ROOT}/src` - actions.start(TAB, ROOT) - actions.loaded(TAB, child, LEVEL) - actions.toggled(TAB, child) - expect(getSnapshot().byTab[TAB]!.expanded).toEqual([ROOT, child]) - actions.toggled(TAB, child) - expect(getSnapshot().byTab[TAB]!.expanded).toEqual([ROOT]) - expect(getSnapshot().byTab[TAB]!.levels[child]).toEqual({ kind: 'ready', level: LEVEL }) + actions.start(PANE, ROOT) + actions.loaded(PANE, child, LEVEL) + actions.toggled(PANE, child) + expect(getSnapshot().byPane[PANE]!.tree!.expanded).toEqual([ROOT, child]) + actions.toggled(PANE, child) + expect(getSnapshot().byPane[PANE]!.tree!.expanded).toEqual([ROOT]) + expect(getSnapshot().byPane[PANE]!.tree!.levels[child]).toEqual({ kind: 'ready', level: LEVEL }) }) - it('reset drops levels while keeping expansion, scroll, and the automatic setting', () => { + it('keeps the automatic setting independent for each pane', () => { const store = createFilesStore().create() - const { actions } = store - const getSnapshot = (): ReturnType => store.getSnapshot() - const child = `${ROOT}/src` - actions.start(TAB, ROOT) - actions.loaded(TAB, ROOT, LEVEL) - actions.toggled(TAB, child) - actions.loaded(TAB, child, LEVEL) - actions.scrolled(TAB, 120) - actions.autoRefresh(TAB, false) - actions.reset(TAB) - expect(getSnapshot().byTab[TAB]).toEqual({ root: ROOT, levels: {}, expanded: [ROOT, child], scrollTop: 120, autoRefresh: false }) - }) - - it('keeps the automatic setting independent for each tab', () => { - const store = createFilesStore().create() - const other = 'tab-2' as TabId - store.actions.start(TAB, ROOT) + const other = 'pane-2' as PaneId + store.actions.start(PANE, ROOT) store.actions.start(other, ROOT) - store.actions.autoRefresh(TAB, false) - expect(store.getSnapshot().byTab[TAB]!.autoRefresh).toBe(false) - expect(store.getSnapshot().byTab[other]!.autoRefresh).toBe(true) - store.actions.autoRefresh(TAB, true) - expect(store.getSnapshot().byTab[TAB]!.autoRefresh).toBe(true) + store.actions.autoRefresh(PANE, false) + expect(store.getSnapshot().byPane[PANE]!.tree!.autoRefresh).toBe(false) + expect(store.getSnapshot().byPane[other]!.tree!.autoRefresh).toBe(true) + store.actions.autoRefresh(PANE, true) + expect(store.getSnapshot().byPane[PANE]!.tree!.autoRefresh).toBe(true) }) it.each(['removed', 'file'] as const)('drops cached descendants and expansion when a directory is %s', (replacement) => { @@ -107,43 +125,50 @@ describe('createFilesStore', () => { const child = `${ROOT}/src` const grandchild = `${child}/nested` const sibling = `${ROOT}/src-other` - actions.start(TAB, ROOT) - actions.loaded(TAB, ROOT, { entries: [ + actions.start(PANE, ROOT) + actions.loaded(PANE, ROOT, { entries: [ { name: 'src', type: 'directory' }, { name: 'src-other', type: 'directory' }, ], truncated: false }) for (const path of [child, grandchild, sibling]) { - actions.toggled(TAB, path) - actions.loaded(TAB, path, LEVEL) + actions.toggled(PANE, path) + actions.loaded(PANE, path, LEVEL) } - actions.loaded(TAB, ROOT, { entries: replacement === 'file' + actions.loaded(PANE, ROOT, { entries: replacement === 'file' ? [{ name: 'src', type: 'file' }, { name: 'src-other', type: 'directory' }] : [{ name: 'src-other', type: 'directory' }], truncated: false }) - const state = store.getSnapshot().byTab[TAB]! + const state = store.getSnapshot().byPane[PANE]!.tree! expect(state.expanded).toEqual([ROOT, sibling]) expect(Object.keys(state.levels)).toEqual([ROOT, sibling]) expect(state.levels[sibling]).toEqual({ kind: 'ready', level: LEVEL }) }) - it('remembers where the body is scrolled to', () => { + it('keeps the navigator\'s choices before and across a tree started at a new root', () => { const store = createFilesStore().create() const { actions } = store - actions.start(TAB, ROOT) - actions.scrolled(TAB, 120) - expect(store.getSnapshot().byTab[TAB]!.scrollTop).toBe(120) + actions.shown(PANE, true) + actions.resized(PANE, 320) + expect(store.getSnapshot().byPane[PANE]).toEqual({ open: true, width: 320, tree: undefined }) + actions.start(PANE, ROOT) + actions.loaded(PANE, ROOT, LEVEL) + actions.start(PANE, '/work/other') + expect(store.getSnapshot().byPane[PANE]).toEqual({ + open: true, width: 320, + tree: { root: '/work/other', levels: {}, expanded: ['/work/other'], autoRefresh: true }, + }) }) - it('refuses to write a level for a tab that was never started', () => { + it('refuses to write a level for a pane whose tree was never started', () => { const { actions } = createFilesStore().create() - expect(() => { actions.loading('tab-nowhere' as TabId, ROOT) }).toThrow('no tree for tab "tab-nowhere"') + expect(() => { actions.loading('pane-nowhere' as PaneId, ROOT) }).toThrow('no tree for pane "pane-nowhere"') }) - it('forget removes exactly the tab that went away', () => { + it('forget removes exactly the pane that went away', () => { const store = createFilesStore().create() const { actions } = store const getSnapshot = (): ReturnType => store.getSnapshot() - actions.start(TAB, ROOT) - actions.start('tab-2' as TabId, ROOT) - actions.forget(TAB) - expect(Object.keys(getSnapshot().byTab)).toEqual(['tab-2']) + actions.start(PANE, ROOT) + actions.start('pane-2' as PaneId, ROOT) + actions.forget(PANE) + expect(Object.keys(getSnapshot().byPane)).toEqual(['pane-2']) }) }) diff --git a/packages/client/ui-sidebar-files/tests/tree-fixture.client.tsx b/packages/client/ui-sidebar-files/tests/tree-fixture.client.tsx new file mode 100644 index 0000000000..f8e5c9a3fe --- /dev/null +++ b/packages/client/ui-sidebar-files/tests/tree-fixture.client.tsx @@ -0,0 +1,42 @@ +/** Direct component fixture for the tree's framework seats; integration specs use the real renderer. */ +import { useMemo } from 'react' +import type { ReactNode } from 'react' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { FactoryComponentPropsOf, FactoryLocalComponent } from '@deepseek-ai/dsh-client-ui-slots' +import { FileTree } from '../src/client/FileTree.tsx' +import type { FileTreeInput, TreeEntry } from '../src/client/FileTree.tsx' +import { en } from '../src/client/locales.ts' + +export type Accessory = FactoryLocalComponent<'sidebar.files.tree', 'accessory'> + +function unused(): never { + throw new Error('Unexpected tree framework seat') +} + +const seats = { + t: makeTranslate(en), + renderFactorySlot: unused, + useSessions: unused, + useSessionStatus: unused, + useSessionRetainInfo: unused, + useSessionAccess: unused, + useWorkspaces: unused, + useWorkspaceAccess: unused, + useRemoteAccess: unused, + usePanelInfo: unused, + useResource: unused, + useContextMenu: unused, +} + +/** Supply selected local Components through the same local-props-only binding as the renderer. */ +export function TreeFixture( + { accessory, t = seats.t, ...input }: FileTreeInput & { accessory?: Accessory | undefined; t?: typeof seats.t }, +): ReactNode { + const useFactorySlot = ((_name: 'accessory', fallback: Accessory) => { + const Selected = accessory ?? fallback + return useMemo(() => function Bound({ entry }: { entry: TreeEntry }) { + return + }, [Selected, t]) + }) as FactoryComponentPropsOf<'sidebar.files.tree'>['useFactorySlot'] + return +} diff --git a/packages/client/ui-sidebar-files/tests/workspace-tree.client.spec.ts b/packages/client/ui-sidebar-files/tests/workspace-tree.client.spec.ts new file mode 100644 index 0000000000..8a95bf5ff4 --- /dev/null +++ b/packages/client/ui-sidebar-files/tests/workspace-tree.client.spec.ts @@ -0,0 +1,64 @@ +/** The workspace navigator's listed levels as the file tree's entries and directory states. */ +import { describe, expect, it } from 'vitest' +import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import { zh } from '../src/client/locales.ts' +import type { FilesTreeState } from '../src/client/store.ts' +import { parentOf, treeRootOf, workspaceDirectories, workspaceEntries } from '../src/client/workspace-tree.ts' + +const t = makeTranslate(zh) + +/** A tree at `root` with `src` listed below it. */ +function listed(root: string): FilesTreeState { + const top = treeRootOf(root) + return { + root, autoRefresh: true, expanded: [root, `${top}/src`], + levels: { + [root]: { kind: 'ready', level: { entries: [{ name: 'src', type: 'directory' }, { name: 'pipe', type: 'other' }], truncated: false } }, + [`${top}/src`]: { kind: 'ready', level: { entries: [{ name: 'a.ts', type: 'file' }], truncated: false } }, + }, + } +} + +describe('workspace tree keys', () => { + it.each([ + ['/work/app', '/work/app'], + ['/work/app/', '/work/app'], + ['/', ''], + ['C:\\', 'C:'], + ['C:\\repo', 'C:\\repo'], + ['\\\\server\\share\\', '\\\\server\\share'], + ])('keys root %j as %j, its entries one slash below it, and their parents back to the root as stored', (root, key) => { + const tree = listed(root) + expect(workspaceEntries(tree.levels)).toEqual([ + { path: `${key}/src`, name: 'src', meta: { kind: 'directory' } }, + { path: `${key}/pipe`, name: 'pipe', meta: { kind: 'other' } }, + { path: `${key}/src/a.ts`, name: 'a.ts', meta: { kind: 'file' } }, + ]) + expect(parentOf(tree, `${key}/src`)).toBe(root) + expect(parentOf(tree, `${key}/src/lib`)).toBe(`${key}/src`) + expect(Object.keys(workspaceDirectories(tree, t))).toEqual([key, `${key}/src`]) + }) +}) + +describe('workspaceDirectories', () => { + it('reports a directory loading until its first listing, then its failure and truncation', () => { + const failure = new RemoteError('workspace-file/not-found', 'gone', { path: '/r' }) + const states = workspaceDirectories({ + root: '/r', + expanded: ['/r', '/r/a', '/r/b', '/r/c', '/r/d'], + levels: { + '/r': { kind: 'ready', level: { entries: [], truncated: true }, failure, refreshing: true }, + '/r/b': { kind: 'loading' }, + '/r/c': { kind: 'failed', failure }, + '/r/d': { kind: 'ready', level: { entries: [], truncated: false } }, + }, + }, t) + expect(states).toEqual({ + '/r': { failure: zh['error.notFound'], truncated: true }, + '/r/a': { loading: true }, + '/r/b': { loading: true }, + '/r/c': { failure: zh['error.notFound'] }, + '/r/d': {}, + }) + }) +}) diff --git a/packages/client/ui-sidebar-right/README.i18n.yaml b/packages/client/ui-sidebar-right/README.i18n.yaml index fb9079d79a..17ac7a98fc 100644 --- a/packages/client/ui-sidebar-right/README.i18n.yaml +++ b/packages/client/ui-sidebar-right/README.i18n.yaml @@ -27,17 +27,17 @@ en: 98597ce087c48075 zh: c5466bf7955ea7b0 /deepseek-ai-dsh-client-ui-sidebar-right/extension-seats: - en: ef2af36136c4a919 - zh: 48d992e2c923a130 + en: 9533f5af50898daf + zh: 78d3b77c25962145 /deepseek-ai-dsh-client-ui-sidebar-right/ctx-sidebarright: - en: 228794a144cb4065 - zh: b0897365a1dfcd1e + en: ce5622d6d18ed8d1 + zh: 292932d8edb6e1c3 /deepseek-ai-dsh-client-ui-sidebar-right/the-tab-domain: en: 717b86e05b44ebdd zh: 0f4f694d0ea1d7f8 /deepseek-ai-dsh-client-ui-sidebar-right/the-guide: - en: 5fcda0a3a4453b52 - zh: d7818c63bf3371f2 + en: 68fd575ef382223e + zh: 1ab1653ee20c64e9 /deepseek-ai-dsh-client-ui-sidebar-right/copy: en: 67386560b71672a4 zh: 35cf8cb7ddf618ce diff --git a/packages/client/ui-sidebar-right/README.md b/packages/client/ui-sidebar-right/README.md index 6519ecffca..a5eae492e0 100644 --- a/packages/client/ui-sidebar-right/README.md +++ b/packages/client/ui-sidebar-right/README.md @@ -83,7 +83,7 @@ The store saves each Session's layout, tab identities, selection, split ratios, A tab type registers in two stages, and the shipped guide type goes through exactly the same public path a type from another package does (`ui-sidebar-documentpreview` is the live proof). Both stages sit inside the type's own `ctx.effect`, so the registration lives exactly as long as the plugin that made it. 1. **The type** — `ctx.sidebarRightTabs.register({ id, kind, patterns?, priority?, canOpen?, title, guide?, keepMounted? })`, a static declaration with no runtime hook, returning a disposer. `id` is this implementation's identity in the tab system, unique across every registration (a package name is the natural value; the shipped guide is `@deepseek-ai/dsh-client-ui-sidebar-right/guide`): a kind is not unique once an extension may take a builtin's over, so the implementation names itself, and a second registration of an `id` throws. A resource type names `patterns`, globs over `dsh-resource://` addresses: one containing `:` matches the whole address (`dsh-resource://file/**`); one without matches the URI's path at any depth, ignoring case (`*.md`), and an address that is not a URI matches no such pattern. A page type — the guide, a file tree — names none and is opened by kind. `canOpen(address)` vetoes a match. `title(address)` is the tab chip's text, captured when the tab opens. `guide` lists entry boxes for the guide page; picking one opens the contributing type as a page. A `kind` carries at most one `builtin` and one `extension` registration (the extension is in force; the builtin resumes when it leaves); any other collision on a kind throws. The `id` is also the key the type's body and title register under, so an extension and the builtin it takes over hold distinct cells and the seat renders the one in force. -2. **The body** — `ctx.slots.register({ name: 'sidebar.right.pane.tab', key: definition.id }, Body)` reads `{ sidebar, panel, tab }` through the framework-injected `useTabInfo()`. `sidebar` supplies expansion and fullscreen information; `panel.id` identifies its pane; `tab` contains the record fields, `visible`, `navigation`, `signal`, and `actions`. These are not parallel owner props; the type's own store still uses `useStore`/`actions`. Optional title registrations and guide replacements share this hook; an absent title registration uses the text captured at open time. The title wrapper retains tab and occurrence DOM markers without adding a layout box, so dockkit controls icon spacing and vertical alignment in docked tabs and floating headers. +2. **The body** — `ctx.slots.register({ name: 'sidebar.right.pane.tab', key: definition.id }, Body)` reads `{ sidebar, panel, tab }` through the framework-injected `useTabInfo()`. `sidebar` supplies expansion and fullscreen information; `panel.id` identifies its containing pane and `panel.isFloating` reports whether that pane floats. Both follow drags between docked and floating panes while `tab.id` stays unchanged; `tab` contains the record fields, `visible`, `navigation`, `signal`, and `actions`. These are not parallel owner props; the type's own store still uses `useStore`/`actions`. Optional title registrations and guide replacements share this hook; an absent title registration uses the text captured at open time. The title wrapper retains tab and occurrence DOM markers without adding a layout box, so dockkit controls icon spacing and vertical alignment in docked tabs and floating headers. Which type opens a resource follows the editor-resolver convention: the types whose `patterns` match are ranked by `priority` band — `extension` (a type from outside the product, the highest, and the default when none is named), `builtin`, `fallback` (plain viewers anything more specific should beat) — then by the length of the matched pattern, then by registration order; `canOpen` removes a candidate. The bands are string literals so a type in another package needs no runtime import from here. `candidates(address)` returns the ranking, `claim(address, kind?)` the decision; naming a `kind` skips its globs but keeps its `canOpen`. @@ -92,11 +92,15 @@ Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) r ## `ctx.sidebarRight` -`openResource(address, options?)` and `openTab(kind, options?)` are the navigation controller, and every way into the column calls one of them: the conversation's file links and a tool row's line reference (`openResource(fileAddress, { params: { line } })`), the strip's add control and a guide entry box (`openTab`), a file tree's rows (`tab.actions.openResource`). A resource address is a `dsh-resource:///…` URI; without `options.kind` the registry claims it (globs and `canOpen`, best band wins), with it that kind's type in force opens it. A page is named by kind; the tab is recorded under an address this package composes and nobody else spells (`contract/seed.ts`). Both run the same steps as one history entry: a resource tab already showing the same (kind, contentId) is focused wherever it sits unless `revealIfOpened: false`; ordinary page tabs always deduplicate within the target pane, regardless of that option; otherwise a new tab lands in `options.replaceTab`'s pane and slot (closing that tab), else `options.paneId`, else the active docked pane; the panel expands, because content the user cannot see is not opened. Then the Tab domain records the navigation — `params` reach the body as `navigation.params`, with `revision` stepped — outside the layout history. `params` is typed by what is opened: a viewer for a resource type merges its entry into `SidebarRightResourceParamsMap` (the text preview declares `{ line?: number }`); a page type that takes parameters merges into `SidebarRightTabParamsMap` under its kind; values are JSON-shaped by convention, unchecked at run time. An address outside `dsh-resource://`, one no type claims, or a kind nothing registered throws: that is a wiring mistake, not a user error. +`openResource(address, options?)` and `openTab(kind, options?)` are the navigation controller, and every way into the column calls one of them: the conversation's file links and a tool row's line reference (`openResource(fileAddress, { params: { line } })`), the strip's add control and a guide entry box (`openTab`), a file tree's rows (`tab.actions.openResource`). A resource address is a `dsh-resource:///…` URI; without `options.kind` the registry claims it (globs and `canOpen`, best band wins), with it that kind's type in force opens it. A page is named by kind; the tab is recorded under an address this package composes and nobody else spells (`contract/seed.ts`). Both run the same steps as one history entry: a resource tab already showing the same (kind, contentId) is focused wherever it sits unless `revealIfOpened: false`; ordinary page tabs always deduplicate within the target pane, regardless of that option; otherwise a new tab takes the place of `options.replaceTab` (closing that tab, described below), else in `options.paneId`, else in the active docked pane, which is the first docked pane while a floating pane is active. The panel expands, because content the user cannot see is not opened, unless the new tab replaces a floating one or the open reveals a tab in a floating panel. A reveal of a tab that was already on screen — the active tab of a floating panel, or of a docked pane while the column is expanded — makes the pane holding it glow once for about 600ms, whatever opened it and whatever type the tab is; providers do nothing for it. A reveal that switches to a background tab or expands the column does not glow, because that change is already visible. A docked pane glows inward, above its content's sticky bars such as code banners; a floating panel glows outward over its own shadow. The glow uses the accent colour in light mode and white in dark mode. A repeated reveal restarts the glow, the pane carries `data-sidebar-right-flash` while it shows, and with reduced motion the glow holds steady for the same time instead of fading. Each reveal plays once and only while its Session is on screen: hiding the Session ends a glow in progress, a reveal made while it is hidden is dropped rather than played on return, and a Sidebar mounted later does not replay an earlier reveal. Then the Tab domain records the navigation — `params` reach the body as `navigation.params`, with `revision` stepped — outside the layout history. `params` is typed by what is opened: a viewer for a resource type merges its entry into `SidebarRightResourceParamsMap` (the text preview declares `{ line?: number }`); a page type that takes parameters merges into `SidebarRightTabParamsMap` under its kind; values are JSON-shaped by convention, unchecked at run time. An address outside `dsh-resource://`, one no type claims, or a kind nothing registered throws: that is a wiring mistake, not a user error. `preferNewPane: true` first asks the target docked pane to split under the normal two-pane and room rules, then falls back to that pane when no split is available. `replaceTab` takes precedence and disables this preference. -`close(tabId)` closes a tab; `hide(tabId)` closes a floating tab or collapses its docked column; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; `toggleFullscreen(target)` applies the panel control’s display-mode action to a captured dock pane. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the on-screen surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands act on the on-screen Session through its adopted store; with no Session on screen, or before the runtime mints its store, they throw rather than write into a surface nobody draws. `mounted` is the observable of the Session on screen: the selected Session while the Conversation fills the main column, `undefined` while a global panel replaces it or no Session is selected. The plugin derives it from the Session selection and `ctx.layout.panelInfo`, and it changes before React renders the change that causes it, so a component rendered in that commit reads the arriving Session and may act on it from its effects: the frame renders that Session's seat in the same pass, which mints its store. Seats never publish it; each reports only what it renders with: the room its docking kit measured, which `split` and `preferNewPane` apply to that Session's panes (unmeasured panes have room), and whether its frame width presents the panel fullscreen automatically, which the fullscreen command applies. The plan review's automatic open reads `mounted` through a bound hook. +`replaceTab` names the tab new content takes the place of (`tab.actions` passes `replaceTab: true` for its own tab). A docked tab lends its pane and strip slot and closes. A floating tab is replaced in its panel: the new tab keeps the panel's pane id, rectangle, and stacking order and takes focus, raising the panel as a focus does, and the column is not expanded. Content already open is revealed by the rules above, and the named tab stays open; a page is looked up only in the named tab's pane, so a floating guide that opens a page shown in a docked pane becomes that page in its panel. The replaced tab's close handler runs before it is removed. Where new content lands, `replaceTab` takes precedence over `paneId` and `preferNewPane`; a named tab that has closed since is ignored. + +`openResource`, `openResourceIn`, and `tab.actions.openResource` also accept `destination: 'sidebar' | 'float'`, which names the host instead of a pane and takes precedence over `paneId`, `preferNewPane`, `replaceTab`, and `revealIfOpened`; `tab.actions` then passes neither its own pane nor itself for replacement, and no tab closes. Matching uses the Session, the claimed kind, and the content id. A copy already in the requested host is revealed, and glows when it was already on screen. Otherwise one copy from the other host moves there, keeping its tab id, occurrence, signal, and body state such as scroll position; its close handler does not run. Otherwise a new tab opens there. `'sidebar'` uses the active docked pane (the first docked pane while a floating pane is active), never splits, and expands the column; an empty collapsed root takes the tab without a default page beside it. `'float'` uses a new floating panel at the next cascade position and leaves the column's expansion as it is, for a new tab as for a move; a docked tab that leaves its pane follows the usual empty-pane rules. Other copies stay open. Every open still delivers its `params` and steps `revision`, and the whole open, including a move, is one history entry. Without `destination`, placement follows the rules above unchanged. + +`close(tabId)` closes a tab; `hide(tabId)` closes a floating tab or collapses its docked column; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; `toggleFullscreen(target)` applies the panel control’s display-mode action to a captured dock pane. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane and expands the column, as the panel's dock control does. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the on-screen surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands act on the on-screen Session through its adopted store; with no Session on screen, or before the runtime mints its store, they throw rather than write into a surface nobody draws. `mounted` is the observable of the Session on screen: the selected Session while the Conversation fills the main column, `undefined` while a global panel replaces it or no Session is selected. The plugin derives it from the Session selection and `ctx.layout.panelInfo`, and it changes before React renders the change that causes it, so a component rendered in that commit reads the arriving Session and may act on it from its effects: the frame renders that Session's seat in the same pass, which mints its store. Seats never publish it; each reports only what it renders with: the room its docking kit measured, which `split` and `preferNewPane` apply to that Session's panes (unmeasured panes have room), and whether its frame width presents the panel fullscreen automatically, which the fullscreen command applies. The plan review's automatic open reads `mounted` through a bound hook. `focusedTarget(element?)` resolves the current DOM pane and tab, including an embedding iframe; outside focus returns no page. `commandTarget(element?)` additionally allows an outside open to use the on-screen Session's active dock pane. Captures include the occurrence and navigation revision; `isTargetCurrent(target)` rejects changed Sessions, moved or reopened tabs, and intervening navigation. Focus and pointer activation remain separate from persisted layout selection. @@ -114,15 +118,15 @@ Close and refresh commands resolve the focused pane and tab occurrence. Page bod ## The guide -Each `guide` entry has an `id` unique within its provider. The guide renders `sidebar.right.tab.guide.entry` by the active definition id, forwarding the entry id, resolved title and optional description, plus the framework `useTabInfo` hook. A provider can register a custom card under its definition id; otherwise the standard icon/title button renders. The sidebar owns width and placement, while custom components own their markup and interaction. Guide cards use the shared R20 radius, including hover fills and provider-owned split buttons. Entry identity remains stable across sorting and unrelated registrations. Unboxed shortcut text aligns at the right. The terminal shell menu sits beside its entry title. +Each `guide` entry has an `id` unique within its provider. The guide renders `sidebar.right.tab.guide.entry` by the active definition id, forwarding the entry id, resolved title and optional description, plus the framework `useTabInfo` hook. A provider can register a custom card under its definition id; otherwise the standard icon/title button renders. A custom card that opens a page the guide's pane may already show calls `tab.actions.close()` after `openTab`, because a reveal leaves the guide open. The reveal and explicit close can record two history entries; a new-content replacement remains one. The sidebar owns width and placement, while custom components own their markup and interaction. Guide cards use the shared R20 radius, including hover fills and provider-owned split buttons. Entry identity remains stable across sorting and unrelated registrations. Unboxed shortcut text aligns at the right. The terminal shell menu sits beside its entry title. While the root-scoped `sidebar.right.tab.guide.actions` list has registrations, a **More tools** card follows the guide cards. Its menu end-aligns to the card, is at least as wide as the card, and grows with its rows up to 480px or the viewport width minus 24px. The menu rows are that list's registrations. Each receives `entries` (cards in registry order with `id`, `label`, and `pinned`; kind `files` is never listed), `onOpen(id)`, which opens the card's tab type in the guide's place, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The guide keeps an in-memory list of unpinned card ids, each `kind:entry id`. Plugin reload resets pin choices; legacy localStorage values are ignored. The default More menu only offers plugin browsing and creation; pin callbacks remain available to other registrations. The kind identifies a card instead of the provider id, so a pin choice survives an extension taking over that kind with the same entry id. Cards of kind `files` always show. Unpinning hides only the card; it closes no tab and changes neither tab-type registration nor the default page. Description density follows the visible card count. Unregistered card ids remain in memory until plugin reload. A tab type can declare `multiple: true` for independent instances such as terminals. Each open receives a distinct content address, so placement and docking preserve those instances; ordinary pages retain per-pane uniqueness. -Default pages depend on the number of registered guide entries, not the number of tab types or open tabs. Exactly one entry opens its page directly; zero or multiple entries open the guide. Explicitly adding a guide still opens the guide, even with one entry. The sole docked guide is the only tab that cannot close; closing any other sole tab also collapses the column. The close control, context menu, middle click, and `close` API apply the same rule. +Default pages depend on the number of registered guide entries, not tab types or open tabs. Exactly one entry opens its page directly; zero or multiple entries open the guide. Explicitly adding a guide always opens the guide. Closing follows the [sole-docked-tab rule](#state). -The guide tab is a muted compass over one entry capsule per `guide` entry the registered types contributed, in `order`, centred in the body; the guide has no words of its own. A capsule shows the entry's glyph — or the guide's quieter cube placeholder when the entry registered none — and its title; while at most four entries are listed, an entry that registered a `description` shows it under the title, and a longer list drops every description. Picking a default capsule calls `tab.actions.openTab(entry.kind, { replaceTab: true })`, so the guide gives way to the page it opened. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split, an expanded empty root pane, and the pane a sole tab vacates by dropping on its own edge use the same default-page rule, one tab per new pane; the self-edge drop leaves the dragged tab focused. A plain `openTab('guide')` opens or focuses the guide only within the active or named pane. Splitting an empty pane does nothing and returns no new pane. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are disabled with a focusable reason; closing back to one pane enables them. +The guide tab is a muted compass over one entry capsule per `guide` entry the registered types contributed, in `order`, centred in the body; the guide has no words of its own. A capsule shows the entry's glyph — or the guide's quieter cube placeholder when the entry registered none — and its title; while at most four entries are listed, an entry that registered a `description` shows it under the title, and a longer list drops every description. Picking a default capsule or a **More tools** row calls `tab.actions.openTab(entry.kind, { replaceTab: true })` and then `tab.actions.close()`, so the guide gives way to the page it opened and closes itself when that page was revealed; the open-page shortcut from a focused guide does the same. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split, an expanded empty root pane, and the pane a sole tab vacates by dropping on its own edge use the same default-page rule, one tab per new pane; the self-edge drop leaves the dragged tab focused. A plain `openTab('guide')` opens or focuses the guide only within the active or named pane. Splitting an empty pane does nothing and returns no new pane. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are disabled with a focusable reason; closing back to one pane enables them. ## Copy diff --git a/packages/client/ui-sidebar-right/README.zh.md b/packages/client/ui-sidebar-right/README.zh.md index 228305a3e5..7e5319e477 100644 --- a/packages/client/ui-sidebar-right/README.zh.md +++ b/packages/client/ui-sidebar-right/README.zh.md @@ -83,7 +83,7 @@ store 将每个 Session 的布局、标签身份、选中项、分栏比例、 tab 类型分两阶段注册,随包发布的引导类型走的正是别的包的类型走的同一条公开路径(`ui-sidebar-documentpreview` 是活的证明)。两个阶段都在类型自己的 `ctx.effect` 里,因此注册与创建它的插件同生共死。 1. **类型**——`ctx.sidebarRightTabs.register({ id, kind, patterns?, priority?, canOpen?, title, guide?, keepMounted? })`,一份没有运行时钩子的静态声明,返回 disposer。`id` 是这个实现在 tab 系统里的身份,在全部注册中唯一(包名是天然取值;随包引导页是 `@deepseek-ai/dsh-client-ui-sidebar-right/guide`):一旦 extension 可以接管 builtin 的 kind,kind 就不再唯一,所以实现要自己命名,同一 `id` 的第二次注册会 throw。资源类型给出 `patterns`,即作用于 `dsh-resource://` 地址的 glob:含 `:` 的匹配整个地址(`dsh-resource://file/**`);不含的匹配 URI 路径的任意深度且忽略大小写(`*.md`),不是 URI 的地址不匹配任何这类模式。页类型——引导页、文件树——不给出模式,按 kind 打开。`canOpen(address)` 否决一次命中。`title(address)` 是 tab chip 的文字,在 tab 打开时捕获。`guide` 列出引导页的入口框;选中一个即把贡献它的类型作为页打开。一个 `kind` 最多承载一份 `builtin` 与一份 `extension` 注册(extension 生效;它离开后 builtin 恢复);kind 上的其它任何撞名都 throw。`id` 同时也是该类型正文与标题注册时用的 key,因此 extension 与它接管的 builtin 各占一个格位,席位渲染生效的那个。 -2. **正文**——`ctx.slots.register({ name: 'sidebar.right.pane.tab', key: definition.id }, Body)` 通过框架注入的 `useTabInfo()` 读取 `{ sidebar, panel, tab }`。`sidebar` 提供开合与全屏信息,`panel.id` 命名所在格,`tab` 包含原记录字段、`visible`、`navigation`、`signal` 和 `actions`。这些字段不再作为平铺owner props传入;类型自己的store仍使用 `useStore`/`actions`。可选标题注册及引导替换共享该hook;未注册标题时使用打开时保存的文本。标题包装保留 tab 与 occurrence 的 DOM 标记,但不引入布局盒,因此停靠 tab 和浮窗标题中的图标间距与垂直对齐由 dockkit 控制。 +2. **正文**——`ctx.slots.register({ name: 'sidebar.right.pane.tab', key: definition.id }, Body)` 通过框架注入的 `useTabInfo()` 读取 `{ sidebar, panel, tab }`。`sidebar` 提供开合与全屏信息,`panel.id` 标识所在分栏,`panel.isFloating` 表示该分栏是否为浮窗;两者随 tab 在停靠分栏与浮窗之间拖动而更新,`tab.id` 保持不变。`tab` 包含原记录字段、`visible`、`navigation`、`signal` 和 `actions`。这些字段不再作为平铺owner props传入;类型自己的store仍使用 `useStore`/`actions`。可选标题注册及引导替换共享该hook;未注册标题时使用打开时保存的文本。标题包装保留 tab 与 occurrence 的 DOM 标记,但不引入布局盒,因此停靠 tab 和浮窗标题中的图标间距与垂直对齐由 dockkit 控制。 由哪个类型打开资源遵循编辑器解析器的惯例:`patterns` 命中的类型先按 `priority` 档排序——`extension`(产品外的类型,最高档,也是未命名时的默认)、`builtin`、`fallback`(任何更具体的类型都应胜过的通用查看器)——再按命中模式的长度,再按注册顺序;`canOpen` 会剔除候选。各档是字符串字面量,因此别的包里的类型不需要从这里做运行时导入。`candidates(address)` 返回排序,`claim(address, kind?)` 返回决定;指定 `kind` 时跳过它的 glob 但保留它的 `canOpen`。 @@ -92,11 +92,15 @@ tab 类型分两阶段注册,随包发布的引导类型走的正是别的包 ## `ctx.sidebarRight` -`openResource(address, options?)` 与 `openTab(kind, options?)` 是导航控制器,进入该列的每条路都调用其中之一:会话区的文件链接与工具行的行号引用(`openResource(fileAddress, { params: { line } })`),tab 条的添加控件与引导入口框(`openTab`),文件树的行(`tab.actions.openResource`)。资源地址是 `dsh-resource:///…` URI;不带 `options.kind` 时由注册表认领(glob 与 `canOpen`,最高档胜出),带它时由该 kind 生效的类型打开。页按 kind 命名;tab 记录在本包拼出、别处无人书写的地址下(`contract/seed.ts`)。两者以同一组步骤作为一条历史运行:已展示同一 (kind, contentId) 的资源 tab 被聚焦,不限所在分栏,除非 `revealIfOpened: false`;页 tab 始终只在目标分栏内去重,不受该选项影响;否则新 tab 落到 `options.replaceTab` 所在的格与位置(并关掉那个 tab),再退而落到 `options.paneId`,再退而落到活跃停靠格;面板展开,因为用户看不到的内容不算打开。随后 Tab 域记录这次导航——`params` 以 `navigation.params` 抵达正文,`revision` 递增——不进布局历史。`params` 按所开之物定型:某资源类型的查看器把自己那项并入 `SidebarRightResourceParamsMap`(文本预览声明 `{ line?: number }`);接受参数的页类型按其 kind 并入 `SidebarRightTabParamsMap`;值约定为 JSON 形状,运行时不校验。`dsh-resource://` 之外的地址、无人认领的地址、或未注册的 kind 都会 throw:那是接线错误,不是用户错误。 +`openResource(address, options?)` 与 `openTab(kind, options?)` 是导航控制器,进入该列的每条路都调用其中之一:会话区的文件链接与工具行的行号引用(`openResource(fileAddress, { params: { line } })`),tab 条的添加控件与引导入口框(`openTab`),文件树的行(`tab.actions.openResource`)。资源地址是 `dsh-resource:///…` URI;不带 `options.kind` 时由注册表认领(glob 与 `canOpen`,最高档胜出),带它时由该 kind 生效的类型打开。页按 kind 命名;tab 记录在本包拼出、别处无人书写的地址下(`contract/seed.ts`)。两者以同一组步骤作为一条历史运行:已展示同一 (kind, contentId) 的资源 tab 被聚焦,不限所在分栏,除非 `revealIfOpened: false`;页 tab 始终只在目标分栏内去重,不受该选项影响;否则新 tab 取代 `options.replaceTab` 的位置(并关掉那个 tab,见下文),再退而落到 `options.paneId`,再退而落到活跃停靠格;活动格是浮窗时为第一个停靠格。面板展开,因为用户看不到的内容不算打开;新 tab 替换浮动 tab、或这次打开揭示的是浮窗中的 tab 时除外。揭示一个原本就在屏幕上的 tab(浮窗的活动 tab,或该列展开时停靠格的活动 tab)时,存放它的格发光提示一次,约 600ms,与由谁打开、tab 是什么类型无关,提供方无需做任何事。揭示需要切到后台 tab 或展开该列时不发光,因为这一变化本身已经可见。停靠格向内发光,位于其内容的吸顶栏(如代码块标题栏)之上;浮窗在自身阴影之上向外发光。浅色模式用强调色,深色模式用白色。再次揭示会重新开始发光,发光期间该格带有 `data-sidebar-right-flash`;减少动态效果时发光在同样时长内保持不变而不淡出。每次揭示只在其会话位于屏幕上时播放一次:隐藏该会话会结束进行中的发光,会话隐藏期间的揭示直接丢弃、不在切回时补播,之后挂载的侧栏也不会重放先前的揭示。随后 Tab 域记录这次导航——`params` 以 `navigation.params` 抵达正文,`revision` 递增——不进布局历史。`params` 按所开之物定型:某资源类型的查看器把自己那项并入 `SidebarRightResourceParamsMap`(文本预览声明 `{ line?: number }`);接受参数的页类型按其 kind 并入 `SidebarRightTabParamsMap`;值约定为 JSON 形状,运行时不校验。`dsh-resource://` 之外的地址、无人认领的地址、或未注册的 kind 都会 throw:那是接线错误,不是用户错误。 `preferNewPane: true` 会先按普通的两格上限与空间规则尝试分割目标停靠格,无法分栏时回退到该格。`replaceTab` 优先并会禁用这项偏好。 -`close(tabId)` 关闭一个 tab;`hide(tabId)` 关闭浮动 tab 或收起其停靠列;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;`toggleFullscreen(target)` 对捕获的停靠分栏执行面板控件的显示模式操作。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进屏幕上停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令通过屏幕上会话已采用的 store 作用于该会话;没有会话在屏幕上、或运行时尚未创建它的 store 时,命令 throw,而不是写进一个没人绘制的面里。`mounted` 是「屏幕上的会话」的可观察值:Conversation 占据主栏时为选中的会话,全局面板替代它或没有选中会话时为 `undefined`。插件从会话选择与 `ctx.layout.panelInfo` 推导它,它在 React 渲染引起变化的那次更新之前就已改变,因此同一次提交里渲染的组件读到的是到来的会话,并可以在自己的 effect 里对它行动:框架在同一轮渲染里渲染该会话的席位,席位随之创建它的 store。席位从不发布它;每个席位只上报它渲染时依据的事实:停靠套件测得的空间,`split` 与 `preferNewPane` 把它用于该会话的分栏(未测量的分栏视为有空间);以及框架宽度是否让面板自动全屏,全屏命令据此执行。计划审阅的自动打开通过绑定的 hook 读取 `mounted`。 +`replaceTab` 指定新内容所取代的 tab(`tab.actions` 对自己的 tab 传 `replaceTab: true`)。停靠 tab 把所在格与条上位置让给新 tab 并关闭。浮动 tab 在浮窗内被原位替换:新 tab 沿用浮窗的 pane id、矩形与叠放顺序并获得焦点(与任何聚焦一样会把该浮窗提到最上层),且不展开这一列。内容已打开时按上文规则揭示,被指定的 tab 保持打开;页只在被指定 tab 所在的格里查找,因此打开某个停靠格已展示的页的浮动引导页,会在其浮窗内变成那个页。被替换 tab 的关闭处理器在其移除之前运行。决定新内容落在哪里时,`replaceTab` 优先于 `paneId` 与 `preferNewPane`;已关闭的指定 tab 被忽略。 + +`openResource`、`openResourceIn` 与 `tab.actions.openResource` 还接受 `destination: 'sidebar' | 'float'`:它指定宿主位置而非分栏,优先于 `paneId`、`preferNewPane`、`replaceTab` 与 `revealIfOpened`;此时 `tab.actions` 既不传自己所在的格,也不把自己作为被替换的 tab,不会关闭任何 tab。匹配依据会话、认领的 kind 与 content id。请求的宿主里已有副本时揭示它,该副本原本就在屏幕上时发光提示。否则把另一宿主中的一份副本移过去,保留其 tab id、occurrence、signal 及滚动位置等正文状态,不运行其关闭处理器。否则在该宿主新开 tab。`'sidebar'` 使用活跃停靠格(活动格是浮窗时为第一个停靠格),从不分栏,并展开该列;空的收起根格直接接收该 tab,旁边不补默认页。`'float'` 在下一个级联位置新建浮窗,新开和移动都不改变该列的展开状态;停靠 tab 离开所在格后按通常的空格规则处理。其他副本保持打开。每次打开仍交付 `params` 并递增 `revision`,整次打开(含移动)是一条历史。不传 `destination` 时,落位规则与上文相同。 + +`close(tabId)` 关闭一个 tab;`hide(tabId)` 关闭浮动 tab 或收起其停靠列;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;`toggleFullscreen(target)` 对捕获的停靠分栏执行面板控件的显示模式操作。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格并展开该列,与浮窗的收回控件一致。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进屏幕上停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令通过屏幕上会话已采用的 store 作用于该会话;没有会话在屏幕上、或运行时尚未创建它的 store 时,命令 throw,而不是写进一个没人绘制的面里。`mounted` 是「屏幕上的会话」的可观察值:Conversation 占据主栏时为选中的会话,全局面板替代它或没有选中会话时为 `undefined`。插件从会话选择与 `ctx.layout.panelInfo` 推导它,它在 React 渲染引起变化的那次更新之前就已改变,因此同一次提交里渲染的组件读到的是到来的会话,并可以在自己的 effect 里对它行动:框架在同一轮渲染里渲染该会话的席位,席位随之创建它的 store。席位从不发布它;每个席位只上报它渲染时依据的事实:停靠套件测得的空间,`split` 与 `preferNewPane` 把它用于该会话的分栏(未测量的分栏视为有空间);以及框架宽度是否让面板自动全屏,全屏命令据此执行。计划审阅的自动打开通过绑定的 hook 读取 `mounted`。 `focusedTarget(element?)` 解析当前 DOM 分栏和标签页,也接受嵌入 iframe;焦点位于外部时不返回页面。`commandTarget(element?)` 另外允许外部打开动作使用屏幕上会话的活动停靠分栏。捕获值包含 occurrence 和导航版本;`isTargetCurrent(target)` 拒绝会话变更、已移动或重新打开的标签页,以及期间发生的导航。焦点和指针激活独立于持久化的布局选择。 @@ -114,15 +118,15 @@ Tab 域按(Session,Tab id)保留品牌化 occurrence id、导航、中止 ## 引导页 -每个 `guide` 入口具有 provider 内唯一的 `id`。开始页按当前生效的定义 id 渲染 `sidebar.right.tab.guide.entry`,传入入口 id、解析后的标题、可选描述,以及框架提供的 `useTabInfo` hook。provider 可按自己的定义 id 注册自定义卡片;未注册时使用默认图标与标题按钮。侧栏负责宽度和排列,自定义组件负责内部结构和交互。引导卡片统一使用 R20,包含悬停底色与提供方自有的分体按钮。排序或其他注册变化不会改变入口身份。无背景的快捷键文字右对齐。终端 Shell 菜单位于入口标题旁。 +每个 `guide` 入口具有 provider 内唯一的 `id`。开始页按当前生效的定义 id 渲染 `sidebar.right.tab.guide.entry`,传入入口 id、解析后的标题、可选描述,以及框架提供的 `useTabInfo` hook。provider 可按自己的定义 id 注册自定义卡片;未注册时使用默认图标与标题按钮。自定义卡片打开的页可能已在引导页所在格中时,需在 `openTab` 之后调用 `tab.actions.close()`,因为揭示不会关闭引导页。揭示与显式关闭可能记录为两条历史;新内容的原位替换仍为一条。侧栏负责宽度和排列,自定义组件负责内部结构和交互。引导卡片统一使用 R20,包含悬停底色与提供方自有的分体按钮。排序或其他注册变化不会改变入口身份。无背景的快捷键文字右对齐。终端 Shell 菜单位于入口标题旁。 root 作用域的 `sidebar.right.tab.guide.actions` list 有注册项时,引导卡片之后显示**更多工具**卡片。它的菜单与卡片末端对齐,不窄于卡片,并随菜单行增宽,最大为 480px 与视口宽度减 24px 中的较小值。菜单行为该 list 的注册项。每个注册项接收 `entries`(按注册表顺序的卡片,含 `id`、`label` 与 `pinned`;不含 kind 为 `files` 的卡片)、在引导页位置打开卡片标签类型的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。引导页在内存中保存已收起卡片 id 列表,每个 id 为 `kind:入口 id`。插件重新加载时 Pin 选择重置,不读取旧 localStorage 值。默认更多菜单只提供插件浏览和创建;其他注册项仍可使用 Pin 回调。卡片由 kind 而不是提供方 id 标识,因此扩展接管某个 kind 且保持入口 id 时,Pin 选择仍然有效。kind 为 `files` 的卡片始终显示。收起只隐藏卡片,不关闭标签,也不改变标签类型注册或默认页。描述的显示密度按可见卡片数计算。未注册卡片的 id 保留在内存中,直到插件重新加载。 终端等需要独立实例的 tab 类型可声明 `multiple: true`。每次打开都会获得独立内容地址,因此移动和停靠会保留这些实例;普通页面仍在每个格内去重。 -默认页取决于已注册的引导入口数,不取决于 tab 类型数或已打开的 tab 数。恰好一个入口时直接打开对应页面;没有入口或有多个入口时打开引导页。即使只有一个入口,显式添加引导页仍会打开引导页。只有作为唯一停靠 tab 的引导页不可关闭;关闭其它任何唯一 tab 时会同时收起整列。关闭控件、上下文菜单、中键与 `close` API 使用同一规则。 +默认页取决于已注册的引导入口数,不取决于 tab 类型数或已打开的 tab 数。恰好一个入口时直接打开对应页面;没有入口或有多个入口时打开引导页。显式添加引导页始终打开引导页。关闭遵循[唯一停靠 tab 规则](#state)。 -引导 tab 是一枚弱化的罗盘,下方是各已注册类型贡献的每个 `guide` 条目一个入口胶囊,按 `order` 排列并在正文中居中;引导页自己没有文字。胶囊显示条目的图标——条目没注册图标时用引导页自己更浅的立方体占位符——和标题;列出的条目不超过四个时,注册了 `description` 的条目在标题下显示它,更长的列表则去掉所有描述。点击默认胶囊会调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`,于是引导页让位给它打开的页。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏、展开且为空的根格、以及唯一 tab 拖到本格边缘分屏后腾出的格使用相同的默认页规则,每个新格一个 tab;这种本格边缘拖放让被拖的 tab 保持聚焦。普通的 `openTab('guide')` 只在活跃或指定分栏内打开或聚焦引导页。对空分栏执行 split 不产生变化,也不返回新分栏。产品最多保留左右两格,默认均分,分隔条限定在 20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。达到两格上限时禁用分栏控件并提供可聚焦的原因提示;关闭回单格后重新启用。 +引导 tab 是一枚弱化的罗盘,下方是各已注册类型贡献的每个 `guide` 条目一个入口胶囊,按 `order` 排列并在正文中居中;引导页自己没有文字。胶囊显示条目的图标——条目没注册图标时用引导页自己更浅的立方体占位符——和标题;列出的条目不超过四个时,注册了 `description` 的条目在标题下显示它,更长的列表则去掉所有描述。点击默认胶囊或**更多工具**菜单行会调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`,再调用 `tab.actions.close()`,于是引导页让位给它打开的页,该页被揭示时引导页关闭自己;在聚焦的引导页上用快捷键打开页时也是如此。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏、展开且为空的根格、以及唯一 tab 拖到本格边缘分屏后腾出的格使用相同的默认页规则,每个新格一个 tab;这种本格边缘拖放让被拖的 tab 保持聚焦。普通的 `openTab('guide')` 只在活跃或指定分栏内打开或聚焦引导页。对空分栏执行 split 不产生变化,也不返回新分栏。产品最多保留左右两格,默认均分,分隔条限定在 20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。达到两格上限时禁用分栏控件并提供可聚焦的原因提示;关闭回单格后重新启用。 ## 文案 diff --git a/packages/client/ui-sidebar-right/src/client/contract/slots.ts b/packages/client/ui-sidebar-right/src/client/contract/slots.ts index 517dfbf68c..bb7c97edd2 100644 --- a/packages/client/ui-sidebar-right/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar-right/src/client/contract/slots.ts @@ -120,6 +120,13 @@ export interface SidebarRightTabNavigation { readonly revision: number } +/** + * The host a resource open settles in: `'sidebar'` is the default docked pane + * and expands the column; `'float'` is a floating panel and leaves the column + * as it is. + */ +export type SidebarRightDestination = 'sidebar' | 'float' + /** Where an open from a tab lands. Without any of these it lands in the pane holding the tab at call time. */ export interface SidebarRightTabPlacement { /** Land a new tab in this pane instead. */ @@ -128,7 +135,14 @@ export interface SidebarRightTabPlacement { readonly preferNewPane?: boolean /** Resource tabs reveal existing content by default; `false` permits duplicates. Pages always deduplicate within the target pane. */ readonly revealIfOpened?: boolean - /** `true` opens in this tab's place — its pane and strip slot — and closes this tab in the same step. */ + /** + * `true` lands new content in this tab's place and closes this tab: a docked + * tab lends its pane and strip slot; a floating tab is replaced in its panel, + * and the new tab keeps the panel's pane id, rectangle, and stacking order, + * takes focus, and leaves the column as it is. Content already open is + * revealed as any open reveals it, and this tab stays open. Where new + * content lands, it takes precedence over `paneId` and `preferNewPane`. + */ readonly replaceTab?: boolean } @@ -147,11 +161,16 @@ export interface SidebarRightTabActions { */ bindCommands(commands: SidebarRightTabCommands): () => void /** - * Open a resource from this tab; see `ISidebarRight.openResource`. + * Open a resource from this tab; see `ISidebarRight.openResource`. With + * `destination`, the open ignores this tab's pane and every placement field, + * and never closes this tab. * @param address - a `dsh-resource://` address. - * @param options - placement and the resource's navigation parameters. + * @param options - placement, an explicit host, and the resource's navigation parameters. */ - openResource(address: string, options?: SidebarRightTabPlacement & { readonly params?: SidebarRightResourceParams }): void + openResource(address: string, options?: SidebarRightTabPlacement & { + readonly params?: SidebarRightResourceParams + readonly destination?: SidebarRightDestination + }): void /** * Open a page type from this tab; see `ISidebarRight.openTab`. * @param kind - the page type's kind. @@ -169,7 +188,11 @@ export interface SidebarRightTabInfo { /** Presentation selected by manual mode or viewport width; preserved while collapsed. */ readonly fullscreen: boolean } - readonly panel: { readonly id: PaneId } + readonly panel: { + readonly id: PaneId + /** Whether this pane is hosted in a floating panel rather than the docked sidebar. */ + readonly isFloating: boolean + } readonly tab: TabRecord & { /** Effective refresh binding for this page's controls. */ readonly refreshShortcut?: ShortcutCatalogEntry | undefined diff --git a/packages/client/ui-sidebar-right/src/client/index.ts b/packages/client/ui-sidebar-right/src/client/index.ts index 6f84abab63..1f3d3781fe 100644 --- a/packages/client/ui-sidebar-right/src/client/index.ts +++ b/packages/client/ui-sidebar-right/src/client/index.ts @@ -217,7 +217,7 @@ export function apply(ctx: ClientContext): void { } } }, } - const injected: Omit = { + const injected: Omit = { syncPresentation({ shown, track, fullscreen }) { if (shown) layout.openRightbar(track, fullscreen) else layout.closeRightbar() @@ -226,12 +226,8 @@ export function apply(ctx: ClientContext): void { splitPane: (paneId) => { controller.split(paneId) }, toggleFullscreen: () => { const target = controller.commandTarget(); if (target !== undefined) controller.toggleFullscreen(target) }, openTab: (kind, options) => { controller.openTab(kind, options) }, - hooks: { - contextMenuTarget: ctx.contextMenus.active, - shortcuts: ctx.shortcuts.catalog, - tabTypes: { subscribe: listener => tabs.subscribe(listener), getSnapshot: () => tabs.entries() }, - }, } + const tabTypes = { subscribe: (listener: () => void) => tabs.subscribe(listener), getSnapshot: () => tabs.entries() } const disposeTypes = [tabs.register(guideDefinition(t))] const disposeSeat = ctx.slots.inject('rightbar', function* () { @@ -253,6 +249,9 @@ export function apply(ctx: ClientContext): void { store, inject: (sessionId): SidebarRightInjected => ({ ...injected, + hooks: { + contextMenuTarget: ctx.contextMenus.active, shortcuts: ctx.shortcuts.catalog, tabTypes, flash: controller.flashOf(sessionId), + }, measureRoom: (canSplitPane) => { measure(sessionId, canSplitPane) }, closeTab: (tabId) => { try { controller.closeIn(sessionId, tabId) } diff --git a/packages/client/ui-sidebar-right/src/client/service.ts b/packages/client/ui-sidebar-right/src/client/service.ts index 6e3da98cf8..8e53ba2f5a 100644 --- a/packages/client/ui-sidebar-right/src/client/service.ts +++ b/packages/client/ui-sidebar-right/src/client/service.ts @@ -35,15 +35,16 @@ */ import { sidebarTargetFromElement, type SidebarRightTarget } from './focus.ts' import { randomUUID } from '@deepseek-ai/dsh-util-crypto' -import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' -import type { FloatRect, PaneId, TabId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit' +import { createSnapshotStore, type ObservableSnapshot, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { FloatRect, LayoutState, PaneId, TabId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit' import { activeDockPaneId, canSplit, findContentTab, findPaneContentTab, dockPaneIds, findTabPane, getPane } from '@deepseek-ai/dsh-client-ui-dockkit' import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SidebarRightNavigationParams, SidebarRightResourceParams, SidebarRightTabParamsFor } from './contract/params.ts' import { pageAddress } from './contract/seed.ts' +import type { SidebarRightDestination } from './contract/slots.ts' import type { SidebarRightTabClaim, SidebarRightTabRegistry } from './tab-registry.ts' -import { canCloseTab, type SidebarRightState, type SurfaceState } from './stores.ts' +import { canCloseTab, findHostContentTab, type SidebarRightState, type SurfaceState } from './stores.ts' import type { createSidebarRightStore } from './stores.ts' import { TabDomain, type PinResource } from './tab-domain.ts' import { SidebarTabInventory } from './tab-inventory.ts' @@ -77,6 +78,8 @@ interface SidebarRightSessions { readonly onScreen: ObservableSnapshot /** Open tab metadata across saved and adopted Sessions. */ readonly openTabs: SidebarTabInventory['source'] + /** Each Session's flash source, created on first use and released when the Session is forgotten. */ + readonly flashes: Map> } /** @@ -98,12 +101,14 @@ export function createSidebarRightController(tabs: SidebarRightTabRegistry, pin: const rooms = new Map() const onScreen = createSnapshotStore(undefined) const inventory = new SidebarTabInventory() - const controller = new SidebarRightController(tabs, pin, host, { adopted, rooms, onScreen, openTabs: inventory.source }) + const flashes = new Map>() + const controller = new SidebarRightController(tabs, pin, host, { adopted, rooms, onScreen, openTabs: inventory.source, flashes }) return { controller, forget: (sessionId) => { inventory.remove(sessionId) rooms.delete(sessionId) + flashes.delete(sessionId) }, show: (sessionId) => { if (onScreen.getSnapshot() !== sessionId) onScreen.set(sessionId) }, measure: (sessionId, canSplitPane) => { rooms.set(sessionId, canSplitPane) }, @@ -153,7 +158,15 @@ export interface SidebarRightPlacement { readonly paneId?: PaneId /** Prefer a new pane for new content; use the target pane when splitting is unavailable. */ readonly preferNewPane?: boolean - /** Take this tab's place — its pane and its strip slot — and close it in the same step. */ + /** + * Land new content in this tab's place and close it in the same step: a + * docked tab lends its pane and strip slot; a floating tab is replaced in its + * panel, and the new tab keeps the panel's pane id, rectangle, and stacking + * order, takes focus, and leaves the column as it is. Content already open is + * revealed, and this tab stays open. Where new content lands, it takes + * precedence over `paneId` and `preferNewPane`. A tab that has closed since + * is ignored, so the open places as without it. + */ readonly replaceTab?: TabId /** * Resource tabs reveal an existing (kind, contentId) by default; `false` @@ -168,6 +181,17 @@ export interface SidebarRightOpenResourceOptions extends SidebarRightPlacement { readonly kind?: string /** The resource's navigation parameters, typed by resource type; delivered as `navigation.params`. */ readonly params?: SidebarRightResourceParams + /** + * Settle the resource in this host, matched by Session, claimed kind, and + * content id; `paneId`, `preferNewPane`, `replaceTab`, and `revealIfOpened` + * are then ignored and no tab closes. A copy already in the host is revealed; + * otherwise one copy from the other host moves there, keeping its tab id and + * occurrence; otherwise a new tab opens there. `'sidebar'` uses the default + * docked pane and expands the column; `'float'` uses a new floating panel and + * leaves the column as it is. Other copies stay open. Omitted, placement + * follows the fields above. + */ + readonly destination?: SidebarRightDestination } /** How a caller wants a page type opened. */ @@ -179,6 +203,12 @@ export interface SidebarRightOpenTabOptions extends S /** The scheme every resource address carries; anything else is not a resource this face opens. */ const RESOURCE_SCHEME = 'dsh-resource://' +/** A request to highlight the pane holding a revealed tab; `serial` changes on every request, so a repeat restarts the highlight. */ +export interface SidebarRightFlash { + readonly paneId: PaneId + readonly serial: number +} + /** Synchronous close/replacement hook; resource owners retain any background cleanup. */ export type SidebarRightCloseHandler = (sessionId: SessionId, tab: TabRecord) => void @@ -201,10 +231,14 @@ export interface ISidebarRight { * `canOpen` accept the address and the best band wins; with it, that kind's * type in force opens the address (its `canOpen` still applies). An address * outside `dsh-resource://`, or one no type will open, is a wiring mistake, - * not a user error, so it throws. The column expands in the same step, - * because content the user cannot see is not opened. + * not a user error, so it throws. A docked tab expands the column in the + * same step, because content the user cannot see is not opened; an open + * that settles in a floating panel leaves the column as it is. + * `options.destination` selects the host explicitly and takes precedence + * over the placement fields; the whole open, including a move between hosts, + * is one recorded intent. * @param address - a `dsh-resource:///…` address. - * @param options - placement, the opening type, and navigation parameters. + * @param options - placement or an explicit host, the opening type, and navigation parameters. */ openResource(address: string, options?: SidebarRightOpenResourceOptions): void /** @@ -272,6 +306,7 @@ export class SidebarRightController implements ISidebarRight { private readonly adopted: ReadonlyMap private readonly rooms: ReadonlyMap private readonly closeHandlers = new Map() + private readonly flashes: Map> /** * Register resource cleanup before explicit removal. Failure preserves the tab. @@ -307,6 +342,7 @@ export class SidebarRightController implements ISidebarRight { this.rooms = sessions.rooms this.mounted = sessions.onScreen this.openTabs = sessions.openTabs + this.flashes = sessions.flashes this.tabDomain = new TabDomain(this, pin) } @@ -413,7 +449,25 @@ export class SidebarRightController implements ISidebarRight { if (!address.startsWith(RESOURCE_SCHEME)) { throw new Error(`sidebarRight: no registered tab type claims "${address}"`) } - this.place(sessionId, actions, this.tabs.claim(address, options.kind), address, options, options.params) + const claim = this.tabs.claim(address, options.kind) + if (options.destination === undefined) this.place(sessionId, actions, claim, address, options, options.params) + else this.placeIn(sessionId, actions, claim, address, options.destination, options.params) + } + + /** Settle a claimed resource in an explicit host: one store intent, no close handler, and a flash only for a reveal. */ + private placeIn( + sessionId: SessionId, + actions: SurfaceActions, + claim: SidebarRightTabClaim, + address: string, + destination: SidebarRightDestination, + params: SidebarRightNavigationParams, + ): void { + const layout = this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId]?.layout + const revealed = layout === undefined ? undefined : findHostContentTab(layout, destination, claim.contentId, claim.kind) + actions.openContent(sessionId, { kind: claim.kind, contentId: claim.contentId, title: claim.title, destination }, + (tabId) => { this.tabDomain.navigate(sessionId, tabId, { address, params }) }) + if (layout !== undefined && revealed !== undefined) this.flashShowing(sessionId, layout, revealed) } /** Place a page type in one session at the address pages are recorded under; an unregistered kind throws. */ @@ -439,16 +493,18 @@ export class SidebarRightController implements ISidebarRight { params: SidebarRightNavigationParams, ): void { const surface = this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId] - const targetPane = surface === undefined ? undefined : placement.paneId ?? activeDockPaneId(surface.layout) - const target = targetPane === undefined ? undefined : surface?.layout.nodes[targetPane] + const layout = surface?.layout + const replaced = placement.replaceTab === undefined ? undefined : layout?.tabs[placement.replaceTab] + const targetPane = layout === undefined ? undefined : placement.paneId ?? activeDockPaneId(layout) + const target = targetPane === undefined ? undefined : layout?.nodes[targetPane] const preferNewPane = placement.preferNewPane === true - && placement.replaceTab === undefined - && surface !== undefined + && replaced === undefined + && layout !== undefined && target?.kind === 'pane' && target.host === 'dock' && target.tabs.length > 0 - && canSplit(surface.layout) - && dockPaneIds(surface.layout).length < 2 + && canSplit(layout) + && dockPaneIds(layout).length < 2 && this.mounted.getSnapshot() === sessionId && this.canSplitPane(sessionId, target.id) const commit = (): void => { actions.openContent(sessionId, { @@ -457,18 +513,56 @@ export class SidebarRightController implements ISidebarRight { title: claim.title, ...placement.paneId === undefined ? {} : { paneId: placement.paneId }, ...preferNewPane ? { preferNewPane: true } : {}, - ...placement.replaceTab === undefined ? {} : { replaceTab: placement.replaceTab }, + ...replaced === undefined ? {} : { replaceTab: replaced.id }, ...placement.revealIfOpened === undefined ? {} : { revealIfOpened: placement.revealIfOpened }, }, (tabId) => { this.tabDomain.navigate(sessionId, tabId, { address, params }) }) } - const layout = surface?.layout - const replaced = placement.replaceTab === undefined ? undefined : layout?.tabs[placement.replaceTab] - const replacementPane = replaced === undefined || layout === undefined ? undefined : findTabPane(layout, replaced.id) - const revealed = layout === undefined ? undefined + // A page is unique per pane: the replaced tab's pane, docked or floating, else the landing pane. + const pagePane = layout === undefined ? undefined + : replaced === undefined ? targetPane : findTabPane(layout, replaced.id).id + const revealed = layout === undefined || pagePane === undefined ? undefined : claim.contentId === pageAddress(claim.kind) - ? findPaneContentTab(layout, replacementPane?.host === 'dock' ? replacementPane.id : placement.paneId ?? activeDockPaneId(layout), claim.contentId, claim.kind) + ? findPaneContentTab(layout, pagePane, claim.contentId, claim.kind) : placement.revealIfOpened === false ? undefined : findContentTab(layout, claim.contentId, claim.kind) - if (replaced === undefined || replaced.id === revealed) { commit(); return } - this.removeAfterCleanup(sessionId, replaced, commit) + // Only new content takes the replaced tab's place; a reveal keeps it open. + if (replaced === undefined || revealed !== undefined) commit() + else this.removeAfterCleanup(sessionId, replaced, commit) + // A reveal focuses the tab where it is. + if (layout !== undefined && revealed !== undefined) this.flashShowing(sessionId, layout, revealed) + } + + /** + * The flash requests of one Session's seat: each open that reveals an + * existing tab names the pane holding it, with a serial that changes on + * every request. + * @param sessionId - the Session whose seat draws the highlight. + * @returns the Session's stable source. + */ + flashOf(sessionId: SessionId): ObservableSnapshot { + return this.flashSource(sessionId) + } + + private flashSource(sessionId: SessionId): SnapshotStore { + let source = this.flashes.get(sessionId) + if (source === undefined) { + source = createSnapshotStore(undefined) + this.flashes.set(sessionId, source) + } + return source + } + + /** + * Flash the pane holding a revealed tab only when that tab was already on screen; a reveal that switches + * tabs or expands the column is its own visible change. + */ + private flashShowing(sessionId: SessionId, layout: LayoutState, tabId: TabId): void { + const pane = findTabPane(layout, tabId) + if (pane.activeTabId === tabId && (pane.host === 'float' || layout.expanded)) this.flash(sessionId, pane.id) + } + + /** Ask a Session's seat to highlight the pane holding a revealed tab. */ + private flash(sessionId: SessionId, paneId: PaneId): void { + const source = this.flashSource(sessionId) + source.set({ paneId, serial: (source.getSnapshot()?.serial ?? 0) + 1 }) } /** @@ -572,11 +666,15 @@ export class SidebarRightController implements ISidebarRight { if (target.host === 'float' && tab?.kind === kind && this.tabs.get(kind)?.multiple !== true) { actions.setExpanded(sessionId, true) this.focus(tab.id) + this.flash(sessionId, target.paneId) } else { + const guide = tab?.kind === 'guide' ? tab : undefined this.openTab(kind, { ...target.host === 'dock' ? { paneId: target.paneId } : {}, - ...tab?.kind === 'guide' ? { replaceTab: tab.id } : {}, + ...guide === undefined ? {} : { replaceTab: guide.id }, }) + // A reveal leaves the replaced guide open; it closes once it has led somewhere. + if (guide !== undefined) this.closeIn(sessionId, guide.id) } return this.mountedSurface()?.layout.activePaneId }) @@ -692,7 +790,7 @@ export class SidebarRightController implements ISidebarRight { } /** - * Return a floating panel's tab to the active docked pane; a missing or docked pane is left alone. + * Return a floating panel's tab to the active docked pane and expand the column; a missing or docked pane is left alone. * @param paneId - the floating pane. */ dock(paneId: PaneId): void { diff --git a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css index cbb739df43..02da4aad58 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css +++ b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css @@ -197,3 +197,96 @@ min-width: 0; overflow: hidden; } + +/* + * An open that reveals an existing tab makes the pane holding it glow once + * (`flash.ts`), a softer, tighter version of the files navigator card's glow + * (ui-sidebar-files FilesBody `.push`): brand-colored in light mode, white in + * dark mode. Only a reveal of a tab already on screen glows; a reveal that + * switches tabs or expands the column is visible on its own. The + * marker alternates between two values with their own keyframes, so a + * repeated reveal restarts the glow; the marker goes when the animation ends. + * Reduced motion holds the glow steady for the same duration instead of + * fading it. + * + * A floating panel glows outward and keeps its own shadow beneath the glow. + * A docked pane fills the column, whose `overflow: clip` would cut an outward + * glow, so it glows inward on a pseudo-element at 7: above a CodeBlock's + * sticky banner (6) and below the kit's drop hints (10) and tab menu (70). + * The pane's cell is its stacking context, so the layer never covers another + * pane, a panel, or a portaled menu, and it takes no pointer events. + */ +.panel :global([data-dockkit-pane][data-sidebar-right-flash]), +.panel :global([data-dockkit-float][data-sidebar-right-flash]) { + --sidebar-right-flash-color: var(--dsw-alias-brand-primary-new-colorprimary-new-color); + --sidebar-right-flash-ring: color-mix(in srgb, var(--sidebar-right-flash-color) 48%, transparent); + --sidebar-right-flash-near: color-mix(in srgb, var(--sidebar-right-flash-color) 16%, transparent); + --sidebar-right-flash-far: color-mix(in srgb, var(--sidebar-right-flash-color) 10%, transparent); +} + +:global(body[data-ds-dark-theme]) .panel :global([data-dockkit-pane][data-sidebar-right-flash]), +:global(body[data-ds-dark-theme]) .panel :global([data-dockkit-float][data-sidebar-right-flash]) { + --sidebar-right-flash-color: var(--dsw-static-neutral-00); +} + +.panel :global([data-dockkit-pane][data-sidebar-right-flash]) { + position: relative; +} + +.panel :global([data-dockkit-pane][data-sidebar-right-flash])::after { + content: ''; + position: absolute; + inset: 0; + z-index: 7; + pointer-events: none; + --sidebar-right-flash-from: + inset 0 0 0 1px var(--sidebar-right-flash-ring), + inset 0 2px 4px 0 var(--sidebar-right-flash-near), + inset 0 0 10px 0 var(--sidebar-right-flash-far); + --sidebar-right-flash-to: + inset 0 0 0 1px transparent, inset 0 2px 4px 0 transparent, inset 0 0 10px 0 transparent; +} + +.panel :global([data-dockkit-float][data-sidebar-right-flash]) { + --sidebar-right-flash-from: + var(--dsw-elevation-prominent), + 0 0 0 1px var(--sidebar-right-flash-ring), + 0 2px 4px 0 var(--sidebar-right-flash-near), + 0 0 10px 0 var(--sidebar-right-flash-far); + --sidebar-right-flash-to: + var(--dsw-elevation-prominent), + 0 0 0 1px transparent, 0 2px 4px 0 transparent, 0 0 10px 0 transparent; +} + +.panel :global([data-dockkit-pane][data-sidebar-right-flash])::after, +.panel :global([data-dockkit-float][data-sidebar-right-flash]) { + animation-duration: calc(var(--ds-transition-duration-slow) * 2); + animation-timing-function: var(--ds-ease-in-out); +} + +.panel :global([data-dockkit-pane][data-sidebar-right-flash='odd'])::after, +.panel :global([data-dockkit-float][data-sidebar-right-flash='odd']) { + animation-name: flashOdd; +} + +.panel :global([data-dockkit-pane][data-sidebar-right-flash='even'])::after, +.panel :global([data-dockkit-float][data-sidebar-right-flash='even']) { + animation-name: flashEven; +} + +@keyframes flashOdd { + from { box-shadow: var(--sidebar-right-flash-from); } + to { box-shadow: var(--sidebar-right-flash-to); } +} + +@keyframes flashEven { + from { box-shadow: var(--sidebar-right-flash-from); } + to { box-shadow: var(--sidebar-right-flash-to); } +} + +@media (prefers-reduced-motion: reduce) { + .panel :global([data-dockkit-pane][data-sidebar-right-flash])::after, + .panel :global([data-dockkit-float][data-sidebar-right-flash]) { + animation-timing-function: step-end; + } +} diff --git a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx index 129daec8eb..e0182a611b 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx +++ b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx @@ -44,13 +44,14 @@ import type { HalvesFit, LayoutState, PaneId } from '@deepseek-ai/dsh-client-ui- import type { SessionId } from '@deepseek-ai/dsh-session/types' import { GUIDE_KIND, pageAddress } from '../contract/seed.ts' import { dockLabels } from '../labels.ts' -import type { SidebarRightOpenTabOptions } from '../service.ts' +import type { SidebarRightFlash, SidebarRightOpenTabOptions } from '../service.ts' import type { SidebarRightTabDefinition } from '../tab-registry.ts' import type { createSidebarRightStore, SurfaceState } from '../stores.ts' import { canCloseTab } from '../stores.ts' import type { TabOccurrence } from '../tab-domain.ts' import type { SidebarRightTabNavigation } from '../contract/slots.ts' import type { TabHookContext } from '../tab-info.ts' +import { flashPane } from './flash.ts' import css from './SidebarRight.module.css' /** The store share the seat receives. */ @@ -110,6 +111,8 @@ export interface SidebarRightInjected { readonly contextMenuTarget: HostObservable readonly shortcuts: HostObservable readonly tabTypes: HostObservable + /** This Session's latest request to make the pane holding a revealed tab glow. */ + readonly flash: HostObservable } readonly keyedHooks: { readonly tabNavigation: (key: string) => HostObservable @@ -143,6 +146,7 @@ interface PanelProps { readonly useTabTypes: RightbarSeatProps['useTabTypes'] readonly useTabNavigation: RightbarSeatProps['useTabNavigation'] readonly useStore: Store['useStore'] + readonly useFlash: RightbarSeatProps['useFlash'] readonly occurrence: SidebarRightInjected['occurrence'] readonly fullscreen: boolean readonly autoFullscreen: boolean @@ -309,6 +313,17 @@ function SidebarPanel(panel: PanelProps & { width: number; panelRef: RefObject value) + const flash = panel.useFlash(value => value) + // Each request plays at most once, and only on screen: the request current + // when the panel mounts, or one arriving while its Session is hidden, is + // already past. Hiding the Session ends a glow in progress. + const played = useRef(flash?.serial) + // The glow is DOM-only: the docking kit owns the pane elements it marks. + useLayoutEffect(() => { + if (flash === undefined || flash.serial === played.current) return undefined + played.current = flash.serial + return panel.active ? flashPane(panelRef.current, flash) : undefined + }, [flash, panel.active, panelRef]) // True from the render that opens the on-screen panel until a transition in // it ends, the panel closes, or it leaves the screen, which cancels the // slide; the stylesheet starts items mounted meanwhile at the closed position. @@ -370,7 +385,7 @@ function SidebarPanel(panel: PanelProps & { width: number; panelRef: RefObject @@ -440,7 +455,7 @@ export function RightbarSeat({ if (surface === undefined) return null const panel: PanelProps = { sessionId, actions, t, renderSlot, surface, openTab, closeTab, getTabTriggerProps, tabMenuOpen, useTabTypes, useTabNavigation, - useStore, occurrence, + useStore, useFlash, occurrence, fullscreen, autoFullscreen, reportRoom, active, retainTab, shortcuts, splitPane, toggleFullscreen, } return diff --git a/packages/client/ui-sidebar-right/src/client/shell/flash.ts b/packages/client/ui-sidebar-right/src/client/shell/flash.ts new file mode 100644 index 0000000000..75780e09de --- /dev/null +++ b/packages/client/ui-sidebar-right/src/client/shell/flash.ts @@ -0,0 +1,29 @@ +/** The glow a reveal draws once on the pane holding the revealed tab; the stylesheet owns its animation. */ +import type { SidebarRightFlash } from '../service.ts' + +/** + * Mark the pane a flash request names, docked or floating, until its animation ends. + * @param root - the Session's panel, which contains its docked and floating panes. + * @param flash - the latest request; alternating serials select alternating keyframes, so a repeat restarts. + * @returns the cleanup that removes the mark, or undefined when no pane was marked. + */ +export function flashPane(root: HTMLElement | null, flash: SidebarRightFlash | undefined): (() => void) | undefined { + if (root === null || flash === undefined) return undefined + const pane = root.querySelector(`[data-dockkit-pane="${flash.paneId}"], [data-dockkit-float="${flash.paneId}"]`) + if (pane === null) return undefined + // A repeat on the same pane cancels the previous keyframes after this mark is set, so only this glow's own animation ends it. + let started: string | undefined + const start = (event: AnimationEvent): void => { if (event.target === pane) started = event.animationName } + const end = (event: AnimationEvent): void => { if (event.target === pane && event.animationName === started) clear() } + const clear = (): void => { + pane.removeEventListener('animationstart', start) + pane.removeEventListener('animationend', end) + pane.removeEventListener('animationcancel', end) + delete pane.dataset.sidebarRightFlash + } + pane.dataset.sidebarRightFlash = flash.serial % 2 === 0 ? 'even' : 'odd' + pane.addEventListener('animationstart', start) + pane.addEventListener('animationend', end) + pane.addEventListener('animationcancel', end) + return clear +} diff --git a/packages/client/ui-sidebar-right/src/client/stores.ts b/packages/client/ui-sidebar-right/src/client/stores.ts index 9478980de6..142de493fe 100644 --- a/packages/client/ui-sidebar-right/src/client/stores.ts +++ b/packages/client/ui-sidebar-right/src/client/stores.ts @@ -29,7 +29,7 @@ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' import { clearSidebarLayout, readSidebarLayout, writeSidebarLayout } from './persistence.ts' import type { - DockMode, DockZone, FloatRect, History, LayoutOp, LayoutState, Mint, PaneId, SplitId, TabId, TabRecord, + DockMode, DockZone, FloatRect, History, LayoutOp, LayoutState, Mint, PaneId, PlannedTab, SplitId, TabId, TabRecord, } from '@deepseek-ai/dsh-client-ui-dockkit' import { activeDockPaneId, createInitialState, dockPaneIds, EMPTY_HISTORY, findContentTab, findPaneContentTab, findTabPane, getPane, @@ -37,6 +37,7 @@ import { planSetMode, planSettle, planSplitPane, planUnfloatPane, record, replay, stepBack, stepForward, } from '@deepseek-ai/dsh-client-ui-dockkit' import { GUIDE_KIND, pageAddress, type SidebarRightSeed } from './contract/seed.ts' +import type { SidebarRightDestination } from './contract/slots.ts' /** One session's docking surface: the layout, its sequence, and the id counter. */ export interface SurfaceState { @@ -89,10 +90,67 @@ export interface OpenContentIntent { readonly paneId?: PaneId /** Split the target pane and put new content alone in the new pane. */ readonly preferNewPane?: boolean - /** Take this tab's pane and slot, and close it in the same entry. */ + /** Land new content in this tab's place and close it in the same entry; a reveal keeps it. See `SidebarRightPlacement.replaceTab`. */ readonly replaceTab?: TabId /** Resource tabs reveal an existing identity by default; `false` permits duplicates. Pages always deduplicate within the target pane. */ readonly revealIfOpened?: boolean + /** Settle in this host instead, ignoring every placement field above; see `SidebarRightOpenResourceOptions.destination`. */ + readonly destination?: SidebarRightDestination +} + +/** + * First tab showing (kind, contentId) in one host: docked panes in visual + * order, or floating panels from the bottom of the stack. + * @param state - current layout. + * @param destination - the host searched. + * @param contentId - the content identity. + * @param kind - the claimed kind. + * @returns the tab, or `undefined` when that host shows no such content. + */ +export function findHostContentTab( + state: LayoutState, destination: SidebarRightDestination, contentId: string, kind: string, +): TabId | undefined { + const panes = destination === 'sidebar' ? dockPaneIds(state) : state.floats + for (const paneId of panes) { + const found = findPaneContentTab(state, paneId, contentId, kind) + if (found !== undefined) return found + } + return undefined +} + +/** + * Plan an open into an explicit host: reveal the copy already there, else move + * one copy from the other host, else open a new tab there. A move keeps the + * tab record, so its occurrence survives the commit. + * @param state - current layout. + * @param mint - id source for a new tab or floating pane. + * @param intent - the claimed content and its host. + * @param destination - the host. + * @returns the operations plus the tab they settle on. + */ +function planOpenIn(state: LayoutState, mint: Mint, intent: OpenContentIntent, destination: SidebarRightDestination): PlannedTab { + const { kind, contentId, title } = intent + const other = destination === 'sidebar' ? 'float' : 'sidebar' + const here = findHostContentTab(state, destination, contentId, kind) + const there = here === undefined ? findHostContentTab(state, other, contentId, kind) : undefined + if (destination === 'sidebar') { + const expand = planSetExpanded(state, true) + if (here !== undefined) return { ops: [...expand, { type: 'focusTab', tabId: here }], tabId: here } + const paneId = activeDockPaneId(state) + if (there !== undefined) return { ops: [...expand, ...planUnfloatPane(state, findTabPane(state, there).id, paneId)], tabId: there } + const opened = planOpenContent(state, mint, { kind, contentId, title, paneId, revealIfOpened: false }) + return { ops: [...expand, ...opened.ops], tabId: opened.tabId } + } + if (here !== undefined) return { ops: [{ type: 'focusTab', tabId: here }], tabId: here } + if (there !== undefined) return { ops: planFloatTab(state, mint, there).ops, tabId: there } + // A new floating tab passes through the default docked pane inside this one + // entry, seated just after that pane's active tab so the pane's selection is + // what it was once the tab leaves; no state between the two is ever committed. + const paneId = activeDockPaneId(state) + const pane = getPane(state, paneId) + const index = pane.activeTabId === undefined ? 0 : pane.tabs.indexOf(pane.activeTabId) + 1 + const opened = planOpenContent(state, mint, { kind, contentId, title, paneId, index, revealIfOpened: false }) + return { ops: [...opened.ops, ...planFloatTab(state, mint, opened.tabId).ops], tabId: opened.tabId } } /** A mint that counts, so the surface can carry its position forward. */ @@ -302,28 +360,52 @@ export function createSidebarRightStore( }) }, // One entry carries the whole open: revealing the column (an open behind - // a collapsed panel is not an open), focusing or seating the tab, and - // closing the tab it replaces. `settled` reports the tab the planner - // landed on, synchronously, because actions return nothing. + // a collapsed panel is not an open) unless it reveals or replaces a + // floating tab, focusing or seating the tab, and closing the tab new + // content replaces. `settled` reports the tab the planner landed on, + // synchronously, because actions return nothing. openContent: (d, sessionId: string, intent, settled) => { d.bySession = seat(d, sessionId, s => advance(s, (state, mint) => { - const { kind, contentId, title, replaceTab: replace } = intent - const ops: LayoutOp[] = [...planSetExpanded(state, true)] - // A replaced tab lends its pane and slot; one that floats cannot (a - // floating pane holds one tab), so the new tab lands as if unplaced. + if (intent.destination !== undefined) { + const planned = planOpenIn(state, mint, intent, intent.destination) + settled(planned.tabId) + return planned.ops + } + const { kind, contentId, title } = intent + // A `replaceTab` tab that has closed since names no place, as the controller reads it. + const replace = intent.replaceTab !== undefined && state.tabs[intent.replaceTab] !== undefined ? intent.replaceTab : undefined + // A docked replaced tab lends its pane and slot; a floating one is + // replaced in its panel below, because a floating pane holds one tab. const replaced = replace === undefined ? undefined : findTabPane(state, replace) - const lent = replace !== undefined && replaced !== undefined && replaced.host === 'dock' ? replaced : undefined + const lent = replaced?.host === 'dock' ? replaced : undefined const paneId = lent?.id ?? intent.paneId const index = lent === undefined || replace === undefined ? undefined : lent.tabs.indexOf(replace) - // A page is unique per pane, not per surface: the pane it would land - // in may already show it, which is then the tab this open settles on. - // The same page in another pane never draws the open away — the kit's - // cross-pane reveal is for resources only. + // A page is unique per pane, not per surface: the replaced tab's pane, + // docked or floating, or the pane it would land in may already show + // it, which is then the tab this open settles on. The same page in + // another pane never draws the open away — the kit's cross-pane + // reveal is for resources only. const page = contentId === pageAddress(kind) - const held = page ? panePage(state, paneId ?? activeDockPaneId(state), kind) : undefined + const held = page ? panePage(state, replaced?.id ?? paneId ?? activeDockPaneId(state), kind) : undefined const revealed = page ? held : intent.revealIfOpened === false ? undefined : findContentTab(state, contentId, kind) + // New content replacing a floating tab takes its place in the same + // pane, at its rectangle and stacking index, without expanding the column. + if (replace !== undefined && revealed === undefined && replaced?.host === 'float') { + const tab = { id: mint('tab'), kind, contentId, title } + settled(tab.id) + return [ + { type: 'closeTab', tabId: replace }, + { type: 'insertPane', pane: { ...replaced, tabs: [tab.id], activeTabId: tab.id }, tabs: [tab], + attach: { mode: 'float', index: state.floats.indexOf(replaced.id) } }, + { type: 'focusTab', tabId: tab.id }, + ] + } + // A reveal of a floating tab raises its panel and leaves the column as it is. + const ops: LayoutOp[] = revealed !== undefined && findTabPane(state, revealed).host === 'float' + ? [] + : [...planSetExpanded(state, true)] let openedInNewPane: TabId | undefined const split = intent.preferNewPane === true && replace === undefined && revealed === undefined ? planSplitPane(state, mint, paneId, (id) => { @@ -346,7 +428,8 @@ export function createSidebarRightStore( : intent.revealIfOpened === undefined ? {} : { revealIfOpened: intent.revealIfOpened }, }) ops.push(...planned.ops) - if (replace !== undefined && replace !== planned.tabId) ops.push({ type: 'closeTab', tabId: replace }) + // A reveal keeps the replaced tab; only new content takes its place. + if (replace !== undefined && revealed === undefined) ops.push({ type: 'closeTab', tabId: replace }) settled(planned.tabId) return ops }, seed)) @@ -397,14 +480,16 @@ export function createSidebarRightStore( d.bySession = seat(d, sessionId, s => advance(s, (state, mint) => planFloatTab(state, mint, tabId, rect).ops, seed)) }, - // A floating pane holds one tab; docking it back lands in the active - // docked pane, and only a page is subject to the merge rule there. + // A floating pane holds one tab; docking it back expands the column and + // lands in the active docked pane, and only a page is subject to the + // merge rule there. unfloatPane: (d, sessionId: string, paneId: PaneId) => { d.bySession = seat(d, sessionId, s => advance(s, (state) => { const floated = getPane(state, paneId).tabs[0] const plan = (): readonly LayoutOp[] => planUnfloatPane(state, paneId) /* v8 ignore next -- a floating pane holds exactly one tab. */ - return floated === undefined ? plan() : arriving(state, floated, activeDockPaneId(state), plan) + const ops = floated === undefined ? plan() : arriving(state, floated, activeDockPaneId(state), plan) + return [...planSetExpanded(state, true), ...ops] }, seed)) }, moveFloat: (d, sessionId: string, paneId: PaneId, x: number, y: number) => { diff --git a/packages/client/ui-sidebar-right/src/client/tab-domain.ts b/packages/client/ui-sidebar-right/src/client/tab-domain.ts index df3fe66f96..625dba6759 100644 --- a/packages/client/ui-sidebar-right/src/client/tab-domain.ts +++ b/packages/client/ui-sidebar-right/src/client/tab-domain.ts @@ -194,8 +194,11 @@ export class TabDomain { controller.signal.addEventListener('abort', release, { once: true }) return release }, + // An explicit host ignores this tab's pane and never names it for replacement. openResource: (address, options = {}) => { - navigator.openResourceIn(sessionId, address, { ...place(options), params: options.params }) + navigator.openResourceIn(sessionId, address, options.destination === undefined + ? { ...place(options), params: options.params } + : { destination: options.destination, params: options.params }) }, openTab: (kind, options = {}) => { navigator.openTabIn(sessionId, kind, { ...place(options), params: options.params }) diff --git a/packages/client/ui-sidebar-right/src/client/tab-info.ts b/packages/client/ui-sidebar-right/src/client/tab-info.ts index 7c109e7b40..874525e6ee 100644 --- a/packages/client/ui-sidebar-right/src/client/tab-info.ts +++ b/packages/client/ui-sidebar-right/src/client/tab-info.ts @@ -40,7 +40,7 @@ export const tabInfoFactory: SlotHookFactory<'sidebar.right.pane.tab', UseSideba const pane = findTabPane(layout, tabId) return { sidebar: { expanded: layout.expanded, fullscreen }, - panel: { id: pane.id }, + panel: { id: pane.id, isFloating: pane.host === 'float' }, tab: { ...tab, visible: active && (pane.host === 'float' || (layout.expanded && (title || pane.activeTabId === tabId))), diff --git a/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx index febe9f1954..4cff92b514 100644 --- a/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx +++ b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx @@ -134,6 +134,11 @@ export function GuideBody({ const entries = useGuideEntries(entries => entries) const unpinned = useEntryPins(ids => ids) const hasEntryActions = useEntryActions(registered => registered) + // A new page replaces the guide; a page already in its pane is revealed, and the guide then closes itself. + const open = (kind: string): void => { + tab.actions.openTab(kind, { replaceTab: true }) + tab.actions.close() + } const visibleEntries = entries.filter(entry => entry.kind === FIXED_GUIDE_KIND || !unpinned.includes(guideEntryId(entry))) const options = { hookContext: useTabInfo, @@ -148,7 +153,7 @@ export function GuideBody({ }, { entryKey: entry.providerId, hookContext: useTabInfo, fallback: shortcut.id === entry.commandId)} - onPick={(selected) => { tab.actions.openTab(selected.kind, { replaceTab: true }) }} />, + onPick={(selected) => { open(selected.kind) }} />, })}
    })} @@ -159,7 +164,7 @@ export function GuideBody({ .map(entry => ({ id: guideEntryId(entry), label: entry.title(), pinned: !unpinned.includes(guideEntryId(entry)) }))} onOpen={(id) => { const entry = entries.find(candidate => guideEntryId(candidate) === id) - if (entry !== undefined) tab.actions.openTab(entry.kind, { replaceTab: true }) + if (entry !== undefined) open(entry.kind) }} onPinnedChange={setPinned} renderSlot={renderSlot} diff --git a/packages/client/ui-sidebar-right/tests/controller-harness.client.ts b/packages/client/ui-sidebar-right/tests/controller-harness.client.ts new file mode 100644 index 0000000000..71e5c087f2 --- /dev/null +++ b/packages/client/ui-sidebar-right/tests/controller-harness.client.ts @@ -0,0 +1,61 @@ +/** A real registry, controller and adopted Session store; each spec decides when to show it. */ +import { vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { LayoutState, TabId } from '@deepseek-ai/dsh-client-ui-dockkit' +import { createSidebarRightController } from '../src/client/service.ts' +import { SidebarRightTabRegistry } from '../src/client/tab-registry.ts' +import { createSidebarRightStore } from '../src/client/stores.ts' +import { guideDefinition } from '../src/client/tabs/guide/definition.ts' + +// A test-only scheme supplies typed parameters without depending on a resource plugin. +declare module '../src/client/contract/params.ts' { + interface SidebarRightResourceParamsMap { + test: { line?: number; x?: number } + } +} + +export const SESSION = 's-test' as SessionId +const t = ((key: string) => key) as Parameters[0] + +/** Construct a controller without publishing its Session or creating its surface. */ +export function controllerHarness() { + const tabs = new SidebarRightTabRegistry(new Context()) + tabs.register(guideDefinition(t)) + tabs.register({ + id: 'test/text', kind: 'text', patterns: ['dsh-resource://file/**'], priority: 'fallback', + title: address => address.slice(address.lastIndexOf('/') + 1), + }) + const pin = vi.fn<(address: string, signal: AbortSignal) => void>() + const { controller, adopt, forget, show, measure } = createSidebarRightController(tabs, pin, { + autoFullscreen: () => false, + openWithFocus: (_sessionId, open) => { open() }, + closeWithFocus: (_sessionId, _paneId, close) => { close() }, + }) + const instance = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed' })).create() + const unadopt = adopt(SESSION, instance) + const layout = (): LayoutState => { + const surface = instance.getSnapshot().bySession[SESSION] + if (surface === undefined) throw new Error('expected a surface') + return surface.layout + } + const room = { allowed: true } + /** Publish with the room rule a rendered seat reports. */ + const publish = (): (() => void) => { + show(SESSION) + measure(SESSION, () => room.allowed) + return () => { show(undefined) } + } + const titles = (): string[] => { + const surface = instance.getSnapshot().bySession[SESSION] + return surface === undefined ? [] : Object.values(surface.layout.tabs).map(tab => tab.title) + } + const tabOf = (title: string): TabId => { + const found = Object.values(layout().tabs).find(tab => tab.title === title) + if (found === undefined) throw new Error(`expected a tab titled ${title}`) + return found.id + } + const entries = (): number => instance.getSnapshot().bySession[SESSION]?.history.entries.length ?? 0 + const expand = (): void => { instance.actions.setExpanded(SESSION, true) } + return { controller, adopt, unadopt, forget, show, measure, tabs, instance, pin, publish, titles, tabOf, layout, entries, room, expand } +} diff --git a/packages/client/ui-sidebar-right/tests/flash.client.spec.tsx b/packages/client/ui-sidebar-right/tests/flash.client.spec.tsx new file mode 100644 index 0000000000..dfce9285e2 --- /dev/null +++ b/packages/client/ui-sidebar-right/tests/flash.client.spec.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom +/** The glow a reveal draws on the revealed tab's pane. */ +import { afterEach, expect, it } from 'vitest' +import { act } from '@testing-library/react' +import type { PaneId } from '@deepseek-ai/dsh-client-ui-dockkit' +import { flashPane } from '../src/client/shell/flash.ts' + +afterEach(() => { document.body.replaceChildren() }) + +/** jsdom has no AnimationEvent, so the event carries its name as a plain property. */ +function animation(target: Element, type: 'animationstart' | 'animationend' | 'animationcancel', animationName: string): void { + act(() => { target.dispatchEvent(Object.assign(new Event(type, { bubbles: true }), { animationName })) }) +} + +it('marks the named docked or floating pane until its own animation ends', () => { + document.body.innerHTML = '

    ' + const root = document.getElementById('root')! + const docked = root.querySelector('[data-dockkit-pane]')! + flashPane(root, { paneId: 'pane1' as PaneId, serial: 1 }) + expect(docked.dataset.sidebarRightFlash).toBe('odd') + animation(docked, 'animationstart', 'flashOdd') + // A child's animation is not the glow's. + animation(docked.querySelector('p')!, 'animationstart', 'spin') + animation(docked.querySelector('p')!, 'animationend', 'flashOdd') + expect(docked.dataset.sidebarRightFlash).toBe('odd') + animation(docked, 'animationend', 'flashOdd') + expect(docked.dataset.sidebarRightFlash).toBeUndefined() + const floating = root.querySelector('[data-dockkit-float]')! + flashPane(root, { paneId: 'pane2' as PaneId, serial: 2 }) + expect(floating.dataset.sidebarRightFlash).toBe('even') + animation(floating, 'animationstart', 'flashEven') + animation(floating, 'animationcancel', 'flashEven') + expect(floating.dataset.sidebarRightFlash).toBeUndefined() + // The cleanup removes the mark at once, as the seat does before the next request. + const clear = flashPane(root, { paneId: 'pane2' as PaneId, serial: 3 })! + expect(floating.dataset.sidebarRightFlash).toBe('odd') + clear() + expect(floating.dataset.sidebarRightFlash).toBeUndefined() +}) + +it('keeps a repeated glow on the same pane through the cancellation of the previous one', () => { + document.body.innerHTML = '
    ' + const root = document.getElementById('root')! + const pane = root.querySelector('[data-dockkit-pane]')! + const clear = flashPane(root, { paneId: 'pane1' as PaneId, serial: 1 })! + animation(pane, 'animationstart', 'flashOdd') + clear() + flashPane(root, { paneId: 'pane1' as PaneId, serial: 2 }) + // The browser cancels the replaced keyframes before the new ones start. + animation(pane, 'animationcancel', 'flashOdd') + expect(pane.dataset.sidebarRightFlash).toBe('even') + animation(pane, 'animationstart', 'flashEven') + animation(pane, 'animationend', 'flashOdd') + expect(pane.dataset.sidebarRightFlash).toBe('even') + animation(pane, 'animationend', 'flashEven') + expect(pane.dataset.sidebarRightFlash).toBeUndefined() +}) + +it('marks nothing without a panel, a request, or the named pane', () => { + document.body.innerHTML = '
    ' + const root = document.getElementById('root')! + expect(flashPane(null, { paneId: 'pane1' as PaneId, serial: 1 })).toBeUndefined() + expect(flashPane(root, undefined)).toBeUndefined() + expect(flashPane(root, { paneId: 'pane1' as PaneId, serial: 1 })).toBeUndefined() +}) diff --git a/packages/client/ui-sidebar-right/tests/focus.client.spec.ts b/packages/client/ui-sidebar-right/tests/focus.client.spec.ts index b949dd5e53..aed8c039a8 100644 --- a/packages/client/ui-sidebar-right/tests/focus.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/focus.client.spec.ts @@ -174,6 +174,34 @@ describe('sidebar focus targets', () => { expect(getPane(h.layout(), floating.paneId).tabs).toHaveLength(1) }) + it('closes a focused docked guide after the page it opens is revealed in its pane', () => { + const h = harness() + const guide = h.controller.active()!.id + h.controller.openTab('files') + const files = h.controller.active()!.id + const target = h.controller.focusedTarget(h.tabElement(guide))! + h.controller.openTabFromTarget('files', target) + expect(Object.keys(h.layout().tabs)).toEqual([files]) + expect(h.controller.active()?.id).toBe(files) + }) + + it.each([false, true])('replaces a focused floating guide in its panel, even while a docked pane shows the page (page docked: %s)', (docked) => { + const h = harness() + h.controller.openTab('terminal') + if (docked) h.controller.openTab('files') + const guide = Object.values(h.layout().tabs).find(tab => tab.kind === 'guide')!.id + h.controller.float(guide) + const panel = findTabPane(h.layout(), guide) + const target = h.controller.focusedTarget(h.tabElement(guide))! + h.controller.openTabFromTarget('files', target) + expect(h.layout().tabs[guide]).toBeUndefined() + const files = Object.values(h.layout().tabs).filter(tab => tab.kind === 'files') + expect(files).toHaveLength(docked ? 2 : 1) + const floating = files.map(tab => findTabPane(h.layout(), tab.id)).find(pane => pane.host === 'float')! + expect(floating.id).toBe(panel.id) + expect(floating.rect).toEqual(panel.rect) + }) + it('shares split limits and fullscreen behavior while preserving floating pages', () => { const h = harness() const target = h.controller.commandTarget(null)! diff --git a/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx b/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx index 352860bc2d..b8ed43cf9c 100644 --- a/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx @@ -70,14 +70,15 @@ function mountGuide(entries: readonly SidebarRightGuideBox[], custom?: (key: str const setPinned = vi.fn() let actionsOwner: SidebarRightGuideActionsOwnerProps | undefined const openTab = vi.fn() + const close = vi.fn() const renderSlot = vi.fn((_seat, _owner, options) => options?.fallback) const props: GuideBodyProps = { ...standard, SessionProvider: ({ children }) => children, useShortcuts: (selector: (entries: readonly ShortcutCatalogEntry[]) => T): T => selector(shortcuts), - useTabInfo: () => ({ sidebar: { expanded: true, fullscreen: false }, panel: { id: 'pane-guide' as PaneId }, + useTabInfo: () => ({ sidebar: { expanded: true, fullscreen: false }, panel: { id: 'pane-guide' as PaneId, isFloating: false }, tab: { ...TAB, visible: true, signal: new AbortController().signal, navigation: { address: TAB.contentId, params: undefined, revision: 0 }, - actions: { bindCommands: vi.fn(() => vi.fn()), openResource: vi.fn(), openTab, close: vi.fn() } } }), + actions: { bindCommands: vi.fn(() => vi.fn()), openResource: vi.fn(), openTab, close } } }), useGuideEntries: bindSnapshotSelector(guideEntries), useEntryPins: bindSnapshotSelector(entryPins), useEntryActions: bindSnapshotSelector(entryActions), @@ -96,7 +97,7 @@ function mountGuide(entries: readonly SidebarRightGuideBox[], custom?: (key: str const boxes = (): string[] => [...view.container.querySelectorAll('[data-sidebar-right-guide-entry]')].map(node => node.getAttribute('data-sidebar-right-guide-entry') ?? '') return { - view, guideEntries, entryPins, openTab, setPinned, renderSlot, entrySlot: props.renderSlot, boxes, useTabInfo: props.useTabInfo, + view, guideEntries, entryPins, openTab, close, setPinned, renderSlot, entrySlot: props.renderSlot, boxes, useTabInfo: props.useTabInfo, actionsOwner: () => { if (actionsOwner === undefined) throw new Error('guide actions not rendered') return actionsOwner @@ -137,8 +138,11 @@ describe('GuideBody', () => { expect(b.setPinned).toHaveBeenCalledExactlyOnceWith('terminal:terminal-20', true) b.actionsOwner().onOpen('removed:entry') expect(b.openTab).not.toHaveBeenCalled() + expect(b.close).not.toHaveBeenCalled() b.actionsOwner().onOpen('terminal:terminal-20') expect(b.openTab).toHaveBeenCalledExactlyOnceWith('terminal', { replaceTab: true }) + expect(b.close).toHaveBeenCalledOnce() + expect(b.close.mock.invocationCallOrder[0]).toBeGreaterThan(b.openTab.mock.invocationCallOrder[0]!) fireEvent.click(b.view.getByRole('menuitem', { name: 'Guide action' })) expect(b.view.queryByRole('menu')).toBeNull() expect(more.getAttribute('aria-expanded')).toBe('false') @@ -184,12 +188,14 @@ describe('GuideBody', () => { cleanup() }) - it('picking a box without a reveal preference preserves the default opening behavior', () => { - const { view, openTab } = mountGuide([box('files', 10)]) + it('picking a box opens its page in the guide\'s place, then closes the guide', () => { + const { view, openTab, close } = mountGuide([box('files', 10)]) const entry = view.container.querySelector('[data-sidebar-right-guide-entry="files"]') if (entry === null) throw new Error('expected the files box') fireEvent.click(entry) expect(openTab).toHaveBeenCalledWith('files', { replaceTab: true }) + expect(close).toHaveBeenCalledOnce() + expect(close.mock.invocationCallOrder[0]).toBeGreaterThan(openTab.mock.invocationCallOrder[0]!) cleanup() }) diff --git a/packages/client/ui-sidebar-right/tests/open-destination.client.spec.ts b/packages/client/ui-sidebar-right/tests/open-destination.client.spec.ts new file mode 100644 index 0000000000..024113b4ff --- /dev/null +++ b/packages/client/ui-sidebar-right/tests/open-destination.client.spec.ts @@ -0,0 +1,222 @@ +/** + * `openResource(address, { destination })`: an explicit host for a resource. + * + * Each case drives the controller over a real registry and store, because a + * reveal, a move, and a new tab are whatever one store intent plans, and the + * Tab domain must keep a moved tab's occurrence across that commit. + */ +import { describe, expect, it, vi } from 'vitest' +import type { PaneId, TabId } from '@deepseek-ai/dsh-client-ui-dockkit' +import { dockPaneIds, findTabPane, getPane } from '@deepseek-ai/dsh-client-ui-dockkit' +import { controllerHarness, SESSION } from './controller-harness.client.ts' + +const A = 'dsh-resource://file/session/s-test/a.txt' +const B = 'dsh-resource://file/session/s-test/b.txt' + +function harness() { + const { controller, instance, layout, entries, show } = controllerHarness() + show(SESSION) + const closed = vi.fn() + controller.registerCloseHandler('text', closed) + const copies = (title: string): TabId[] => Object.values(layout().tabs).filter(tab => tab.title === title).map(tab => tab.id) + const only = (title: string): TabId => { + const [id, ...rest] = copies(title) + if (id === undefined || rest.length > 0) throw new Error(`expected one ${title}`) + return id + } + const host = (tabId: TabId) => findTabPane(layout(), tabId).host + const occurrence = (tabId: TabId) => controller.tabDomain.occurrence(SESSION, { id: tabId }) + const titles = () => Object.values(layout().tabs).map(tab => tab.title).sort() + return { controller, instance, layout, entries, copies, only, host, occurrence, closed, titles } +} + +describe('openResource with a destination — six transitions', () => { + it('1. opens unopened content in the default docked pane and expands the column, without splitting or a seeded guide', () => { + const h = harness() + h.controller.openResource(A, { destination: 'sidebar', params: { line: 3 } }) + const a = h.only('a.txt') + expect(h.host(a)).toBe('dock') + expect(h.layout().expanded).toBe(true) + expect(dockPaneIds(h.layout())).toHaveLength(1) + expect(h.titles()).toEqual(['a.txt']) + expect(h.occurrence(a).navigation.getSnapshot()).toEqual({ address: A, params: { line: 3 }, revision: 1 }) + expect(h.entries()).toBe(1) + }) + + it('2. opens unopened content in a new floating panel on a surface not yet created, leaving the column collapsed and unseeded', () => { + const h = harness() + expect(h.instance.getSnapshot().bySession[SESSION]).toBeUndefined() + h.controller.openResource(A, { destination: 'float', params: { line: 7 } }) + const a = h.only('a.txt') + expect(h.host(a)).toBe('float') + expect(h.layout().floats).toHaveLength(1) + expect(h.layout().expanded).toBe(false) + expect(h.titles()).toEqual(['a.txt']) + expect(getPane(h.layout(), h.layout().rootId).tabs).toEqual([]) + expect(h.layout().activePaneId).toBe(findTabPane(h.layout(), a).id) + expect(h.occurrence(a).navigation.getSnapshot()).toEqual({ address: A, params: { line: 7 }, revision: 1 }) + expect(h.entries()).toBe(1) + }) + + it('2. opens a new floating panel beside other floating panels and keeps the docked selection and expansion', () => { + const h = harness() + h.controller.openResource(B, { destination: 'sidebar' }) + h.controller.openResource('dsh-resource://file/session/s-test/c.txt', { destination: 'float' }) + const root = getPane(h.layout(), h.layout().rootId) + h.controller.openResource(A, { destination: 'float' }) + const floats = h.layout().floats + expect(floats).toHaveLength(2) + const [first, second] = floats.map(id => getPane(h.layout(), id)) + expect(second!.tabs).toEqual([h.only('a.txt')]) + expect(second!.rect).not.toEqual(first!.rect) + expect(getPane(h.layout(), h.layout().rootId)).toEqual(root) + expect(h.layout().expanded).toBe(true) + }) + + it('3. reveals a docked copy where it is and flashes its pane once it is on screen', () => { + const h = harness() + h.controller.openResource(A, { destination: 'sidebar' }) + h.controller.openResource(B, { destination: 'sidebar' }) + const a = h.only('a.txt') + const held = h.occurrence(a) + h.controller.openResource(A, { destination: 'sidebar', params: { line: 9 } }) + expect(h.only('a.txt')).toBe(a) + expect(getPane(h.layout(), h.layout().activePaneId).activeTabId).toBe(a) + expect(h.occurrence(a)).toBe(held) + expect(held.navigation.getSnapshot()).toEqual({ address: A, params: { line: 9 }, revision: 2 }) + // Switching to the tab is the visible change; only a reveal of the tab already on screen flashes. + expect(h.controller.flashOf(SESSION).getSnapshot()).toBeUndefined() + h.controller.openResource(A, { destination: 'sidebar' }) + expect(h.controller.flashOf(SESSION).getSnapshot()).toEqual({ paneId: findTabPane(h.layout(), a).id, serial: 1 }) + }) + + it('4. moves a docked tab into a new floating panel, keeping its id, occurrence and the expansion, as one entry', () => { + const h = harness() + h.controller.openResource(B, { destination: 'sidebar' }) + h.controller.openResource(A, { destination: 'sidebar' }) + const a = h.only('a.txt') + const held = h.occurrence(a) + const before = h.entries() + h.controller.openResource(A, { destination: 'float', params: { line: 4 } }) + expect(h.only('a.txt')).toBe(a) + expect(h.host(a)).toBe('float') + expect(h.layout().floats).toHaveLength(1) + expect(h.layout().expanded).toBe(true) + expect(h.occurrence(a)).toBe(held) + expect(held.signal.aborted).toBe(false) + expect(held.navigation.getSnapshot()).toEqual({ address: A, params: { line: 4 }, revision: 2 }) + expect(h.closed).not.toHaveBeenCalled() + expect(h.controller.flashOf(SESSION).getSnapshot()).toBeUndefined() + expect(h.entries()).toBe(before + 1) + h.controller._undo() + expect(h.host(a)).toBe('dock') + expect(h.layout().floats).toEqual([]) + expect(h.occurrence(a)).toBe(held) + }) + + it('4. moves the last docked tab out under the empty-pane rule, without ending its occurrence', () => { + const h = harness() + h.controller.openResource(A, { destination: 'sidebar' }) + const a = h.only('a.txt') + const held = h.occurrence(a) + h.controller.openResource(A, { destination: 'float' }) + expect(h.host(a)).toBe('float') + expect(h.occurrence(a)).toBe(held) + expect(held.signal.aborted).toBe(false) + // An expanded column never stands empty: its root pane reseeds the default page in the same entry. + expect(h.titles()).toEqual(['a.txt', 'seed']) + expect(h.entries()).toBe(2) + }) + + it('5. reveals a floating copy, raising its panel, without another panel or expanding the column', () => { + const h = harness() + h.controller.openResource(A, { destination: 'float' }) + h.controller.openResource(B, { destination: 'float' }) + const a = h.only('a.txt') + const pane = findTabPane(h.layout(), a).id + h.controller.openResource(A, { destination: 'float', params: { line: 2 } }) + expect(h.layout().floats).toEqual([findTabPane(h.layout(), h.only('b.txt')).id, pane]) + expect(h.layout().activePaneId).toBe(pane) + expect(h.layout().expanded).toBe(false) + expect(h.occurrence(a).navigation.getSnapshot()).toMatchObject({ params: { line: 2 }, revision: 2 }) + expect(h.controller.flashOf(SESSION).getSnapshot()).toEqual({ paneId: pane, serial: 1 }) + }) + + it('6. moves a floating tab into the default docked pane and expands the column, keeping its occurrence, as one entry', () => { + const h = harness() + h.controller.openResource(A, { destination: 'float' }) + const a = h.only('a.txt') + const held = h.occurrence(a) + const before = h.entries() + h.controller.openResource(A, { destination: 'sidebar', params: { line: 5 } }) + expect(h.only('a.txt')).toBe(a) + expect(h.host(a)).toBe('dock') + expect(h.layout().floats).toEqual([]) + expect(h.layout().expanded).toBe(true) + // The collapsed empty root takes the tab; nothing else is seeded beside it. + expect(h.titles()).toEqual(['a.txt']) + expect(h.occurrence(a)).toBe(held) + expect(held.navigation.getSnapshot()).toEqual({ address: A, params: { line: 5 }, revision: 2 }) + expect(h.closed).not.toHaveBeenCalled() + expect(h.entries()).toBe(before + 1) + h.controller._undo() + expect(h.host(a)).toBe('float') + expect(h.layout().expanded).toBe(false) + }) +}) + +describe('openResource with a destination — copies and precedence', () => { + it('prefers the copy already in the requested host and leaves the other copies open', () => { + const h = harness() + h.controller.openResource(A, { destination: 'sidebar' }) + h.controller.openResource(A, { revealIfOpened: false }) + const docked = h.copies('a.txt') + h.instance.actions.floatTab(SESSION, docked[1]!) + h.controller.openResource(A, { destination: 'float' }) + expect(h.copies('a.txt')).toEqual(docked) + expect(h.layout().floats).toHaveLength(1) + expect(h.layout().activePaneId).toBe(findTabPane(h.layout(), docked[1]!).id) + h.controller.openResource(A, { destination: 'sidebar' }) + expect(h.host(docked[0]!)).toBe('dock') + expect(h.host(docked[1]!)).toBe('float') + expect(getPane(h.layout(), h.layout().activePaneId).activeTabId).toBe(docked[0]) + }) + + it('moves one copy from the other host when the requested one holds none', () => { + const h = harness() + h.controller.openResource(A, { destination: 'float' }) + h.controller.openResource(A, { destination: 'float', revealIfOpened: false }) + expect(h.layout().floats).toHaveLength(1) + h.controller.openResource(A, { revealIfOpened: false }) + const [floating, docked] = h.copies('a.txt') + h.instance.actions.floatTab(SESSION, docked!) + h.controller.openResource(A, { destination: 'sidebar' }) + expect([h.host(floating!), h.host(docked!)].sort()).toEqual(['dock', 'float']) + expect(h.copies('a.txt')).toHaveLength(2) + }) + + it('ignores a floating paneId, replaceTab, and preferNewPane, and runs no close handler', () => { + const h = harness() + h.controller.openResource(B, { destination: 'float' }) + const b = h.only('b.txt') + const floating: PaneId = findTabPane(h.layout(), b).id + h.controller.openResource(A, { destination: 'sidebar', paneId: floating, replaceTab: b, preferNewPane: true }) + expect(h.host(h.only('a.txt'))).toBe('dock') + expect(h.only('b.txt')).toBe(b) + expect(dockPaneIds(h.layout())).toHaveLength(1) + expect(h.closed).not.toHaveBeenCalled() + }) + + it('reaches a Session through openResourceIn', () => { + const h = harness() + h.controller.openResourceIn(SESSION, A, { destination: 'float' }) + expect(h.host(h.only('a.txt'))).toBe('float') + }) + + it('keeps the traditional placement when destination is omitted: a floating paneId still fails as before', () => { + const h = harness() + h.controller.openResource(B, { destination: 'float' }) + const floating = findTabPane(h.layout(), h.only('b.txt')).id + expect(() => { h.controller.openResource(A, { paneId: floating }) }).toThrow('openTab requires a docked pane') + }) +}) diff --git a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx index 7ee9fd3559..c6ae78c2e8 100644 --- a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx @@ -208,11 +208,69 @@ it('keeps duplicate Session Tabs independent and preserves a body through dockin expect(h.controller.focusedTarget()?.tabId).toBe(tab.id) act(() => { h.controller.dock(floating.paneId) }) expect(document.querySelector('[data-page-input]')).toBe(body) - expect(body.closest('[aria-hidden="true"]')).not.toBeNull() - act(() => { h.controller.toggleExpanded() }) + // Docking expands the collapsed column, which shows the same body. + expect(h.controller.isExpanded()).toBe(true) + expect(body.closest('[aria-hidden="true"]')).toBeNull() expect(body.closest('[data-dockkit-host]')).toBe(cell) }) +it('never detaches a retained page\'s embedded element across floating replacements, page opens, moves, and collapsing', async () => { + const h = await mountSeat() + // A Desktop browser page keeps its `webview` in the DOM; detaching it would reload the guest. + const webview = document.createElement('webview') + await act(async () => { + h.runtime.ctx.sidebarRightTabs.register({ id: 'test/webapp', kind: 'webapp', keepMounted: true, title: () => 'Web app' }) + h.runtime.slots.register({ name: 'sidebar.right.pane.tab', key: 'test/webapp' }, + () =>
    { if (host !== null && !host.contains(webview)) host.append(webview) }} />) + h.registerPage('files') + }) + act(() => { h.controller.openTab('webapp') }) + const page = h.controller.active()! + const parent = webview.parentElement + expect(webview.isConnected).toBe(true) + const observer = new MutationObserver(() => {}) + observer.observe(h.view.container, { childList: true, subtree: true }) + const detached = (): Node[] => observer.takeRecords() + .flatMap(record => [...record.removedNodes]).filter(node => node === webview || node.contains(webview)) + const kept = (): void => { + expect(detached()).toEqual([]) + expect(webview.isConnected).toBe(true) + expect(webview.parentElement).toBe(parent) + } + + // A floating tab is replaced in its panel by new content, then reveals content already open. + const a = h.open('a.txt') + act(() => { h.controller.float(a.id) }) + h.open('b.txt', { replaceTab: a.id }) + kept() + const floating = h.controller.active()! + h.open('c.txt') + h.open('c.txt', { replaceTab: floating.id }) + kept() + // A page opens from a docked target and from a floating one. + act(() => { h.controller.openTabFromTarget('files', h.controller.commandTarget()!) }) + kept() + const target = h.controller.focusedTarget(element(h.view.container, `[data-tab-body="${floating.id}"]`))! + act(() => { h.controller.openTabFromTarget('files', target) }) + kept() + // The page itself floats, docks, and moves to another pane. + act(() => { h.controller.float(page.id) }) + kept() + const float = h.layout().floats.find(id => getPane(h.layout(), id).tabs.includes(page.id))! + act(() => { h.controller.dock(float) }) + kept() + let other: PaneId | undefined + act(() => { other = h.controller.split() }) + act(() => { h.actions.placeTab(SESSION, page.id, other!, 0) }) + expect(getPane(h.layout(), other!).tabs).toContain(page.id) + kept() + act(() => { h.controller.toggleExpanded() }) + expect(h.layout().expanded).toBe(false) + act(() => { h.controller.toggleExpanded() }) + kept() + observer.disconnect() +}) + it('retains a collapsed Session Tab and remounts an unretained hidden body', async () => { const h = await mountSeat() let latest: SidebarRightTabInfo | undefined @@ -810,6 +868,9 @@ describe('slot-owned useTabInfo', () => { expect(bodyA.dataset['instance']).not.toBe(bodyB.dataset['instance']) expect(bodyA.dataset['tabBody']).toBe(a.id) const signal = h.bodies.get(a.id)!.tab.signal + const docked = h.bodies.get(a.id)!.panel.id + expect(h.bodies.get(a.id)?.panel).toEqual({ id: docked, isFloating: false }) + expect(h.titles.get(a.id)?.panel).toEqual({ id: docked, isFloating: false }) act(() => { h.actions.setExpanded(SESSION, false) }) expect(h.bodies.get(a.id)?.tab.visible).toBe(false) expect(h.titles.get(a.id)?.tab.visible).toBe(false) @@ -820,8 +881,14 @@ describe('slot-owned useTabInfo', () => { expect(h.bodies.get(a.id)?.tab.visible).toBe(true) const paneId = h.bodies.get(a.id)!.panel.id expect(h.layout().floats).toContain(paneId) + expect(h.bodies.get(a.id)?.panel).toEqual({ id: paneId, isFloating: true }) + expect(h.titles.get(a.id)?.panel).toEqual({ id: paneId, isFloating: true }) + expect(h.bodies.get(a.id)?.tab.id).toBe(a.id) act(() => { h.controller.dock(paneId) }) expect(h.layout().floats).toHaveLength(0) + expect(h.bodies.get(a.id)?.panel).toEqual({ id: docked, isFloating: false }) + expect(h.titles.get(a.id)?.panel).toEqual({ id: docked, isFloating: false }) + expect(h.bodies.get(a.id)?.tab.id).toBe(a.id) expect(h.bodies.get(a.id)?.tab.signal).toBe(signal) }) @@ -1048,3 +1115,60 @@ it('keeps a resource tab and reports a synchronous cleanup failure from its clos release() } }) + +describe('the reveal glow', () => { + it('marks the pane of a revealed tab already on screen, docked or floating, restarts on a repeat, and clears when the animation ends', async () => { + const h = await mountSeat() + const a = h.open('a.txt') + h.open('b.txt') + const marked = (): HTMLElement[] => [...document.querySelectorAll('[data-sidebar-right-flash]')] + expect(marked()).toEqual([]) + // Switching back to a background tab is the visible change and marks nothing. + h.open('a.txt') + expect(marked()).toEqual([]) + h.open('a.txt') + const pane = document.querySelector(`[data-dockkit-pane="${h.layout().activePaneId}"]`)! + expect(marked()).toEqual([pane]) + expect(pane.dataset.sidebarRightFlash).toBe('odd') + h.open('a.txt') + expect(pane.dataset.sidebarRightFlash).toBe('even') + fireEvent.animationEnd(pane) + expect(marked()).toEqual([]) + act(() => { h.controller.float(a.id) }) + h.open('a.txt') + const floating = document.querySelector('[data-dockkit-float]')! + expect(marked()).toEqual([floating]) + fireEvent.animationEnd(floating) + // An open that lands new content marks nothing. + h.open('c.txt') + expect(marked()).toEqual([]) + }) + + it('plays each request once and only on screen: a hidden Session ends its glow and never replays it, and a later mount skips it', async () => { + const h = await mountSeat() + h.open('a.txt') + h.open('b.txt') + const marked = (): HTMLElement[] => [...document.querySelectorAll('[data-sidebar-right-flash]')] + h.open('b.txt') + expect(marked()).toHaveLength(1) + act(() => { h.runtime.panelInfo.set({ activePanelId: 'other-panel' as MainPanelId }) }) + expect(marked()).toEqual([]) + act(() => { h.runtime.panelInfo.set({ activePanelId: null }) }) + expect(marked()).toEqual([]) + // A request whose Session is hidden in the same commit is dropped, not deferred. + act(() => { + h.controller.openResource('dsh-resource://file/session/s-test/b.txt') + h.runtime.panelInfo.set({ activePanelId: 'other-panel' as MainPanelId }) + }) + expect(marked()).toEqual([]) + act(() => { h.runtime.panelInfo.set({ activePanelId: null }) }) + expect(marked()).toEqual([]) + // A second panel for the Session mounts after the request and leaves it unplayed. + const again = h.runtime.renderSlot('rightbar', { width: 420, viewportWidth: 1440, canShow: true }) + expect(again.container.querySelector('[data-dockkit-pane]')).not.toBeNull() + expect(marked()).toEqual([]) + // A new request still plays. + h.open('b.txt') + expect(marked().length).toBeGreaterThan(0) + }) +}) diff --git a/packages/client/ui-sidebar-right/tests/service.client.spec.ts b/packages/client/ui-sidebar-right/tests/service.client.spec.ts index 29e10f4e3b..46072beeaf 100644 --- a/packages/client/ui-sidebar-right/tests/service.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/service.client.spec.ts @@ -9,79 +9,12 @@ * complete until the tab knows how it was navigated to. */ import { describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { LayoutState, PaneId, TabId } from '@deepseek-ai/dsh-client-ui-dockkit' +import type { PaneId, TabId } from '@deepseek-ai/dsh-client-ui-dockkit' import { dockPaneIds, findTabPane, getPane } from '@deepseek-ai/dsh-client-ui-dockkit' -import { createSidebarRightController } from '../src/client/service.ts' -import { SidebarRightTabRegistry } from '../src/client/tab-registry.ts' +import type { SidebarRightCloseHandler } from '../src/client/service.ts' import { createSidebarRightStore } from '../src/client/stores.ts' -import { guideDefinition } from '../src/client/tabs/guide/definition.ts' - -// The params map is empty in this package; a test-only scheme lets specs hand -// parameters through the typed `open`/`navigate` faces. -declare module '../src/client/contract/params.ts' { - interface SidebarRightResourceParamsMap { - test: { line?: number; x?: number } - } -} - -const SESSION = 's-test' as SessionId - -/** Key-echoing translate: this file asserts behaviour, not copy. */ -const t = ((key: string) => key) as Parameters[0] - -/** A controller over a real registry and a real store, adopted as the runtime mints it. */ -function harness() { - const ctx = new Context() - const tabs = new SidebarRightTabRegistry(ctx) - tabs.register(guideDefinition(t)) - // A type claiming `file:` addresses, standing in for whatever package owns - // them: this service's contract is that SOME registered type claims, not that - // a particular one does. - tabs.register({ - id: 'test/text', - kind: 'text', - patterns: ['dsh-resource://file/**'], - priority: 'fallback', - title: address => address.slice(address.lastIndexOf('/') + 1), - }) - const pin = vi.fn<(address: string, signal: AbortSignal) => void>() - const { controller, adopt, show, measure } = createSidebarRightController(tabs, pin, { - autoFullscreen: () => false, - openWithFocus: (_sessionId, open) => { open() }, - closeWithFocus: (_sessionId, _paneId, close) => { close() }, - }) - const instance = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed' })).create() - // The runtime mints the Session's store before any seat renders, and the plugin adopts it. - const unadopt = adopt(SESSION, instance) - const layout = (): LayoutState => { - const surface = instance.getSnapshot().bySession[SESSION] - if (surface === undefined) throw new Error('expected a surface') - return surface.layout - } - /** The room rule's verdict the seat would report; a spec flips it to model a narrow pane. */ - const room = { allowed: true } - /** Put the Session on screen the way the plugin does from the selection, with the room rule its seat reports. */ - const publish = (): (() => void) => { - show(SESSION) - measure(SESSION, () => room.allowed) - return () => { show(undefined) } - } - const titles = (): string[] => { - const surface = instance.getSnapshot().bySession[SESSION] - return surface === undefined ? [] : Object.values(surface.layout.tabs).map(tab => tab.title) - } - const tabOf = (title: string): TabId => { - const found = Object.values(layout().tabs).find(tab => tab.title === title) - if (found === undefined) throw new Error(`expected a tab titled ${title}`) - return found.id - } - const entries = (): number => instance.getSnapshot().bySession[SESSION]?.history.entries.length ?? 0 - // A surface starts empty; expanding seeds the default guide ('seed'). - const expand = (): void => { instance.actions.setExpanded(SESSION, true) } - return { controller, adopt, unadopt, show, measure, tabs, instance, pin, publish, titles, tabOf, layout, entries, room, expand } -} +import { controllerHarness as harness, SESSION } from './controller-harness.client.ts' describe('SidebarRightController — opening', () => { it('keeps independently opened instances distinct when they return to the same pane', () => { @@ -245,7 +178,7 @@ describe('SidebarRightController — opening', () => { expect(Object.values(layout().tabs).map(tab => tab.title)).not.toContain('c.txt') }) - it('still closes the replaced tab when the address is already open elsewhere', () => { + it('keeps the replaced tab when the address is already open elsewhere', () => { const { controller, publish, layout, tabOf, titles } = harness() publish() controller.openResource('dsh-resource://file/session/s-test/a.txt') @@ -254,7 +187,7 @@ describe('SidebarRightController — opening', () => { publish() const spare = tabOf('b.txt') controller.openResource('dsh-resource://file/session/s-test/a.txt', { replaceTab: spare }) - expect(layout().tabs[spare]).toBeUndefined() + expect(layout().tabs[spare]).toBeDefined() expect(titles().filter(title => title === 'a.txt')).toHaveLength(1) expect(getPane(layout(), layout().activePaneId).activeTabId).toBe(tabOf('a.txt')) }) @@ -409,7 +342,7 @@ describe('SidebarRightController — the two opens', () => { expect(entries()).toBe(before) }) - it('replaceTab opens in the named tab\'s place and closes it, as one entry', () => { + it('replaceTab focuses the page its pane already shows and keeps the named tab, as one entry', () => { const { controller, instance, publish, layout, tabOf, entries, expand } = harness() instance.actions.open(SESSION) expand() @@ -418,11 +351,108 @@ describe('SidebarRightController — the two opens', () => { publish() const before = entries() controller.openTab('guide', { replaceTab: tabOf('a.txt') }) - expect(Object.values(layout().tabs).map(tab => tab.title)).toEqual(['seed']) + expect(Object.values(layout().tabs).map(tab => tab.title)).toEqual(['seed', 'a.txt']) + expect(controller.active()?.kind).toBe('guide') expect(entries()).toBe(before + 1) }) }) +describe('SidebarRightController — replacing a tab', () => { + const A = 'dsh-resource://file/session/s-test/a.txt' + const B = 'dsh-resource://file/session/s-test/b.txt' + const RECT = { x: 40, y: 50, width: 300, height: 200 } + + /** A tab showing b.txt to be replaced, docked or floating, with its close handler recorded. */ + function replaced(floating: boolean) { + const h = harness() + const cleaned = vi.fn() + h.controller.registerCloseHandler('text', cleaned) + h.expand() + h.publish() + h.controller.openResource(B) + const from = h.tabOf('b.txt') + if (floating) h.controller.float(from, RECT) + const float = floating ? findTabPane(h.layout(), from).id : undefined + return { ...h, from, float, cleaned } + } + + it.each([ + ['docked, same pane', false, false], + ['docked, another pane', false, true], + ['floating, docked content', true, false], + ['floating, content in another pane', true, true], + ] as const)('reveals content already open and keeps the replaced tab (%s)', (_label, floating, split) => { + const h = replaced(floating) + const pane = split ? h.controller.split()! : undefined + h.controller.openResource(A, pane === undefined ? {} : { paneId: pane }) + if (pane !== undefined) expect(findTabPane(h.layout(), h.tabOf('a.txt')).id).toBe(pane) + const before = Object.keys(h.layout().tabs) + h.controller.openResource(A, { replaceTab: h.from }) + expect(Object.keys(h.layout().tabs)).toEqual(before) + expect(getPane(h.layout(), h.layout().activePaneId).activeTabId).toBe(h.tabOf('a.txt')) + expect(findTabPane(h.layout(), h.from).host).toBe(floating ? 'float' : 'dock') + expect(h.cleaned).not.toHaveBeenCalled() + }) + + it('replaces a floating tab in its panel, keeping the pane id, rectangle and stacking order, and leaves the column collapsed', () => { + const h = replaced(true) + h.controller.openResource('dsh-resource://file/session/s-test/c.txt') + h.controller.float(h.tabOf('c.txt'), { ...RECT, x: 400 }) + // The replaced tab is the panel the reader is using, so it is the topmost one. + h.controller.focus(h.from) + const floats = h.layout().floats + expect(floats).toHaveLength(2) + expect(floats.at(-1)).toBe(h.float) + h.instance.actions.setExpanded(SESSION, false) + const before = h.entries() + h.controller.openResource(A, { replaceTab: h.from }) + const pane = getPane(h.layout(), h.float!) + expect(h.layout().floats).toEqual(floats) + expect(pane.rect).toEqual(RECT) + expect(pane.tabs).toEqual([h.tabOf('a.txt')]) + expect(h.layout().activePaneId).toBe(h.float) + expect(h.layout().tabs[h.from]).toBeUndefined() + expect(h.layout().expanded).toBe(false) + expect(h.cleaned).toHaveBeenCalledTimes(1) + expect(h.entries()).toBe(before + 1) + }) + + it('replaces a floating tab in its panel with a page, ignoring a named pane', () => { + const h = replaced(true) + h.tabs.register({ id: 'test/files', kind: 'files', title: () => 'files' }) + h.controller.openTab('files', { replaceTab: h.from, paneId: dockPaneIds(h.layout())[0]! }) + expect(getPane(h.layout(), h.float!).tabs).toEqual([h.tabOf('files')]) + expect(h.layout().tabs[h.from]).toBeUndefined() + }) + + it('opens as an ordinary open when the replaced tab has closed since', () => { + const h = replaced(true) + h.controller.close(h.from) + h.controller.openResource(A, { replaceTab: h.from }) + expect(findTabPane(h.layout(), h.tabOf('a.txt')).host).toBe('dock') + expect(h.layout().floats).toEqual([]) + expect(h.cleaned).toHaveBeenCalledTimes(1) + }) + + it.each([true, false])('keeps a files-like page that names itself as replaceTab and opens a file already shown elsewhere (floating: %s)', (floating) => { + const h = harness() + h.tabs.register({ id: 'test/files', kind: 'files', title: () => 'files' }) + const cleaned = vi.fn() + h.controller.registerCloseHandler('files', cleaned) + h.expand() + h.publish() + h.controller.openResource(A) + h.controller.openTab('files') + const page = h.tabOf('files') + if (floating) h.controller.float(page, RECT) + h.controller.openResource(A, { replaceTab: page }) + expect(h.layout().tabs[page]).toBeDefined() + expect(h.titles().filter(title => title === 'a.txt')).toHaveLength(1) + expect(getPane(h.layout(), h.layout().activePaneId).activeTabId).toBe(h.tabOf('a.txt')) + expect(cleaned).not.toHaveBeenCalled() + }) +}) + describe('SidebarRightController — layout operations', () => { it('focuses an existing tab and its pane, and leaves a missing tab alone', () => { const { controller, publish, layout, tabOf, entries, expand } = harness() @@ -510,6 +540,46 @@ describe('SidebarRightController — layout operations', () => { controller.dock('pane-nowhere' as PaneId) expect(entries()).toBe(before) }) + + it('expands a collapsed column in the one entry that docks a panel, beside docked tabs or into an empty column', () => { + const { controller, instance, publish, layout, tabOf, titles, entries } = harness() + publish() + controller.openResource('dsh-resource://file/session/s-test/a.txt') + controller.openResource('dsh-resource://file/session/s-test/b.txt') + controller.float(tabOf('b.txt')) + controller.toggleExpanded() + expect(layout().expanded).toBe(false) + const floated = tabOf('b.txt') + const record = layout().tabs[floated] + const [float] = layout().floats + if (float === undefined) throw new Error('expected a float') + let before = entries() + instance.actions.unfloatPane(SESSION, float) + expect(layout().expanded).toBe(true) + expect(entries()).toBe(before + 1) + const pane = findTabPane(layout(), floated) + expect(pane.host).toBe('dock') + expect(pane.tabs).toEqual([tabOf('a.txt'), floated]) + expect(pane.activeTabId).toBe(floated) + expect(layout().tabs[floated]).toBe(record) + + // A collapsed column whose docked pane is empty shows only the docked tab, with no seeded page. + controller.close(tabOf('a.txt')) + controller.toggleExpanded() + controller.float(floated) + expect(layout().expanded).toBe(false) + const [empty] = dockPaneIds(layout()) + if (empty === undefined) throw new Error('expected the docked pane') + expect(getPane(layout(), empty).tabs).toEqual([]) + const [again] = layout().floats + if (again === undefined) throw new Error('expected a float') + before = entries() + controller.dock(again) + expect(layout().expanded).toBe(true) + expect(entries()).toBe(before + 1) + expect(findTabPane(layout(), floated).tabs).toEqual([floated]) + expect(titles()).toEqual(['b.txt']) + }) }) describe('SidebarRightController — a tab\'s own actions', () => { @@ -810,3 +880,97 @@ it('limits batch-close candidates to the captured pane and invalidates a moved a expect(h.layout().tabs[first]).toBeDefined() unregister(); unpublish(); h.unadopt() }) + +describe('SidebarRightController — opening from a floating tab', () => { + const file = (name: string): string => `dsh-resource://file/session/s-test/${name}` + /** A floating source tab, the only tab left, so the docked area is empty and the column collapsed. */ + function floating() { + const h = harness() + h.tabs.register({ id: 'test/files', kind: 'files', title: () => 'files' }) + h.publish() + h.controller.openResource(file('page.txt')) + const from = h.tabOf('page.txt') + h.controller.openResource(file('other.txt')) + h.controller.float(from) + h.controller.close(h.tabOf('other.txt')) + const actions = h.controller.tabDomain.occurrence(SESSION, { id: from }).tabActions + return { ...h, from, actions } + } + + it.each([ + ['a file', (h: ReturnType) => { h.actions.openResource(file('a.txt')) }, 'a.txt'], + ['a second copy', (h: ReturnType) => { h.actions.openResource(file('page.txt'), { revealIfOpened: false }) }, 'page.txt'], + ['a page', (h: ReturnType) => { h.actions.openTab('files') }, 'files'], + ] as const)('lands %s in the empty docked pane and expands the collapsed column, seeding no default page', (_label, open, title) => { + const h = floating() + expect(h.layout().expanded).toBe(false) + expect(getPane(h.layout(), h.layout().rootId).tabs).toEqual([]) + const before = h.entries() + open(h) + const root = getPane(h.layout(), h.layout().rootId) + expect(root.tabs).toHaveLength(1) + expect(h.layout().tabs[root.tabs[0]!]?.title).toBe(title) + expect(h.titles()).not.toContain('seed') + expect(h.layout().expanded).toBe(true) + expect(h.layout().activePaneId).toBe(root.id) + expect(findTabPane(h.layout(), h.from).host).toBe('float') + expect(h.entries()).toBe(before + 1) + }) + + it('appends to the active docked pane holding tabs, without splitting it', () => { + const h = floating() + h.controller.openResource(file('docked.txt')) + h.actions.openResource(file('a.txt')) + expect(dockPaneIds(h.layout())).toHaveLength(1) + expect(getPane(h.layout(), h.layout().rootId).tabs).toEqual([h.tabOf('docked.txt'), h.tabOf('a.txt')]) + }) +}) + +describe('SidebarRightController — flashing a reveal', () => { + it('flashes the pane holding a revealed tab, restarting on a repeat, and leaves new content unflashed', () => { + const h = harness() + h.publish() + h.controller.openResource('dsh-resource://file/session/s-test/page.txt') + h.controller.openResource('dsh-resource://file/session/s-test/a.txt', { preferNewPane: true }) + const pane = findTabPane(h.layout(), h.tabOf('a.txt')).id + const flashes = h.controller.flashOf(SESSION) + expect(flashes.getSnapshot()).toBeUndefined() + h.controller.focus(h.tabOf('page.txt')) + h.controller.openResource('dsh-resource://file/session/s-test/a.txt') + expect(flashes.getSnapshot()).toEqual({ paneId: pane, serial: 1 }) + h.controller.openResource('dsh-resource://file/session/s-test/a.txt') + expect(flashes.getSnapshot()).toEqual({ paneId: pane, serial: 2 }) + expect(h.controller.flashOf(SESSION)).toBe(flashes) + h.controller.openResource('dsh-resource://file/session/s-test/b.txt') + expect(flashes.getSnapshot()?.serial).toBe(2) + // Forgetting the Session releases its source; the next one starts with no request. + h.forget(SESSION) + const next = h.controller.flashOf(SESSION) + expect(next).not.toBe(flashes) + expect(next.getSnapshot()).toBeUndefined() + }) +}) + +describe('SidebarRightController — page uniqueness for a floating replacement', () => { + it('turns a floating guide into a page in its panel while a docked pane shows that page', () => { + const h = harness() + h.tabs.register({ id: 'test/files', kind: 'files', title: () => 'Files' }) + h.expand() + h.publish() + h.controller.openTab('files') + const docked = h.tabOf('Files') + h.controller.openTab('guide', { revealIfOpened: false }) + const guide = Object.values(h.layout().tabs).find(tab => tab.kind === 'guide')!.id + h.controller.float(guide) + const panel = findTabPane(h.layout(), guide) + const tabs = Object.keys(h.layout().tabs).length + const flashed = h.controller.flashOf(SESSION).getSnapshot() + h.controller.tabDomain.occurrence(SESSION, { id: guide }).tabActions.openTab('files', { replaceTab: true }) + expect(h.layout().tabs[guide]).toBeUndefined() + expect(h.layout().tabs[docked]).toBeDefined() + const floating = Object.values(h.layout().tabs).find(tab => tab.kind === 'files' && tab.id !== docked)! + expect(findTabPane(h.layout(), floating.id)).toMatchObject({ id: panel.id, host: 'float' }) + expect(Object.keys(h.layout().tabs)).toHaveLength(tabs) + expect(h.controller.flashOf(SESSION).getSnapshot()).toBe(flashed) + }) +}) diff --git a/packages/client/ui-sidebar-right/tests/sidebar-right-styles.client.spec.ts b/packages/client/ui-sidebar-right/tests/sidebar-right-styles.client.spec.ts index 378c72834e..d59a3c843f 100644 --- a/packages/client/ui-sidebar-right/tests/sidebar-right-styles.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/sidebar-right-styles.client.spec.ts @@ -1,4 +1,4 @@ -/** Right sidebar slide style contracts. */ +/** Right sidebar slide and reveal-flash style contracts. */ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' @@ -17,4 +17,16 @@ describe('SidebarRight.module.css', () => { expect(css).toMatch(new RegExp(String.raw`@media \(prefers-reduced-motion: reduce\)\s*\{[^{}]*` + String.raw`\.panel\[data-sidebar-right-open\] ${host}[^{}]*\{\s*transition: none;\s*}`)) }) + + it('draws the reveal glow inside a docked pane above sticky code banners, and outside a floating panel over its own shadow', () => { + const pane = String.raw`\.panel :global\(\[data-dockkit-pane\]\[data-sidebar-right-flash\]\)` + const float = String.raw`\.panel :global\(\[data-dockkit-float\]\[data-sidebar-right-flash\]\)` + const layer = new RegExp(String.raw`${pane}::after\s*\{([^{}]*)}`).exec(css)?.[1] ?? '' + expect(layer).toMatch(/content: '';\s*position: absolute;\s*inset: 0;\s*z-index: 7;\s*pointer-events: none;/) + expect(layer).toMatch(/--sidebar-right-flash-from:\s*inset /) + expect(css).toMatch(new RegExp(String.raw`${pane}\s*\{\s*position: relative;\s*}`)) + const panel = new RegExp(String.raw`${float}\s*\{(\s*--sidebar-right-flash-from[^{}]*)}`).exec(css)?.[1] ?? '' + expect(panel).toMatch(/--sidebar-right-flash-from:\s*var\(--dsw-elevation-prominent\),/) + expect(panel).toMatch(/--sidebar-right-flash-to:\s*var\(--dsw-elevation-prominent\),/) + }) }) diff --git a/packages/client/ui-sidebar-right/tests/stores.client.spec.ts b/packages/client/ui-sidebar-right/tests/stores.client.spec.ts index 961e657046..bdf1b8e962 100644 --- a/packages/client/ui-sidebar-right/tests/stores.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/stores.client.spec.ts @@ -450,3 +450,45 @@ describe('createSidebarRightStore — page uniqueness', () => { expect(Object.values(layout().tabs).filter(tab => tab.kind === 'guide')).toHaveLength(1) }) }) + +describe('createSidebarRightStore — reveals of floating tabs', () => { + it('leaves a collapsed column collapsed when an open from one floating panel reveals another', () => { + const { actions, layout, expand } = harness() + expand() + const open = (name: string): TabId => { + let tabId: TabId | undefined + actions.openContent(SESSION, { kind: 'text', contentId: `dsh-resource://file/session/s-test/${name}`, title: name }, (id) => { tabId = id }) + return tabId! + } + const a = open('a.txt') + const b = open('b.txt') + actions.floatTab(SESSION, a) + actions.floatTab(SESSION, b) + const panelB = findTabPane(layout(), b).id + actions.setExpanded(SESSION, false) + const tabs = Object.keys(layout().tabs).length + let settled: TabId | undefined + actions.openContent(SESSION, { + kind: 'text', contentId: 'dsh-resource://file/session/s-test/b.txt', title: 'b.txt', replaceTab: a, + }, (id) => { settled = id }) + expect(settled).toBe(b) + expect(layout().expanded).toBe(false) + expect(layout().activePaneId).toBe(panelB) + expect(Object.keys(layout().tabs)).toHaveLength(tabs) + expect(layout().tabs[a]).toBeDefined() + }) + + it('finds a page a replaced floating tab would open only in that tab\'s panel', () => { + const { actions, layout, expand, guide } = harness() + expand() + actions.openContent(SESSION, { kind: 'files', contentId: pageAddress('files'), title: 'Files' }, () => {}) + const seeded = guide() + actions.floatTab(SESSION, seeded) + const panel = findTabPane(layout(), seeded) + let settled: TabId | undefined + actions.openContent(SESSION, { kind: 'files', contentId: pageAddress('files'), title: 'Files', replaceTab: seeded }, (id) => { settled = id }) + expect(layout().tabs[seeded]).toBeUndefined() + expect(findTabPane(layout(), settled!)).toMatchObject({ id: panel.id, host: 'float', rect: panel.rect }) + expect(Object.values(layout().tabs).filter(tab => tab.kind === 'files')).toHaveLength(2) + }) +}) diff --git a/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts b/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts index 7c9a10c46e..7ce476b2f5 100644 --- a/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts @@ -191,6 +191,8 @@ describe('TabDomain — a tab\'s own actions', () => { const { tabActions } = domain.occurrence(SESSION, recordOf(current(), tabId)) tabActions.openTab('files', { replaceTab: true }) expect(navigator.openTabIn).toHaveBeenLastCalledWith(SESSION, 'files', { replaceTab: tabId }) + tabActions.openResource('dsh-resource://file/session/s-one/b.txt', { replaceTab: true }) + expect(navigator.openResourceIn).toHaveBeenLastCalledWith(SESSION, 'dsh-resource://file/session/s-one/b.txt', { replaceTab: tabId }) tabActions.close() expect(navigator.closeIn).toHaveBeenCalledWith(SESSION, tabId) }) @@ -210,4 +212,19 @@ describe('TabDomain — a tab\'s own actions', () => { { paneId: current().rootId, preferNewPane: true, revealIfOpened: false }, ) }) + + it('passes only an explicit host and params, never this tab\'s pane or itself for replacement', () => { + const { domain, navigator, controller, current } = harness() + const tabId = controller.openContent({ kind: 'text', contentId: 'dsh-resource://file/session/s-one/a.txt', title: 'a' }) + domain.sync(SESSION, current()) + const { tabActions } = domain.occurrence(SESSION, recordOf(current(), tabId)) + tabActions.openResource('dsh-resource://file/session/s-one/b.txt', { + destination: 'float', replaceTab: true, paneId: getPane(current(), current().rootId).id, preferNewPane: true, revealIfOpened: false, params: { line: 8 }, + }) + expect(navigator.openResourceIn).toHaveBeenLastCalledWith(SESSION, 'dsh-resource://file/session/s-one/b.txt', + { destination: 'float', params: { line: 8 } }) + tabActions.openResource('dsh-resource://file/session/s-one/b.txt', { destination: 'sidebar' }) + expect(navigator.openResourceIn).toHaveBeenLastCalledWith(SESSION, 'dsh-resource://file/session/s-one/b.txt', + { destination: 'sidebar', params: undefined }) + }) }) diff --git a/packages/client/ui-sidebar-right/tests/tab-info.client.spec.tsx b/packages/client/ui-sidebar-right/tests/tab-info.client.spec.tsx index e82ec054a5..81e83a8b9b 100644 --- a/packages/client/ui-sidebar-right/tests/tab-info.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/tab-info.client.spec.tsx @@ -1,11 +1,11 @@ // @vitest-environment jsdom /** Tab information refuses readers whose committed record and navigation binding disagree. */ import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, renderHook } from '@testing-library/react' +import { act, cleanup, renderHook } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { keyedObservableHook } from '@deepseek-ai/dsh-client-ui-renderer/src/client/bindings.tsx' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' +import { findTabPane, type TabId } from '@deepseek-ai/dsh-client-ui-dockkit' import type { UseSidebarRightTabInfo } from '../src/client/contract/slots.ts' import { createSidebarRightStore } from '../src/client/stores.ts' import { TabDomain, type TabOccurrence } from '../src/client/tab-domain.ts' @@ -44,11 +44,11 @@ function harness() { signal: occurrence.signal, actions: occurrence.tabActions, useStore, useTabNavigation, }) } - const open = (beforeCommit?: (tabId: TabId) => void): TabId => { + const open = (beforeCommit?: (tabId: TabId) => void, address = ADDRESS): TabId => { let opened: TabId | undefined - instance.actions.openContent(SESSION, { kind: 'text', contentId: ADDRESS, title: 'a' }, (tabId) => { + instance.actions.openContent(SESSION, { kind: 'text', contentId: address, title: 'a' }, (tabId) => { opened = tabId - domain.navigate(SESSION, tabId, { address: ADDRESS, params: undefined }) + domain.navigate(SESSION, tabId, { address, params: undefined }) beforeCommit?.(tabId) }) if (opened === undefined) throw new Error('expected the tab to open') @@ -119,4 +119,54 @@ describe('tabInfoFactory committed-record relation', () => { expect(h.layout()?.tabs[tabId]).toBeDefined() expectUncommitted(useTabInfo, tabId) }) + + it('keeps the tab identity while its panel follows docking and floating', () => { + const h = harness() + const tabId = h.open() + const view = renderHook(h.bind(tabId)) + const docked = findTabPane(h.layout()!, tabId).id + expect(view.result.current.tab.id).toBe(tabId) + expect(view.result.current.panel).toEqual({ id: docked, isFloating: false }) + + act(() => { h.instance.actions.floatTab(SESSION, tabId) }) + const floating = findTabPane(h.layout()!, tabId) + expect(floating.host).toBe('float') + expect(floating.id).not.toBe(docked) + expect(view.result.current.tab.id).toBe(tabId) + expect(view.result.current.panel).toEqual({ id: floating.id, isFloating: true }) + + act(() => { h.instance.actions.unfloatPane(SESSION, floating.id) }) + expect(findTabPane(h.layout()!, tabId).host).toBe('dock') + expect(view.result.current.tab.id).toBe(tabId) + expect(view.result.current.panel).toEqual({ id: docked, isFloating: false }) + }) + + it('keeps floating panel ids and host changes independent across tabs', () => { + const h = harness() + const firstId = h.open() + const secondId = h.open(undefined, 'dsh-resource://file/session/s-info/b.txt') + const first = renderHook(h.bind(firstId)) + const second = renderHook(h.bind(secondId)) + const docked = first.result.current.panel.id + expect(second.result.current.panel).toEqual({ id: docked, isFloating: false }) + + act(() => { h.instance.actions.floatTab(SESSION, firstId) }) + const firstPane = findTabPane(h.layout()!, firstId).id + expect(first.result.current.panel).toEqual({ id: firstPane, isFloating: true }) + expect(second.result.current.panel).toEqual({ id: docked, isFloating: false }) + + act(() => { h.instance.actions.floatTab(SESSION, secondId) }) + const secondPane = findTabPane(h.layout()!, secondId).id + expect(new Set([docked, firstPane, secondPane]).size).toBe(3) + expect(first.result.current.panel).toEqual({ id: firstPane, isFloating: true }) + expect(second.result.current.panel).toEqual({ id: secondPane, isFloating: true }) + expect(first.result.current.tab.id).toBe(firstId) + expect(second.result.current.tab.id).toBe(secondId) + + act(() => { h.instance.actions.unfloatPane(SESSION, firstPane) }) + expect(first.result.current.panel).toEqual({ id: docked, isFloating: false }) + expect(second.result.current.panel).toEqual({ id: secondPane, isFloating: true }) + expect(first.result.current.tab.id).toBe(firstId) + expect(second.result.current.tab.id).toBe(secondId) + }) }) diff --git a/packages/client/ui-sidebar-terminal/tests/terminal-guide.client.spec.tsx b/packages/client/ui-sidebar-terminal/tests/terminal-guide.client.spec.tsx index b8cb722130..19a6c0210c 100644 --- a/packages/client/ui-sidebar-terminal/tests/terminal-guide.client.spec.tsx +++ b/packages/client/ui-sidebar-terminal/tests/terminal-guide.client.spec.tsx @@ -48,7 +48,7 @@ function mount(description: string | undefined = en.description, shortcut?: Guid ...standard, useShortcuts: (selector: (entries: readonly ShortcutCatalogEntry[]) => T): T => selector(shortcuts), entryId: 'new', kind: 'terminal', title: en.new, description, t: makeTranslate(en), - useTabInfo: () => ({ sidebar: { expanded: true, fullscreen: false }, panel: { id: 'pane-guide' as PaneId }, + useTabInfo: () => ({ sidebar: { expanded: true, fullscreen: false }, panel: { id: 'pane-guide' as PaneId, isFloating: false }, tab: { id: 'tab-guide' as TabId, kind: 'guide', contentId: 'sidebar://guide', title: 'Start', visible: true, signal: new AbortController().signal, navigation: { address: 'sidebar://guide', params: undefined, revision: 0 }, diff --git a/packages/client/ui-subagent/src/client/locales.ts b/packages/client/ui-subagent/src/client/locales.ts index 385572dad2..897b65ece0 100644 --- a/packages/client/ui-subagent/src/client/locales.ts +++ b/packages/client/ui-subagent/src/client/locales.ts @@ -40,6 +40,7 @@ export const zh = { 'switcher.aria': '切换子智能体:{title}', 'tree.aria': '子智能体会话', 'open.sidebar': '在侧边栏打开', + 'open.floatWindow': '在浮窗中打开', 'open.sidebar.aria': '在侧边栏打开 {label}', 'sidebar.chat': '聊天', 'readonly.oneShot.title': '一次性子智能体记录', @@ -85,6 +86,7 @@ export const en: Record = { 'switcher.aria': 'Switch subagent: {title}', 'tree.aria': 'Subagent sessions', 'open.sidebar': 'Open in sidebar', + 'open.floatWindow': 'Open in floating panel', 'open.sidebar.aria': 'Open {label} in sidebar', 'sidebar.chat': 'Chat', 'readonly.oneShot.title': 'One-shot subagent record', @@ -133,6 +135,7 @@ export const ja = { 'switcher.aria': 'サブエージェントを切り替え: {title}', 'tree.aria': 'サブエージェントセッション', 'open.sidebar': 'サイドバーで開く', + 'open.floatWindow': 'フローティングパネルで開く', 'open.sidebar.aria': '{label}をサイドバーで開く', 'sidebar.chat': 'チャット', 'readonly.oneShot.title': '単発サブエージェントの記録', @@ -178,6 +181,7 @@ export const es = { 'switcher.aria': 'Cambiar subagente: {title}', 'tree.aria': 'Sesiones de subagente', 'open.sidebar': 'Abrir en la barra lateral', + 'open.floatWindow': 'Abrir en un panel flotante', 'open.sidebar.aria': 'Abra {label} en la barra lateral', 'sidebar.chat': 'Chat', 'readonly.oneShot.title': 'Registro de subagente de una sola vez', @@ -223,6 +227,7 @@ export const fr = { 'switcher.aria': 'Changer de sous-agent : {title}', 'tree.aria': 'Sessions de sous-agents', 'open.sidebar': 'Ouvrir dans la barre latérale', + 'open.floatWindow': 'Ouvrir dans un panneau flottant', 'open.sidebar.aria': 'Ouvrir {label} dans la barre latérale', 'sidebar.chat': 'Discussion', 'readonly.oneShot.title': 'Enregistrement de sous-agent unique', @@ -268,6 +273,7 @@ export const de = { 'switcher.aria': 'Subagent wechseln: {title}', 'tree.aria': 'Subagentensitzungen', 'open.sidebar': 'In der Seitenleiste öffnen', + 'open.floatWindow': 'In schwebendem Panel öffnen', 'open.sidebar.aria': 'Öffnen Sie {label} in der Seitenleiste', 'sidebar.chat': 'Chat', 'readonly.oneShot.title': 'Einmaliger Subagentendatensatz', @@ -313,6 +319,7 @@ export const ru = { 'switcher.aria': 'Переключить субагент: {title}', 'tree.aria': 'Сеансы субагента', 'open.sidebar': 'Открыть в боковой панели', + 'open.floatWindow': 'Открыть на плавающей панели', 'open.sidebar.aria': 'Откройте {label} на боковой панели.', 'sidebar.chat': 'Чат', 'readonly.oneShot.title': 'Разовая запись субагента', @@ -358,6 +365,7 @@ export const pt = { 'switcher.aria': 'Alternar subagente: {title}', 'tree.aria': 'Sessões de subagente', 'open.sidebar': 'Abrir na barra lateral', + 'open.floatWindow': 'Abrir em painel flutuante', 'open.sidebar.aria': 'Abra {label} na barra lateral', 'sidebar.chat': 'Chat', 'readonly.oneShot.title': 'Registro de subagente único', diff --git a/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx b/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx index 85205eada1..3bbbd3842a 100644 --- a/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/changes-tab.client.spec.tsx @@ -217,7 +217,7 @@ function mount(options: MountOptions = {}) { const props: ChangesTabProps = { useTabInfo: () => ({ sidebar: { expanded: true, fullscreen: false }, - panel: { id: 'pane-1' as PaneId }, + panel: { id: 'pane-1' as PaneId, isFloating: false }, tab: { id: TAB, kind: CHANGES_KIND, contentId, title: 'Changes', visible: useVisible(value => value), navigation: useNavigation(value => value), signal: aborter.signal, actions: tabActions, diff --git a/packages/experimental/client-ui-forge/tests/components.client.spec.tsx b/packages/experimental/client-ui-forge/tests/components.client.spec.tsx index b107772c15..7e37743637 100644 --- a/packages/experimental/client-ui-forge/tests/components.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/components.client.spec.tsx @@ -101,7 +101,7 @@ describe('ChangesTabTitle', () => { function title(contentId: string) { const info: SidebarRightTabInfo = { sidebar: { expanded: true, fullscreen: false }, - panel: { id: 'pane-1' as PaneId }, + panel: { id: 'pane-1' as PaneId, isFloating: false }, tab: { id: 'tab-1' as TabId, kind: 'forge-changes', contentId, title: 'Captured', visible: true, navigation: { address: contentId, params: undefined, revision: 1 }, diff --git a/packages/experimental/client-ui-forge/tests/forge-tab.client.spec.tsx b/packages/experimental/client-ui-forge/tests/forge-tab.client.spec.tsx index d13fc4dc36..e7e48b2f70 100644 --- a/packages/experimental/client-ui-forge/tests/forge-tab.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/forge-tab.client.spec.tsx @@ -30,7 +30,7 @@ const unused = (): never => { throw new Error('the forge tab does not call this function tabInfo(visible: boolean, openResource: SidebarRightTabActions['openResource']): SidebarRightTabInfo { return { sidebar: { expanded: true, fullscreen: false }, - panel: { id: 'pane-1' as PaneId }, + panel: { id: 'pane-1' as PaneId, isFloating: false }, tab: { id: 'tab-forge' as TabId, kind: 'forge-panel', diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 81c6de7149..7c8087d45f 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -4490,8 +4490,8 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ key: 'sidebar.right.tab.files.actions', kind: 'list', scope: 'session', - summary: 'Workspace directory actions after the file tree\'s reload control.', - doc: 'Workspace directory actions after the file tree\'s reload control.', + summary: 'Workspace directory actions after the `files` page\'s reload control.', + doc: 'Workspace directory actions after the `files` page\'s reload control.', registerOptions: [ { name: 'id', @@ -4544,7 +4544,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.tab.files.actions\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.files.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar-files/src/client/index.ts:35', + source: 'packages/client/ui-sidebar-files/src/client/index.ts:39', }, { key: 'sidebar.right.tab.guide', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index b4e9e46a53..a2b6c728eb 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -5043,6 +5043,14 @@ export const EVENT_API: readonly EventApiEntry[] = [ description: 'Module replacements have finished loading.', parameters: [{ name: 'reloads', description: 'Replaced plugins and their module locations.' }], }, + { + name: 'llm-pi-ai/request-headers', + mode: 'waterfall', + signature: '\'llm-pi-ai/request-headers\'(request: PiAiRequestHeaderContext, headers: Headers, next: () => Headers): Headers', + summary: 'Prepare headers for one pi-ai inference call, excluding model discovery.', + description: 'Prepare headers for one pi-ai inference call, excluding model discovery. Listeners run synchronously and must call next(); they may edit only the request-owned headers and must not retain them. Throws fail the call with REQUEST_HEADERS before inference I/O. Harness attribution wins afterward. Non-TypeError messages are user-visible and must exclude secrets. TypeError details and non-Error values are replaced with safe guidance.', + parameters: [{ name: 'request', description: 'immutable routing metadata from the exact prepared call.' }, { name: 'headers', description: 'fresh profile headers, shared only within this dispatch.' }, { name: 'next', description: 'continue header preparation through the remaining listeners.' }], + }, { name: 'llm/adapters-updated', mode: 'emit', @@ -7463,6 +7471,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PermissionCatalog', declaration: 'export interface PermissionCatalog {\n options: PresetOption[];\n defaultOptions: PresetOption[];\n defaultPreset: string;\n}', }, + { + name: 'PiAiRequestHeaderContext', + declaration: 'export interface PiAiRequestHeaderContext extends Readonly, \'provider\' | \'model\' | \'sessionId\' | \'purpose\'>> {\n readonly baseURL: string;\n}', + }, { name: 'PlatformSession', declaration: 'export interface PlatformSession {\n readonly origin: string;\n readonly token: string;\n readonly userId: AccountUserId | null;\n readonly embeddedPageDist?: string;\n readonly requestHeaders?: Readonly>;\n}', diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 38662a9882..635adc8d9f 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -45,8 +45,11 @@ en: 1598ac8a8bb9b6bf zh: fdd089c5ae7722cd /deepseek-ai-dsh-llm-pi-ai/understand-the-implementation/design-philosophy: - en: 8aba1113937b368a - zh: 804a4ca672a776df + en: db1ac8ccca2d858b + zh: 1d7cd4153aad22b0 +/deepseek-ai-dsh-llm-pi-ai/understand-the-implementation/request-headers: + en: 615ca9834bf6544f + zh: 2d70b548f99000d1 /deepseek-ai-dsh-llm-pi-ai/understand-the-implementation/source-map: en: 64912b8a3a336419 zh: 33a245969b4194f6 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 0dcf15b97a..1021dea71d 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -140,6 +140,13 @@ The adapter is built on immutable snapshots and per-operation resolution. Each o Initial prompt text and active tools use pi-ai's `Context.systemPrompt` and `Context.tools`; later system and tool updates remain in `Context.messages`. pi-ai normalizes these fields and applies the selected protocol's serialization rules. + +### Request headers + +Plugins can contribute model-hidden transport metadata through the synchronous `llm-pi-ai/request-headers` waterfall. Each inference call supplies immutable routing metadata (`PiAiRequestHeaderContext`) and a fresh `Headers` copy of its profile headers; when configured names differ only in capitalization, the last entry wins. The endpoint belongs to the prepared model, so concurrent calls and configuration changes cannot exchange header state. Listeners edit that copy, call `next()`, and release their references on return; `ctx.on()` removes their registration when the owning plugin unloads. Harness attribution is applied after all contributors, and pi-ai receives a detached header record. + +This event covers inference calls with or without a Session id, including auxiliary calls, but excludes model discovery. A contributor exception or invalid HTTP header fails the call with `REQUEST_HEADERS` before inference dispatch; no partially prepared request is sent. The failure identifies the provider and includes the contributor's top-level error message, which must exclude credentials and header values. Native header validation can echo values in a `TypeError`, so TypeError details and non-Error throws receive safe guidance instead; nested causes remain attached to the thrown error but are not copied into the public failure. Contributions do not change messages, tool schemas, or the Session log. Direct `PiAiAdapter` consumers can supply the equivalent `prepareHeaders` callback. + ### Source map | File | Role | diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index c434554f7d..1526faa4b8 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -140,6 +140,13 @@ Config 更新严格验证发生变化的 provider。初始加载将已存储的 初始提示词文本和可用工具分别使用 pi-ai 的 `Context.systemPrompt` 与 `Context.tools`;后续系统消息和工具更新保留在 `Context.messages` 中。pi-ai 负责规范化这些字段,并应用所选协议的序列化规则。 + +### 请求头 + +插件可通过同步的 `llm-pi-ai/request-headers` waterfall 贡献模型不可见的传输元数据。每次推理调用提供不可变的路由元数据(`PiAiRequestHeaderContext`)和从 profile 标头复制出的独立 `Headers`;配置名称仅大小写不同时,以最后一个条目为准。端点来自已准备的模型,因此并发调用与配置变更不会交换请求头状态。监听器修改该副本、调用 `next()`,并在返回时释放对它的引用;所属插件卸载时,`ctx.on()` 会移除注册。Harness 的应用标识在所有贡献器之后写入,pi-ai 接收独立的标头记录。 + +该事件覆盖有无 Session id 的推理调用及辅助调用,不用于模型发现。贡献器异常或无效 HTTP 标头会在推理发送前以 `REQUEST_HEADERS` 使调用失败,不发送部分准备好的请求。失败信息包含 provider 和贡献器的顶层错误消息,该消息必须排除凭据与标头值。原生标头校验可能在 `TypeError` 中回显值,因此 TypeError 详情与非 Error 抛出值改用安全的排查提示;嵌套 cause 仍附在抛出的错误上,但不会复制到公开失败信息中。贡献不会改变消息、工具 schema 或 Session 日志。直接使用 `PiAiAdapter` 的调用方可提供等效的 `prepareHeaders` 回调。 + ### 源码地图 | 文件 | 职责 | diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 9c8b4d3f60..4c3bdce845 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -62,6 +62,7 @@ import type { ResolvedPiAiProviderProfile } from './config.ts' import { requestImageTarget, toPiContext } from './context.ts' import { conversationUpdates, createModels, getSupportedThinkingLevels } from './models.ts' import { toStreamChunks } from './stream.ts' +import type { PiAiRequestHeaderContext } from './types.ts' /** * Initially active declaration added to an Anthropic request whose projected @@ -104,6 +105,18 @@ export interface PiAiAdapterOptions { * every request no matter how often the human signed in. */ auth: PiAiAuthInjection + /** + * Synchronously prepare this call's headers before SDK dispatch. The input + * contains a fresh copy of profile headers; attribution is enforced afterward. + * Omission preserves profile headers. Throws fail the call with REQUEST_HEADERS + * before inference I/O. Do not retain or mutate headers after returning. + * Non-TypeError messages become public diagnostics and must exclude secrets; + * TypeError details and non-Error values are replaced with safe guidance. + * @param request - immutable routing metadata from the prepared call. + * @param headers - request-owned headers initialized from the profile. + * @returns headers to pass to pi-ai after applying Harness attribution. + */ + prepareHeaders?: (request: PiAiRequestHeaderContext, headers: Headers) => Headers /** Resolve the optional durable attachment service at request time. */ resolveAttachments?: () => AttachmentStore | undefined /** Bridge one attachment reference into the current model-tool execution world. */ @@ -213,13 +226,42 @@ function reasoningInfo( } } -/** Merge deployment headers while removing case-insensitive attribution collisions. */ -function requestHeaders(headers: Readonly> | undefined): Record { - const attribution = attributionHeaders() - const reserved = new Set(Object.keys(attribution).map(name => name.toLowerCase())) - return { - ...Object.fromEntries(Object.entries(headers ?? {}).filter(([name]) => !reserved.has(name.toLowerCase()))), - ...attribution, +/** Native header TypeErrors can echo credentials; other plugin errors own their public message. */ +function requestHeaderDiagnostic(error: unknown): string { + if (error instanceof TypeError) { + return 'TypeError during header preparation; check the contributing plugin\'s header names, values, and configuration' + } + return error instanceof Error + ? error.message + : 'the contributing plugin threw a non-Error value; throw an Error with a safe diagnostic' +} + +/** Prepare request-owned headers without allowing contributors to replace attribution. */ +function requestHeaders( + configured: Readonly> | undefined, + options: GenerateOptions, + baseURL: string, + prepare: PiAiAdapterOptions['prepareHeaders'], +): Record { + try { + const initial = new Headers() + // Profile aliases overwrite case-insensitively; the Headers constructor appends them. + for (const [name, value] of Object.entries(configured ?? {})) initial.set(name, value) + const request: PiAiRequestHeaderContext = Object.freeze({ + provider: options.provider, + model: options.model, + baseURL, + ...options.sessionId === undefined ? {} : { sessionId: options.sessionId }, + ...options.purpose === undefined ? {} : { purpose: options.purpose }, + }) + const headers = prepare === undefined ? initial : prepare(request, initial) + for (const [name, value] of Object.entries(attributionHeaders())) headers.set(name, value) + return Object.fromEntries(headers) + } catch (error) { + throw new LlmError( + `pi-ai request header preparation failed for provider "${options.provider}": ${requestHeaderDiagnostic(error)}`, + 'REQUEST_HEADERS', { cause: error }, + ) } } @@ -419,9 +461,7 @@ export class PiAiAdapter extends LlmAdapter { ...options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens }, ...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) }, signal: watchdog.signal, - // Profile headers are deployment-owned; attribution names are - // Harness-owned and therefore win collisions. - headers: requestHeaders(profile.headers), + headers: requestHeaders(profile.headers, options, model.baseUrl, this.config.prepareHeaders), }) const iterator = toStreamChunks(events, model.contextWindow, options.signal, model.id)[Symbol.asyncIterator]() let exhausted = false diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 7b05531258..5a53e7106a 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -73,9 +73,11 @@ import { discoverModels } from './discovery.ts' import type { StoredModelDiscoveryProfile } from './discovery.ts' import { registerPiAiFlows } from './login.ts' import { providerDefaultsOf } from './provider-data.ts' +import type { PiAiRequestHeaderContext } from './types.ts' export { PiAiAdapter } from './adapter.ts' export type { PiAiAdapterOptions } from './adapter.ts' +export type { PiAiRequestHeaderContext } from './types.ts' export { Config } from './config.ts' export type { Options, @@ -94,6 +96,25 @@ export { supportedProtocols } from './provider.ts' export const name = 'llm-pi-ai' export const inject = ['llm'] +declare module '@deepseek-ai/cordis' { + interface Events { + /** + * Prepare headers for one pi-ai inference call, excluding model discovery. + * Listeners run synchronously and must call next(); they may edit only the + * request-owned headers and must not retain them. Throws fail the call with + * REQUEST_HEADERS before inference I/O. Harness attribution wins afterward. + * Non-TypeError messages are user-visible and must exclude secrets. TypeError + * details and non-Error values are replaced with safe guidance. + * @mode waterfall + * @param request - immutable routing metadata from the exact prepared call. + * @param headers - fresh profile headers, shared only within this dispatch. + * @param next - continue header preparation through the remaining listeners. + * @returns headers passed to pi-ai after Harness attribution is applied. + */ + 'llm-pi-ai/request-headers'(request: PiAiRequestHeaderContext, headers: Headers, next: () => Headers): Headers + } +} + const NS = 'llm-pi-ai' /** @@ -218,6 +239,7 @@ export function apply(ctx: Context, config: Config): void { profiles, resolveApiKey, auth, + prepareHeaders: (request, headers) => ctx.waterfall('llm-pi-ai/request-headers', request, headers, () => headers), resolveAttachments: () => ctx.get('attachments'), resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess( attachments, diff --git a/packages/llm/llm-pi-ai/src/types.ts b/packages/llm/llm-pi-ai/src/types.ts new file mode 100644 index 0000000000..32c9f006e6 --- /dev/null +++ b/packages/llm/llm-pi-ai/src/types.ts @@ -0,0 +1,10 @@ +/** Request metadata exposed to pi-ai transport-header contributors. */ + +import type { GenerateOptions } from '@deepseek-ai/dsh-llm' + +/** Model-hidden routing metadata from the exact prepared adapter call. */ +export interface PiAiRequestHeaderContext extends Readonly, + 'provider' | 'model' | 'sessionId' | 'purpose'>> { + /** Resolved endpoint of the model selected for this call. */ + readonly baseURL: string +} diff --git a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts index 7c65de41f2..324574bfb9 100644 --- a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts +++ b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts @@ -9,6 +9,7 @@ import { Context } from '@deepseek-ai/cordis' import Loader, { type ModuleLoaderV2 } from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import LlmRuntime, { createMessage, createUserMessage, userAgent } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' import AuthorizationService from '@deepseek-ai/dsh-authorization' import LocalCredentialProvider from '@deepseek-ai/dsh-credentials-local' import { profileComposition } from '../../../settings/settings/tests/profile-composition.ts' @@ -87,6 +88,25 @@ async function loadComposition(): Promise<{ ctx: Context; settingsPath: string } } describe('llm-pi-ai real dormant composition', () => { + it('dispatches header contributors through the Loader-mounted adapter', async () => { + const server = await mockServer([{ events: textEvents }]) + const { ctx, settingsPath } = await loadComposition() + ctx.on('llm-pi-ai/request-headers', (request, headers, next) => { + headers.set('x-request-session', String(request.sessionId)) + return next() + }) + await writeFile(settingsPath, [ + '- id: llm-pi-ai', ' config:', ' providers:', ' deepseek:', + ' apiKeyEnv: PI_COMPOSITION_KEY', ` baseURL: ${server.url}`, '', + ].join('\n')) + await vi.waitFor(() => { + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek']) + }, { timeout: 5000 }) + const result = await assemble(ctx, { model: 'deepseek-flash', messages: [], sessionId: SessionId('loader-session') }) + expect(result.finish).toEqual({ kind: 'stop' }) + expect(server.headers[0]?.['x-request-session']).toBe('loader-session') + }) + it('boots with zero routes and registers one the moment settings supply a profile', async () => { vi.stubEnv('PI_COMPOSITION_KEY', '') const server = await mockServer([{ events: textEvents }]) diff --git a/packages/llm/llm-pi-ai/tests/request-headers.spec.ts b/packages/llm/llm-pi-ai/tests/request-headers.spec.ts new file mode 100644 index 0000000000..7b26c39317 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/request-headers.spec.ts @@ -0,0 +1,129 @@ +/** Request header contributions reach the provider without crossing call lifetimes. */ + +import { afterEach, expect, it, onTestFinished, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import LlmRuntime, { userAgent } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import * as PiAi from '../src/index.ts' +import { assemble } from './assemble.ts' +import { closeMockServers, mockServer, textEvents } from './mock-server.ts' + +afterEach(async () => { + await closeMockServers() + vi.unstubAllEnvs() +}) + +async function harness(baseURL: string, otherURL?: string, headers?: Record): Promise { + vi.stubEnv('PI_HEADERS_KEY', 'fixture-key') + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(LlmRuntime) + await ctx.plugin(PiAi, { providers: { deepseek: { + apiKeyEnv: 'PI_HEADERS_KEY', baseURL, + headers: { 'X-Company': 'static', 'X-Request-Session': 'static-session', ...headers }, + }, ...otherURL === undefined ? {} : { gateway: { + apiKeyEnv: 'PI_HEADERS_KEY', api: 'openai-completions', baseURL: otherURL, models: [{ id: 'm' }], + } } } }) + return ctx +} + +it.each([false, true])('keeps the last configured value of case-insensitive headers with contributor=%s', async (contribute) => { + const server = await mockServer([{ events: textEvents }]) + const ctx = await harness(server.url, undefined, { + Authorization: 'Bearer old-fixture', authorization: 'Bearer new-fixture', + 'X-Tenant': 'old-tenant', 'x-tenant': 'new-tenant', + }) + if (contribute) { + ctx.on('llm-pi-ai/request-headers', (_request, headers, next) => { + expect(headers.get('authorization')).toBe('Bearer new-fixture') + headers.set('X-Tenant', 'contributed-tenant') + return next() + }) + } + const result = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) + expect(result.finish).toEqual({ kind: 'stop' }) + expect(server.headers[0]?.authorization).toBe('Bearer new-fixture') + expect(server.headers[0]?.['x-tenant']).toBe(contribute ? 'contributed-tenant' : 'new-tenant') + expect(server.headers[0]?.['user-agent']).toBe(userAgent()) +}) + +it('composes request-local headers, enforces attribution, and removes disposed listeners', async () => { + const server = await mockServer(Array.from({ length: 3 }, () => ({ events: textEvents }))) + const ctx = await harness(server.url) + const seen: PiAi.PiAiRequestHeaderContext[] = [] + const remove = ctx.on('llm-pi-ai/request-headers', (request, headers, next) => { + seen.push(request) + expect(Object.isFrozen(request)).toBe(true) + if (request.sessionId === undefined) headers.delete('x-request-session') + else headers.set('x-request-session', request.sessionId) + headers.set('USER-AGENT', 'contributor') + return next() + }) + const removeSecond = ctx.on('llm-pi-ai/request-headers', (_request, headers, next) => { + headers.set('x-second-listener', 'yes') + return next() + }) + await assemble(ctx, { model: 'deepseek-flash', messages: [], sessionId: SessionId('one') }) + await assemble(ctx, { model: 'deepseek-flash', messages: [] }) + expect(seen).toEqual([ + { provider: 'deepseek', model: 'deepseek-flash', baseURL: server.url, sessionId: 'one' }, + { provider: 'deepseek', model: 'deepseek-flash', baseURL: server.url }, + ]) + expect(server.headers[0]).toMatchObject({ + 'x-request-session': 'one', 'x-company': 'static', 'x-second-listener': 'yes', 'user-agent': userAgent(), + }) + expect(server.headers[1]).not.toHaveProperty('x-request-session') + remove() + removeSecond() + await assemble(ctx, { model: 'deepseek-flash', messages: [], sessionId: SessionId('two') }) + expect(server.headers[2]).toMatchObject({ 'x-request-session': 'static-session', 'x-company': 'static' }) + expect(server.headers[2]).not.toHaveProperty('x-second-listener') +}) + +it('uses the prepared endpoint and isolates overlapping requests', async () => { + const first = await mockServer([{ events: textEvents, holdOpen: true }]) + const second = await mockServer([{ events: textEvents }]) + const ctx = await harness(first.url, second.url) + const seen: PiAi.PiAiRequestHeaderContext[] = [] + ctx.on('llm-pi-ai/request-headers', (request, headers, next) => { + seen.push(request) + headers.set('x-request-session', String(request.sessionId)) + return next() + }) + const prepared = await ctx.llm.prepareCall({ provider: 'deepseek', model: 'deepseek-flash' }) + const iterator = prepared.stream({ ...prepared.config, messages: [], sessionId: SessionId('first') })[Symbol.asyncIterator]() + onTestFinished(async () => { await iterator.return?.() }) + await iterator.next() + await assemble(ctx, { provider: 'gateway', model: 'm', messages: [], sessionId: SessionId('second') }) + while (!(await iterator.next()).done) { /* Drain the first response after the second call settles. */ } + expect(seen.map(({ provider, baseURL, sessionId }) => ({ provider, baseURL, sessionId }))).toEqual([ + { provider: 'deepseek', baseURL: first.url, sessionId: 'first' }, + { provider: 'gateway', baseURL: second.url, sessionId: 'second' }, + ]) + expect(first.headers[0]?.['x-request-session']).toBe('first') + expect(second.headers[0]?.['x-request-session']).toBe('second') +}) + +it.each([ + ['throw', 'fixture header failure'], + ['invalid name', 'TypeError during header preparation; check the contributing plugin\'s header names, values, and configuration'], + ['invalid value', 'TypeError during header preparation; check the contributing plugin\'s header names, values, and configuration'], + ['non-Error', 'the contributing plugin threw a non-Error value; throw an Error with a safe diagnostic'], +] as const)('reports a safe reason before HTTP dispatch when a contributor reports %s', async (failure, detail) => { + const server = await mockServer([]) + const ctx = await harness(server.url) + ctx.on('llm-pi-ai/request-headers', (_request, headers, next) => { + if (failure === 'throw') throw new Error('fixture header failure', { cause: new Error('private-cause-fixture') }) + if (failure === 'non-Error') throw 'private-thrown-fixture' + if (failure === 'invalid name') headers.set('invalid\nname', 'value') + else headers.set('authorization', 'Bearer private-header-fixture\nsuffix') + return next() + }) + const result = await assemble(ctx, { model: 'deepseek-flash', messages: [] }) + expect(result.finish).toMatchObject({ kind: 'error', failure: { + code: 'REQUEST_HEADERS', + message: `pi-ai request header preparation failed for provider "deepseek": ${detail}`, + } }) + expect(JSON.stringify(result.finish)).not.toContain('private-') + expect(server.requests).toHaveLength(0) +}) diff --git a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md index 18acf57c7a..0b2af605e8 100644 --- a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md +++ b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md @@ -112,7 +112,7 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo | `@deepseek-ai/dsh-client-ui-sidebar` | no | Sidebar plugin: session multi-level tree, search, grouping, state dots | | `@deepseek-ai/dsh-client-ui-sidebar-browser` | no | Sandboxed Web browser tabs for the right Sidebar | | `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` | yes | Extensible Sidebar previews for Office documents, spreadsheets, Markdown, code, images, PDF, HTML, and plain text | -| `@deepseek-ai/dsh-client-ui-sidebar-files` | no | Workspace file tree tab type for the right Sidebar: lazy directory listing over the workspaceFiles Remote namespace, opening files into the Sidebar | +| `@deepseek-ai/dsh-client-ui-sidebar-files` | no | The workspace files page and one workspace directory tree per pane beside docked and floating file previews, listed lazily over the workspaceFiles Remote namespace | | `@deepseek-ai/dsh-client-ui-sidebar-right` | no | Right Sidebar: the docking surface's session-bound state, its panel and header expand control, and the navigation service over it | | `@deepseek-ai/dsh-client-ui-sidebar-terminal` | no | Interactive shell tabs for the right Sidebar | | `@deepseek-ai/dsh-client-ui-skill` | no | Web skill references and the dedicated skill tool row | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ceac9346a4..e285e87afe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6210,6 +6210,9 @@ importers: '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:* version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-sidebar-files': + specifier: workspace:* + version: link:../ui-sidebar-files '@deepseek-ai/dsh-client-ui-sidebar-right': specifier: workspace:* version: link:../ui-sidebar-right diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 84b6abaf88..d0d0ce7268 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -254,6 +254,7 @@ export const EVENT_SCOPE_PAGE: Record = { 'goal': 'goal.md', 'schedule': 'schedule.md', 'llm': 'llm-streaming.md', + 'llm-pi-ai': 'llm-streaming.md', 'mcp-manager': 'mcp.md', 'permission-presets': 'permission-presets.md', 'session': 'session.md', @@ -432,6 +433,7 @@ export const LINK_MAP: Readonly> = { DeepSeekLlmApiExtensionProvider: 'llm-streaming.md', DeepSeekLlmApiExtensionRequest: 'llm-streaming.md', LlmCallConfig: 'llm-streaming.md', + PiAiRequestHeaderContext: 'llm-streaming.md', ConfigureCall: 'llm-streaming.md', LlmModelContext: 'llm-streaming.md', LlmModelReasoningInfo: 'llm-streaming.md', @@ -946,6 +948,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet = new Set([ 'Entry', 'Array', 'Plugin', 'AbortSignal', + 'Headers', 'AsyncIterable', 'Context', 'Error', diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index 1e092ea165..09fc30cc1e 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -643,7 +643,7 @@ "1bbd18e22567ce0529d02b770533f9bda84ff379b161dda8b9548053a18a0e97": 1 }, "packages/client/ui-sidebar-files/tests/mount.client.tsx": { - "2caa9ae382deea09b4ac64586c1ab49bd935b1e689218e3d485bd7b918e22066": 2, + "2caa9ae382deea09b4ac64586c1ab49bd935b1e689218e3d485bd7b918e22066": 1, "684eae6dc3d4dbe526c5a5b11ebd84e35e335bebd519cf09c0f3a24ba9280fc1": 1 }, "packages/client/ui-sidebar-files/tests/watch.client.spec.ts": { diff --git a/scripts/rescope-vendor.spec.ts b/scripts/rescope-vendor.spec.ts index 18755ce397..0cd000b654 100644 --- a/scripts/rescope-vendor.spec.ts +++ b/scripts/rescope-vendor.spec.ts @@ -1,8 +1,11 @@ /** Recorded npm evidence stays intact while authored files and exact edits remain checked. */ -import { readFileSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { exactEditState, isRescopeExcluded, rescopeText } from './rescope-vendor.ts' +import { exactEditState, isRescopeExcluded, listRescopeFiles, rescopeText } from './rescope-vendor.ts' const ANCHOR = '\n## Sync procedure' const INSERTED = `\n15. **rescope**: one log entry.\n${ANCHOR}` @@ -13,6 +16,27 @@ function expectRoundTrip(file: string, text: string, scoped: string, lines: numb expect(rescopeText(scoped, file, true)).toEqual({ text, lines }) } +it('lists the complete eligible corpus when tracked paths exceed one MiB', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-rescope-paths-')) + // A Git hook or `git rebase -x` exports these; inherited, they would point the fixture at the caller's repository. + const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => + !['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE'].includes(key.toUpperCase()))) + try { + execFileSync('git', ['init', '--quiet'], { cwd: root, env }) + const blob = execFileSync('git', ['hash-object', '-w', '--stdin'], { cwd: root, env, input: '', encoding: 'utf8' }).trim() + const paths = Array.from({ length: 12_000 }, (_, i) => `packages/example/src/${String(i).padStart(5, '0')}-${'x'.repeat(80)}.ts`) + expect(Buffer.byteLength(paths.join('\0'))).toBeGreaterThan(1024 * 1024) + // Index-only entries exercise Git's complete output without creating thousands of source files. + const entries = [...paths, 'scripts/dependency-catalog/package-lock.json'] + execFileSync('git', ['update-index', '--index-info'], { + cwd: root, env, input: entries.map(path => `100644 ${blob}\t${path}\n`).join(''), + }) + expect(listRescopeFiles(root, env)).toEqual(paths) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + describe('rescope file selection', () => { it('preserves the recorded npm resolution', () => { expect(isRescopeExcluded('scripts/dependency-catalog/package-lock.json')).toBe(true) diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts index 3bc3a1f67a..30c607310c 100644 --- a/scripts/rescope-vendor.ts +++ b/scripts/rescope-vendor.ts @@ -662,14 +662,24 @@ export function exactEditState(text: string, find: string, replace: string, expe return hits === expect && landed === 0 ? 'pending' : 'invalid' } +/** + * List tracked paths that the rescope checks or rewrites. + * @param repoRoot - Git working directory. + * @param env - Git's environment; omitted, Git inherits this process's environment. + * @returns eligible repository-relative paths, including large tracked corpora. + */ +export function listRescopeFiles(repoRoot: string, env?: NodeJS.ProcessEnv): string[] { + return execFileSync('git', ['ls-files', '-z'], { cwd: repoRoot, env, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }) + .split('\0') + .filter(file => file !== '' && !isRescopeExcluded(file)) +} + function main(): void { const args = process.argv.slice(2) const mode = args.includes('--apply') ? 'apply' : args.includes('--check') ? 'check' : 'dry' const reverse = args.includes('--reverse') const all = patterns(reverse) - const files = execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8' }) - .split('\0') - .filter(file => file !== '' && !isRescopeExcluded(file)) + const files = listRescopeFiles(root) const counts = new Map() const failures: string[] = [] diff --git a/snapshots/web/markdown-file-links/preview.expected.md b/snapshots/web/markdown-file-links/preview.expected.md index 2b9b91c636..e4c1688f82 100644 --- a/snapshots/web/markdown-file-links/preview.expected.md +++ b/snapshots/web/markdown-file-links/preview.expected.md @@ -1,4 +1,5 @@ - text: {{cwd}}/other/example.txt - button "Line wrap" [pressed] - button "Read the file again" +- button "Show file tree" - text: other file diff --git a/snapshots/web/seeded-history/file-preview.expected.md b/snapshots/web/seeded-history/file-preview.expected.md index 64d5ce902f..a1504927b3 100644 --- a/snapshots/web/seeded-history/file-preview.expected.md +++ b/snapshots/web/seeded-history/file-preview.expected.md @@ -2,4 +2,5 @@ - button "Line wrap" [pressed] - button "Read the file again" - button "Show file location" +- button "Show file tree" - text: alpha diff --git a/tsconfig.host.json b/tsconfig.host.json index a1620b0c6f..6ee794aeed 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -80,12 +80,15 @@ "apps/web/tests/details-session-lifecycle.e2e.ts", "apps/web/tests/sessionless-header.e2e.ts", "apps/web/tests/document-preview.e2e.ts", + "apps/web/tests/file-browser.e2e.ts", + "apps/web/tests/file-browser-composition.e2e.ts", "apps/web/tests/excel-opc.e2e.ts", "apps/web/tests/office-fixture.ts", "apps/web/tests/sidebar-browser.e2e.ts", "apps/web/tests/plugin-config.e2e.ts", "apps/web/tests/input-plugin-extensions.e2e.ts", "apps/web/tests/plugin-manager.e2e.ts", + "apps/web/tests/plugin-market-toolbar.e2e.ts", "apps/web/tests/plugin-sticky-motion.e2e.ts", "apps/web/tests/entry-customization.e2e.ts", "apps/web/tests/cua-optin.e2e.ts",