From c71e907490e53d849cb3cb333c9d34e2390f40ed Mon Sep 17 00:00:00 2001 From: imccyu Date: Fri, 18 Sep 2026 17:17:21 +0800 Subject: [PATCH 01/10] feat(web): add watched sidebar resource refresh --- ...ebar-file-and-directory-auto-refresh.zh.md | 346 ++++++++++++++++++ packages/api/workspace-files/src/changes.ts | 54 ++- .../workspace-files/src/client/change-feed.ts | 35 +- .../workspace-files/src/client/provider.ts | 11 +- .../api/workspace-files/src/client/types.ts | 2 +- packages/api/workspace-files/src/index.ts | 7 +- packages/api/workspace-files/src/types.ts | 6 + .../tests/change-feed.client.spec.ts | 12 +- .../api/workspace-files/tests/changes.spec.ts | 4 +- .../tests/fake-remote.client.ts | 4 +- .../src/client/TextPreview.module.css | 5 + .../src/client/TextPreview.tsx | 45 ++- .../src/client/document/contract.ts | 2 + .../src/client/document/resource-group.ts | 38 ++ .../src/client/face.ts | 18 +- .../src/client/html/HtmlBody.tsx | 35 +- .../src/client/html/read-relative.ts | 14 +- .../src/client/index.ts | 3 +- .../src/client/locales.ts | 6 + .../src/client/office/OfficeBody.tsx | 1 + .../src/client/store.ts | 12 + .../tests/face.client.spec.ts | 8 +- .../tests/fixtures.client.ts | 11 +- .../tests/html-read-relative.client.spec.ts | 8 +- .../markdown-registration.client.spec.tsx | 1 + packages/client/ui-sidebar-files/package.json | 1 + .../src/client/FilesBody.module.css | 5 + .../ui-sidebar-files/src/client/FilesBody.tsx | 17 +- .../src/client/directory-node.ts | 109 ++++++ .../ui-sidebar-files/src/client/face.ts | 64 +++- .../ui-sidebar-files/src/client/index.ts | 4 +- .../ui-sidebar-files/src/client/locales.ts | 6 + .../ui-sidebar-files/src/client/store.ts | 29 +- .../tests/face.client.spec.ts | 6 +- .../ui-sidebar-files/tests/mount.client.tsx | 2 +- .../tests/scripted-list.client.ts | 4 +- packages/fs/fs-local/package.json | 1 + packages/fs/fs-local/src/index.ts | 16 + packages/fs/fs/src/index.ts | 4 + pnpm-lock.yaml | 6 + 40 files changed, 860 insertions(+), 102 deletions(-) create mode 100644 .agents/notes/proposed/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/document/resource-group.ts create mode 100644 packages/client/ui-sidebar-files/src/client/directory-node.ts diff --git a/.agents/notes/proposed/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md b/.agents/notes/proposed/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md new file mode 100644 index 0000000000..638448be8f --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md @@ -0,0 +1,346 @@ +# Agent Note: Sidebar 文件预览与目录树按需监听并自动刷新 + +Status: proposed + +## 问题 + +Sidebar 的 Document Preview 和 Files 面板都需要反映磁盘上的最新状态。变化来源包括 Harness 文件工具、Shell 命令、用户编辑器,以及其他进程;只监听 Harness 自己的文件操作不够。 + +目前两条读取路径都已存在,但缺少完整的自动刷新链路。 + +| 对象 | 现有行为 | 缺口 | +|---|---|---| +| 文件 Resource | `ResourceProvider.open()` 提供初始元数据和后续变化,`ResourceRegistry` 管理订阅及持有期,组件通过 `useResource` 读取 | 文件变化源不是系统级 watch | +| Document Preview | `TextPreview` 已比较 Resource 版本与正文版本,并提供三种加载模式的 reload | 检测到变化后仅提示用户点击重新加载 | +| Workspace 文件事件 | `WorkspaceFiles.changes()` 转发当前 Session 的 `fs/observed`,客户端按文件过滤 | Host 不知道具体关注哪些路径;外部编辑器和 Shell 修改不会产生这类事件 | +| Files 面板 | `filesFace` 读取目录,`createFilesStore` 保存已加载层级及展开状态 | 新增、删除、重命名后需要手动刷新;展开过的目录再次打开可能直接展示旧缓存 | + +`Resource` 当前承载文件元数据,不持有预览正文。正文由 Document Preview 或它选择的渲染器读取。因此,“元数据更新”和“重新加载正文”应保持各自的职责,不把正文缓存搬进通用 Resource,也不要求每个渲染器直接接触文件系统监听。 + +## 方案 + +在现有 FS、Workspace 文件流、Resource 和 Sidebar 组件之间补齐按需监听。文件与目录共用系统监听能力和现有 Remote 流传输,但消费方式不同。 + +- Document Preview:监听当前文件,Resource 发布新元数据,预览自动调用已有 reload。HTML 的根文件和引用的 CSS/JS 都加入同一个 `ResourceGroup`,任一成员变化都使整个 HTML 预览失效。 +- Files 面板:以目录节点树管理读取和监听。每个已打开的目录节点只加载并监听自己的直接子项;Client 打开多少层子树,就逐层加载并订阅多少层,不提前遍历未打开的后代。 +- 折叠目录时释放该目录及其不可见后代的监听;再次展开时重新订阅并读取,不能把折叠前缓存当作最新结果。 +- 监听只传元数据或失效通知;正文仍走现有文件读取接口,目录内容仍走 `list()`。 +- 使用本地 Chokidar 系统监听,不定时轮询整个 Workspace,不默认递归扫描所有后代。 +- 保留两个面板的手动刷新入口和自动刷新开关功能,但暂时隐藏开关的 icon 按钮。开关状态及切换逻辑不变,新 Tab 默认开启;隐藏入口不关闭监控或自动刷新。 + +这是待实现方案。下面明确区分已有类型和拟新增对象;方法名是建议名称,实施时可随局部 API 命名调整。 + +## 类与文件改动 + +### FS 与 Host + +| 类或类型 | 所在文件 | 拟改动 | +|---|---|---| +| `FileSystem` | [fs/src/index.ts](../../../../packages/fs/fs/src/index.ts) | 增加可选的单目标 `watch(target, changed, signal)` 能力;文件观察自身,目录观察直接子项;就绪后返回异步关闭函数,不支持的提供方返回 `undefined` | +| `FsTarget` | [fs/src/types.ts](../../../../packages/fs/fs/src/types.ts) | 沿用现有目标类型,不单独增加 FS 监听事件体系,也不让上层解析 `targetKey` | +| `LocalFileSystem` | [fs-local/src/index.ts](../../../../packages/fs/fs-local/src/index.ts) | 实现 Chokidar 适配;把本机事件归一化为 FS 监听通知,并在结束时等待 watcher 关闭;依赖声明在 `fs-local` | +| `SandboxedFileSystem` | [fs-sandbox/src/index.ts](../../../../packages/fs/fs-sandbox/src/index.ts) | 继承本地只读监听能力,不复制 watcher 实现;保留自身对写入、编辑的策略检查 | +| `SshFileSystem` | [fs-ssh/src/index.ts](../../../../packages/ssh/fs-ssh/src/index.ts) | 明确远端监听能力的支持状态;不把远端 `processPath()` 传给本机 Chokidar。远端系统监听不作为这次本地实现的附带工程 | +| `WorkspaceFiles` | [workspace-files/src/index.ts](../../../../packages/api/workspace-files/src/index.ts) | 将 `changes(scope, signal)` 改为目标明确的监听请求;文件请求沿用 Session 文件解析与访问规则,目录请求沿用 `list()` 的 Workspace 限制 | +| `WorkspaceChangeFeed` | [workspace-files/src/changes.ts](../../../../packages/api/workspace-files/src/changes.ts) | 在现有 follow 中建立目标 watch,发送就绪和变化;保留原队列与操作观察入口,出流前过滤目标,不重写整套分发逻辑 | +| `ChangeFollower` | [workspace-files/src/changes.ts](../../../../packages/api/workspace-files/src/changes.ts) | 接收原有操作观察及新增系统 watch 通知,补充监听错误与关闭完成的处理 | +| `WorkspaceWatchRequest`、`WorkspaceFileWatchFrame` | [workspace-files/src/types.ts](../../../../packages/api/workspace-files/src/types.ts) | 新增目标路径与文件/目录请求类型;沿用 `ready`/`change` 帧,消费方根据自己的请求解释变化,目录不需要再增加一套帧格式 | + +### Client 与 Sidebar + +| 类、函数或类型 | 所在文件 | 拟改动 | +|---|---|---| +| `ChangeFeed`、`SessionFeed`、`Follower` | [workspace-files/src/client/change-feed.ts](../../../../packages/api/workspace-files/src/client/change-feed.ts) | 将流的键改为 Session 加目标路径,继续复用既有就绪、路径绑定、取消和重连逻辑;此版不为了命名重排类结构 | +| `createFileResourceProvider` | [workspace-files/src/client/provider.ts](../../../../packages/api/workspace-files/src/client/provider.ts) | 将文件地址中的 Session 与路径传给目标流;首次读取及后续通知都发布完整元数据,不能只换 `version` 而继续沿用旧 `bytes` | +| `ResourceRegistry`、`ResourceProvider`、`UseResource` | [Resource 定义](../../../../packages/client/resources/src/client/contract.ts)、[ResourceRegistry](../../../../packages/client/resources/src/client/resources.ts) | 保持现有流、持有计数和订阅模型;不新增第二套 `onChange`,不为此增加通用正文 reload API | +| `TextPreview` | [TextPreview.tsx](../../../../packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx) | Resource 或 group 变化且开关开启时自动触发现有 reload;保留错误状态与手动刷新,增加独立的自动刷新按钮 | +| `textFace`、`TabReads`、`createTextStore` | [预览 face](../../../../packages/client/ui-sidebar-documentpreview/src/client/face.ts)、[预览存储](../../../../packages/client/ui-sidebar-documentpreview/src/client/store.ts) | 每个 Tab 持有一个 group,补充默认开启的 `autoRefresh` 和表示待刷新的 `resourcesDirty`;复用正文读取代次与 `loadRevision` | +| 新增的包内 `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 在读取 CSS/JS 前加入资源,解析结束后释放不再引用的成员 | +| 各文档渲染器 | [DocumentContent](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) 及同包各渲染器目录 | 不接 watch;继续消费文本、字节或 renderer revision。检查刷新时取消旧任务、释放旧对象及保留现有视图偏好 | +| `filesFace`、`FilesInjected` | [Files face](../../../../packages/client/ui-sidebar-files/src/client/face.ts) | 继续作为已有业务入口,直接持有各 Tab 根节点;绑定目录读取和变化流,转发展开、折叠、刷新及自动刷新开关,不新增 `FilesController` | +| 新增的包内 `DirectoryNode` | [directory-node.ts](../../../../packages/client/ui-sidebar-files/src/client/directory-node.ts) | 表示一个已打开的目录,拥有本目录变化流、读取状态和已打开子节点;负责局部刷新、展开、递归关闭,以及向子节点传递自动刷新状态 | +| `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) | 将展开、折叠、刷新和面板卸载交给注入回调;按节点展示结果渲染,不扫描整棵树生成监听集合,展示仍只读已有框架钩子与存储 | +| Files 注册入口 | [Files client/index.ts](../../../../packages/client/ui-sidebar-files/src/client/index.ts) | 给现有 `filesFace` 增加目录变化流回调;异步副作用留在注入实现,不引入另一个功能插件的运行时导出 | + +## 监听接口与事件内容 + +### 一条流对应一个明确目标 + +原先 `changes(sessionId)` 无法告诉 Host 哪个文件或目录需要系统监听。新增目录需求后,建议把请求显式分成以下两种,而不是让后端根据当前 `stat` 猜测。 + +```text +changes(sessionId, { kind: 'file', path }, signal) +changes(sessionId, { kind: 'directory', path }, signal) +``` + +Host 方法仍通过 `WorkspaceFileScope` 接收解析后的 Session 上下文。Client 传 Session id,不能自行伪造 Host 的工作目录或权限。 + +`kind` 是监听意图,不随目标暂时消失而变化。例如,文件删除后仍然监听这个文件位置,目录删除后也不能把原目录订阅解释成普通文件订阅。 + +| 通知 | 内容 | 消费方动作 | +|---|---|---| +| 就绪 | 当前一代监听已经建立 | 执行首次或重连后的 `stat()`/`list()`,接收读取期间排队的变化 | +| 文件存在或更新 | 沿用路径和版本通知;Client 收到后重新 stat | 用完整结果更新 Resource 的路径、版本和大小;自动刷新开启时重新读取正文 | +| 文件缺失 | 目标路径和缺失状态 | Resource 报告不可用,预览保留已加载内容并显示错误 | +| 目录失效 | 目录目标流中的 `change` 帧 | 只重新调用这个目录的 `list()`,不按目录版本去重,也不刷新整棵树 | +| 监听失败 | 明确的错误或不支持状态 | 不伪装成文件删除;保留可读内容和手动刷新能力 | + +不用把文件正文、完整目录列表或每个变化的补丁塞进通知。文件名由路径表达;文件大小来自当前 stat;修改时间若只为判断变化,继续由后端版本 token 表达,不要求 UI 解析 token 或另外引入时间戳比较。 + +Resource 发布的路径、版本和大小来自同一次 stat。先沿用现有小通知,再由 Client stat,避免为了减少一次请求扩大事件协议。目录变化不应只比较目录自身 mtime:直接子文件的大小或类型变化也可能影响 `list()` 返回值,应按相关子项事件使目录列表失效。 + +### 系统监听与访问范围 + +- `FileSystem.watch()` 接受本提供方解析出的目标;本机或远端路径的含义由 FS 提供方负责。 +- 文件监听保持文件读取的访问规则。现有预览允许通过所选 FS 读取部分 Workspace 外的路径,不能把目录树的范围限制顺手强加给这些文件。 +- 目录监听保持 `list()` 的 Workspace 范围及路径检查,不因新增 watch 扩大目录浏览权限。 +- 文件监听需要覆盖原地写入、临时文件替换、删除和同一路径重建。Chokidar 可在内部观察必要的父目录,但对外仍是指定文件的监听。 +- 目录监听只关注该目录自身和直接子项,不递归打开尚未展开的子目录;直接子项的创建、删除、重命名、类型或可见元数据变化都使这一层失效。 +- 本地实现采用系统事件模式;若需要调整写入稳定时间、事件合并窗口等部署参数,应由拥有它们的配置声明,不散落在组件里。 +- 系统 watch 只驱动 UI 刷新,不把外部磁盘变化伪装成某个 agent 已读取文件的 `fs/observed`,不改变文件编辑的已观察版本策略。 + +## 文件预览的完整数据流 + +```text +用户在 Files 中点击文件,或从消息中打开文件链接 + → Sidebar 创建/复用预览 Tab + → TabDomain 为文件地址 pin Resource + → ResourceRegistry 打开 createFileResourceProvider 的元数据流 + → 文件提供方提交 changes(sessionId, { kind: 'file', path }) + → WorkspaceFiles 解析目标及访问上下文 + → WorkspaceChangeFeed 获取对应 FileSystem.watch + → LocalFileSystem 建立 Chokidar 监听 + → ready 到达 Client,文件提供方 stat 并发布初始元数据 + +编辑器/Shell/文件工具修改磁盘文件 + → Chokidar 通知 LocalFileSystem + → WorkspaceChangeFeed 读取当前文件元数据 + → Remote 流发送文件变化 + → 文件提供方发布 ResourceSnapshot + → TextPreview 的 useResource 读到新版本 + → 调用所选加载模式的现有 reload + → 新正文或新 renderer revision 交给文档渲染器 + → 页面显示最新内容 +``` + +现有预览正文可以与元数据初始化并行读取,不必为了 watch 重排所有加载逻辑。自动刷新依据“读取开始时观察到的版本”和“实际读到的版本”判断,避免较晚到达的初始 stat 被当作一次新修改。 + +| 加载模式 | 已有刷新入口 | 刷新结果 | +|---|---|---| +| `text-pages` | `reloadPages()` | 废弃旧正文代次,重新读取文本页;不能混合不同版本的页 | +| `bytes-complete` | `reloadAll()` | 重新读取完整字节,PDF、HTML、图片等消费新字节 | +| `renderer` | `prepareRenderer(..., reload = true)` | 增加 `loadRevision`,渲染器取消旧加载并重新请求,例如 Office 转换 | + +普通渲染器不需要添加 `watch()` 或新的 reload 接口。已有 `DocumentContent` 中的文本、字节和 revision 变化已经能驱动重新加载。 + +## HTML 的 ResourceGroup + +一个 HTML 预览的 group 包含 HTML 根文件,以及现有 HTML 打包器实际读取的 CSS/JS 文件。成员都是普通文件 Resource,不为依赖另建 watcher 或额外文件类型。group 是预览包内对象,复用 Resource 已有的订阅和持有计数。 + +```text +index.html Resource ─┐ +theme.css Resource ──┼→ ResourceGroup 变化 → resourcesDirty → 自动刷新开关 → HTML reload +page.js Resource ───┘ +``` + +- `addResource(address, version?)` 在读取依赖时将它加入 group,已有成员不会重复订阅。 +- `setResources(addresses)` 提交本次解析出的依赖列表;预览拥有者始终保留根文件,其余未再出现的成员释放订阅。 +- CSS/JS 的引用由原来的 HTML 相对文件读取逻辑解析,继续使用 HTML 所属 Session,不按浏览器页面地址猜测本机路径。 +- HTML 本身没有改变,但 CSS 或 JS 改变时,也重新加载整个 HTML,生成新的 iframe 内容。 +- 依赖缺失和恢复同样属于成员变化;解析失败时仍保留已经发现的依赖,后续恢复可以再次触发加载。 +- 新成员的初始元数据不是一次修改,不应让 HTML 因为自己的首次依赖读取而无限 reload。 +- group 变化只把 `resourcesDirty` 置为 `true`;开始刷新时清除,读取期间再次变化则重新置位。不累计次数,也不增加另一套读取代次。 +- 关闭预览 Tab 时释放整个 group。关闭自动刷新仅停止自动重读,group 继续观察并记录陈旧状态。 + +此版覆盖打包器现有支持的直接 stylesheet 链接和普通 JS 脚本,不顺手增加 CSS `@import`、ES module 图或运行时网络请求的依赖发现。 + +## 自动刷新开关 + +Document Preview 和 Files 各自保留原 reload 按钮。独立自动刷新开关的 icon 入口暂时隐藏,状态、切换逻辑、文案和样式保留;新 Tab 仍默认 `autoRefresh: true`。隐藏只影响显示,不把功能删除或强制改为不可关闭。 + +| 状态或操作 | 行为 | +|---|---| +| 自动刷新开启 | 开关高亮,使用已有暂停图标,提示“关闭自动刷新”;变化自动刷新对应预览或目录节点 | +| 点击关闭 | 开关显示播放图标,提示“开启自动刷新”;继续观察变化,但不自动替换当前内容 | +| 点击重新开启 | 若暂停期间已有变化,立即补一次刷新;之后继续自动刷新 | +| 点击原 reload | 无论开关状态如何,都主动读取一次,不改变开关状态 | +| 自动刷新关闭时展开新目录 | 新目录仍正常首次加载并订阅;开关只控制后续变化引起的自动重读 | + +暂停/播放使用现有图标,手动 reload 保留环形箭头,不新增图标系统。按钮通过 `aria-pressed` 表达状态,名称和提示走现有 locale 字典。 + +## Files 面板的完整数据流 + +```text +用户打开 Files 面板 + → FilesBody 获得 Session 根目录和 Tab 状态 + → filesFace 打开根 DirectoryNode + → 根节点打开 changes(sessionId, { kind: 'directory', path }) + → Host 建立该目录的非递归监听并发送 ready + → 根节点调用 list(sessionId, path) + → createFilesStore 保存这一层的目录项 + → Level/Entry 展示目录和文件 + +用户展开子目录 + → Entry 将展开动作交给 filesFace + → 父 DirectoryNode 创建并打开指定的子 DirectoryNode + → 子节点建立自己的监听,ready 后读取自己的直接子项 + → 存储更新展开状态与这一层的展示数据 + +外部进程在已监听目录中新增/删除/重命名直接子项 + → Host 发出对应目录的失效通知 + → 对应 DirectoryNode 只重新 list 自己 + → 存储替换这一层目录项,保留其他层和视图偏好 + → 该节点与新列表核对自己已打开的子节点 + → 移除已消失或变为文件的子节点,并递归释放其子树 +``` + +文件预览和目录树是两条独立的消费路径。预览文件可以在 Files 面板没有打开时自动刷新;Files 也可以在没有任何文件预览时自动更新目录项。无需把整棵目录树包装为一个 Resource,也不为此新增目录 Resource 协议。 + +目录面板直接通过注入的 Remote 业务回调取得失效通知和列表结果,显示值统一写回自己的存储;不绕过组件现有的读取方式,不让组件直接持有 Chokidar、Remote 迭代器或底层观察对象。 + +## 目录节点树与生命周期 + +Files 的运行时对象本身就是一棵按需打开的目录树,不先建立整棵 Workspace 树,也不另算一个扁平的 watcher 集合。现有 `filesFace` 持有各 Tab 根节点,读取、监听和释放逐层交给 `DirectoryNode`,不另抽控制器类。 + +| 节点职责 | 具体行为 | +|---|---| +| 本目录身份 | 持有该目录路径和所属 Session/Tab,目录列表只是它的直接子项 | +| 本目录监听 | 一个打开的节点持有一个目标变化流;收到通知只使本节点列表失效 | +| 本目录读取 | 持有有效 list 请求的代次与待补读状态,结果通过存储 actions 发布 | +| 已打开子目录 | 按直接子目录名称持有子 `DirectoryNode`;未展开目录只是列表中的目录项,不创建活动子节点 | +| 展开 | 确认直接子项仍为目录,创建或复用对应子节点,再由子节点建立自己的监听与读取 | +| 折叠 | 关闭并移除对应活动子节点;子节点递归关闭后代,然后释放自己的监听和请求 | +| 列表更新 | 保留仍存在的已打开子节点,关闭已删除、重命名或变为文件的分支;不重新打开所有节点 | +| 整树关闭 | 对根节点执行关闭,自然递归释放整棵活动子树,不另遍历全局监听注册表 | + +`createFilesStore.expanded` 可以继续保存用户的展开偏好,但不是另一份监听注册表。父目录关闭后,即使存储保留了后代展开偏好,也不能继续持有这些后代的活动节点。重新打开时先读取父目录,再对仍存在且需要恢复的子目录逐层建立节点。 + +```text +workspace/ 监听 +├── src/ 已展开 监听 +│ ├── components/ 已展开 监听 +│ └── internal/ 已折叠 不监听内部 +└── assets/ 已折叠 不监听内部 + +折叠 src/ 后: +workspace/ 继续监听 +├── src/ 停止 src/ 与 components/ 的监听 +└── assets/ 不监听内部 +``` + +父目录的监听仍能发现折叠目录自身被删除、重命名或替换,但不负责发现其深层内容变化。再次展开时重新 list,补上未监听期间的变化。 + +| 用户操作或生命周期事件 | 文件预览 | Files 面板 | +|---|---|---| +| 首次打开 | 打开文件 Resource 监听并读取正文 | 打开根节点,由根节点建立监听并读取列表 | +| 展开子目录 | 无影响 | 父节点打开对应子节点;子节点监听并读取直接子项,按偏好逐层恢复仍存在的后代 | +| 折叠子目录 | 已打开的文件预览继续监听 | 释放该目录及其当前不可见后代的监听;可保留展开偏好和显示缓存 | +| 切走 Tab 或面板卸载 | Tab pin 继续持有文件元数据流;正文组件不在时不主动重读,切回来按版本与开关状态补刷新 | 此版沿用 Tab 生命周期,保留已展开目录节点和监听;不另外引入面板显隐生命周期 | +| 手动刷新 | 重用当前预览 reload,不重建整套 Resource | 从根节点递归刷新已打开子节点,不进入折叠目录,也不重建全部 watcher | +| 关闭最后一个持有者 | Resource abort,文件流结束,Host 等待 watcher 关闭 | Tab 关闭时终止全部目录流、取消读取并删除 Tab 状态 | +| 连接恢复 | 新流 ready 后重新 stat,即使断线期间没有可回放事件也能发现变化 | 新流 ready 后重新 list 所有仍需观察的目录 | + +Files 监听跟随 Tab 生命周期;折叠关闭对应子树,关闭 Tab 则释放根节点与整棵活动树。暂时隐藏面板不额外拆除和重建节点,这使改动集中在现有入口和目录对象。存储保留展开偏好,节点只在重新展开时用这份偏好逐层恢复,不维护第二份全局展开集合。 + +同一个完整文件 Resource 地址的共享继续由 `ResourceRegistry` 完成。目录节点的父子所有权保证一个 Tab 内同一分支不会重复打开;第一版不增加跨 Session、跨窗口或路径别名的全局 watcher 去重系统。目录下的普通文件项不各自订阅文件 watch,只有真正打开为预览 Resource 的文件才取得独立文件监听。 + +## 用户可见动线 + +### 编辑已预览的文件 + +1. 用户从 Files 打开 Markdown、代码、图片、PDF、HTML 或 Office 文件。 +2. 用户在外部编辑器保存,或 Shell 命令改写该文件。 +3. 自动刷新默认开启,Document Preview 自动显示新内容;用户关闭开关后可保持当前内容,手动 reload 或重新开启时再读取。 +4. 当前渲染器选择和换行偏好保留;文本滚动位置按现有机制恢复,内容缩短时只能落在新内容允许的位置。 +5. PDF、HTML 和图片的精确阅读位置恢复不是本方案额外承诺;沿用各渲染器现有能力,避免为自动刷新加入一套跨格式定位系统。 + +### 浏览正在变化的目录 + +1. 用户打开 Files,根目录列表加载并开始监听。 +2. 用户展开 `src` 和 `src/components`,两个子目录分别开始监听。 +3. 外部工具在 `src/components` 创建文件,只有这一层列表刷新,其他目录不折叠、不重新加载。 +4. 用户折叠 `src`,其自身和 `components` 的监听关闭,根目录继续监听。 +5. 折叠期间外部工具继续修改内容;用户再次展开 `src` 时重新读取,依次恢复仍有效的子目录展开状态,不停留在旧缓存。 + +### 删除、重命名和失败 + +- 文件删除后,已有预览内容保留并显示不可用提示;原路径重建后自动恢复读取。重命名视为旧路径消失和新路径出现,不根据 inode 自动改写预览地址。 +- 目录删除或重命名后,父目录刷新移除旧行,并释放旧子树监听。新目录按新名称显示,不擅自转移旧路径的展开偏好。 +- 自动读取失败时保留已有可读内容或目录列表,并展示错误与重试入口;没有旧结果时显示现有空态或失败态。 +- 后端不支持 watch 时,初始读取和手动刷新仍可用,但不能把自动刷新标成正常运行。SSH 等提供方不因本地 watch 实现而被宣称支持远端监听。 + +## 就绪、竞态与刷新合并 + +监听、读取与展示只需要处理少数明确的顺序问题,不扩展成磁盘事务或操作日志。 + +- 先建立 watch,再发送 ready,然后取得初始 stat/list。读取过程中到达的变化不能丢弃;必要时再读一次,使最终结果收敛到最新状态。 +- 事件表示“目标可能变化”,不是一次用户操作的精确回放。Chokidar 的重复通知和连续写入可以合并;文件按版本去重,目录按待刷新状态合并。 +- 同一目录最多有一次有效 list 在途;在途期间再次失效,只记录需要补读,完成后读取最新状态,不累计任意长度的刷新任务队列。 +- 文件自动刷新复用读取代次和 renderer revision。连续通知不能让旧请求覆盖新内容,也不能因同一已处理版本而无限 reload。 +- 目录后台刷新保留当前列表,不另存没有显示用途的 `refreshing` 标记;只有没有可显示列表时才进入 `loading`,避免子树闪烁和监听反复重建。 +- 目录折叠、删除或 Tab 关闭后,已取消读取的晚到结果不得重新创建状态或恢复监听。释放流时要等待 Host watcher 真正关闭。 +- 文件或目录短暂消失不能让监听永远结束;正常文件变化与监听自身失败必须分开处理。原子保存与删除重建由同一路径的监听覆盖。 +- 原有文件读取并非事务快照,stat 与正文读取之间仍可能发生写入;本方案保证后续通知触发刷新,不声称每次读取都获得原子快照。 + +## 不采用的替代方案 + +**在 Resource 上再加一套 `onChange` 和正文 reload。** 现有 `open()`、`source()`、`useResource` 已经表达变化,正文又不归通用 Resource 所有。重复接口会使元数据、正文和渲染器分别维护刷新状态。 + +**整个 Workspace 递归 watch。** 打开一个文件或一个根目录不应导致监听所有深层目录。按当前文件和展开目录取得监听,资源使用量与用户实际打开的内容相关。 + +**保留 Session 广播流,再增加路径注册/退订协议。** Host 需要知道具体目标,但不必再维护一个由额外命令修改的路径集合。一目标一流可以直接复用现有取消、重连和结束语义。 + +**直接下发正文或完整目录列表。** 这会混合监听与内容读取,还要重复分页、字节限制、文件格式和目录条目限制。小通知配合现有 `read()`/`list()` 更直接。 + +**根据变化事件在客户端增删目录行。** 文件系统通知可能合并或重复,目录列表还有排序、类型和条目上限。重新 list 受影响的一层即可保持现有语义,不需要维护目录增量合并算法。 + +**在 Workspace API 中直接调用 Chokidar。** API 不拥有执行世界,SSH 路径可能只在远端存在。watch 必须由实际 FS 提供方实现。 + +## 实施范围与验收 + +实施可按下面的顺序推进,每一步都保留同一套目标明确的监听模型。 + +1. FS 定义和本地提供方:文件与非递归目录 watch、就绪、错误和异步释放;沙箱本地提供方继承。 +2. Workspace Remote:目标请求、文件元数据帧、目录失效帧,以及 Client 流消费和重连后的重新读取。 +3. Document Preview:ResourceGroup、HTML 的 CSS/JS 成员、自动刷新开关,以及三种现有 reload 路径。 +4. Files:现有 face 加按需 `DirectoryNode` 树、自动刷新开关、节点局部刷新、折叠递归释放和重新展开逐层补读。 +5. 同步受影响 README、JSDoc、Remote 生成声明和用户输出验证;不修改会话事件日志、持久化格式或模型输入。 + +| 验收场景 | 预期结果 | +|---|---| +| 外部原地写入、原子替换、Shell 写入 | 打开的文件预览自动更新,不依赖 `fs/observed` | +| 文本、完整字节、自主加载渲染器 | 三种模式都刷新,旧请求不能覆盖新结果 | +| HTML 根文件不变,仅 CSS/JS 改变 | group 触发整个 HTML 重新加载 | +| HTML 删除一条依赖引用 | 本次 group 更新后释放不再引用的成员 | +| 关闭自动刷新后修改,再手动刷新或重新开启 | 暂停期间保持内容,手动操作或重新开启后补刷新;按钮职责独立 | +| 根目录直接子项新增、删除、重命名 | Files 根层自动更新 | +| 已展开子目录变化 | 只刷新对应目录层,保留其他层与展开偏好 | +| 未展开的深层目录 | 不创建深层 watcher,不递归遍历 | +| 折叠父目录 | 释放它和不可见后代的监听;文件预览若仍打开则独立继续监听 | +| 重新展开、切回 Files、连接恢复 | 重新读取当前列表,不依赖错过事件的回放 | +| 删除后重建、目录替换为文件 | 正确更新显示并释放失效子树监听,目标重建后能恢复 | +| 快速写入、重复通知、读取中再修改 | 有限次补读并收敛到最新状态,不无限刷新 | +| 关闭 Tab、卸载插件、取消初始化 | 所属监听完全关闭;晚到通知和读取不再写入状态 | +| 不支持监听的提供方、越界目录请求 | 明确报告支持状态或访问错误,不观察错误执行世界或扩大目录权限 | + +验证应包含可控事件源下的就绪与取消测试、真实临时目录上的本地 watch 集成测试,以及从真实 Web 组合进入的文件预览和目录树动线。真实文件测试等待 watcher 就绪后再操作,通过观察到的状态结束,不靠固定 sleep 猜测事件时机。用户可见自动刷新应更新对应的无密钥回放或拥有者本地预期输出,并录制实际产品动线供实现审阅;这些都是后续实现的验收要求,不是本文已经执行的结果。 + +## 风险与后续范围 + +- OS 文件事件不是持久消息,断线或进程重启可能丢通知,因此每一代流就绪后都要重新取得当前状态。 +- 多个已打开文件和展开目录会增加 watcher 与流数量;第一版用按需生命周期控制数量,不提前加入复杂的跨消费者去重服务。 +- 自动刷新可能打断阅读,尤其是 HTML 内部交互和 Office 转换。保留视图偏好、合并连续变化,但不承诺保存任意嵌入文档的运行状态。 +- HTML 的 CSS/JS 通过现有解析器进入 group;更深依赖、图片依赖和运行时动态加载不作为这次修改的额外解析工程。 +- SSH 远端系统监听需要独立的远端实现策略;这里要求接口如实表达不支持,不使用本机 watcher 或隐式轮询冒充远端能力。 + +## 与已有 Agent Note 的关系 + +本提案增加自动刷新和系统监听,不替代已有 Resource、文件读取授权和渲染器职责的整体设计。以下记录仍有独立价值,本文仅引用,不修改或归档它们。 + +- [Client Resource 模型](../../implemented/architecture/2026-09-05-client-resource-model.zh.md):保留地址、提供方、订阅和持有期;新增行为由已有流承载。 +- [Workspace 文件服务](../../implemented/architecture/2026-09-05-workspace-files-service.zh.md):保留读取与列表接口职责;拟调整其 Session 范围观察流及事件来源。 +- [文档预览操作](../../implemented/architecture/2026-09-08-document-preview-operations.zh.md):保留正文加载所有权和三种加载方式;拟改变文件变化后的手动确认刷新行为。 +- [Workspace 文件读取授权](../../implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md):保留文件读取与目录浏览的不同访问范围;目标 watch 分别遵循对应规则。 +- [Sidebar 文本预览与文件树](../../implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md):保留按 Tab 的展开、导航和滚动状态;拟增加目录自动失效及展开期监听。 diff --git a/packages/api/workspace-files/src/changes.ts b/packages/api/workspace-files/src/changes.ts index ccbcc25684..5a656f02ba 100644 --- a/packages/api/workspace-files/src/changes.ts +++ b/packages/api/workspace-files/src/changes.ts @@ -9,11 +9,12 @@ import type { Context } from '@deepseek-ai/cordis' import { Deque } from '@deepseek-ai/dsh-deque' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type { FsObservation, FsTarget } from '@deepseek-ai/dsh-fs' -import type { WorkspaceFileWatchFrame } from './types.ts' +import type { WorkspaceFileWatchFrame, WorkspaceWatchRequest } from './types.ts' /** One `fs/observed` emission as received, before any generation filters it. */ -type Observed = readonly [target: FsTarget, observation: FsObservation] +type Observed = readonly [target: FsTarget, observation?: FsObservation] /** Owns `fs/observed` observation and every open `changes` generation. */ export class WorkspaceChangeFeed { @@ -24,8 +25,10 @@ export class WorkspaceChangeFeed { ctx.on('fs/observed', (target, observation) => { for (const follower of this.followers) follower.push([target, observation]) }) - ctx.effect(() => () => { - for (const follower of this.followers) follower.close() + ctx.effect(() => async () => { + const followers = [...this.followers] + for (const follower of followers) follower.close() + await Promise.all(followers.map(follower => follower.done.promise)) this.followers.clear() }, 'workspace-files.changes') } @@ -37,12 +40,14 @@ export class WorkspaceChangeFeed { * @returns `ready` after observation is active and the root resolves, then * observations made after the generation was first pulled, in emission order. */ - async *follow(workspaceRoot: string, signal: AbortSignal): AsyncIterable { + async *follow(workspaceRoot: string, request: WorkspaceWatchRequest, signal: AbortSignal): AsyncIterable { signal.throwIfAborted() // Registered before the root resolves, so nothing observed while it does is // missed; the root only filters at drain time. const follower = new ChangeFollower() + signal = AbortSignal.any([signal, follower.controller.signal]) this.followers.add(follower) + let unwatch: (() => Promise) | undefined try { // Under the generation's signal, so a consumer leaving mid-resolve on a slow // backend releases the follower now rather than when the resolve settles; @@ -52,26 +57,48 @@ export class WorkspaceChangeFeed { throw error }) if (root === undefined || signal.aborted || follower.isClosed) return + const target = await this.ctx.fs.resolve(request.path, { cwd: workspaceRoot, signal }) + if (request.kind === 'directory' && !this.ctx.fs.contains(root, target)) { + throw new RemoteError('workspace-file/outside-workspace', 'Directory is outside the workspace', { path: request.path }) + } + const watching = this.ctx.fs.watch(target, (error) => { + if (error !== undefined) follower.fail(error) + else if (!follower.isClosed) follower.push([target]) + }, signal) + if (watching === undefined) { + throw new RemoteError('workspace-file/watch-unsupported', 'Filesystem watching is unavailable', { path: request.path }) + } + unwatch = await watching + if (signal.aborted) return yield { kind: 'ready' } - for await (const [target, observation] of follower.read(signal)) { - if (!this.ctx.fs.contains(root, target)) continue + for await (const [observed] of follower.read(signal)) { + if (observed.targetKey !== target.targetKey) continue + const info = await this.ctx.fs.stat(target, signal) const absolutePath = this.ctx.fs.processPath(target) yield { kind: 'change', - change: observation.kind === 'present' - ? { absolutePath, version: observation.version } + change: info !== undefined + ? { absolutePath, version: info.version } : { absolutePath, absent: true }, } } } finally { - this.followers.delete(follower) follower.close() + try { + await unwatch?.() + } finally { + this.followers.delete(follower) + follower.done.resolve() + } } } } /** One generation's queue: observations wait here until its consumer pulls them. */ class ChangeFollower { + readonly controller = new AbortController() + readonly done = Promise.withResolvers() + private error: Error | undefined private readonly queue = new Deque() private wake: (() => void) | undefined private closed = false @@ -88,9 +115,15 @@ class ChangeFollower { close(): void { this.closed = true + this.controller.abort() this.wake?.() } + fail(error: Error): void { + this.error = error + this.close() + } + /** Drain until closed or aborted; anything still queued then is dropped with the generation. */ async *read(signal: AbortSignal): AsyncIterable { const abort = (): void => { this.close() } @@ -106,6 +139,7 @@ class ChangeFollower { await new Promise((resolve) => { this.wake = resolve }) this.wake = undefined } + if (this.error !== undefined) throw this.error } finally { signal.removeEventListener('abort', abort) } diff --git a/packages/api/workspace-files/src/client/change-feed.ts b/packages/api/workspace-files/src/client/change-feed.ts index 39f43c3940..3ce7090f7b 100644 --- a/packages/api/workspace-files/src/client/change-feed.ts +++ b/packages/api/workspace-files/src/client/change-feed.ts @@ -83,7 +83,7 @@ class Follower implements AsyncIterable { while (true) { const next = this.pending.shift() if (next !== undefined) { - if (this.hostKey === undefined || next.key === this.hostKey) yield next.notice + if (next.notice.kind === 'refresh' || this.hostKey === undefined || next.key === this.hostKey) yield next.notice continue } if (this.ended) return @@ -102,6 +102,7 @@ class SessionFeed { private readonly stream: SupervisedStream private closed = false private started = false + private acknowledged = false /** * @param remote - the Remote face carrying `workspaceFiles.changes`. @@ -112,6 +113,7 @@ class SessionFeed { constructor( remote: WorkspaceFilesRemote, sessionId: SessionId, + path: string, after: Promise | undefined, private readonly onClose: (closed: Promise) => void, ) { @@ -121,7 +123,7 @@ class SessionFeed { // two Host streams open at once. open: (signal) => { this.started = false - return openAfter(after, () => remote.workspaceFiles.changes(sessionId, signal)) + return openAfter(after, () => remote.workspaceFiles.changes(sessionId, { kind: 'file', path }, signal)) }, // A normal end means the Host closed the session's feed: the session is // gone or the Host is shutting down, so there is nothing to reopen. @@ -156,7 +158,11 @@ class SessionFeed { case 'ready': item.accept() this.started = true - for (const follower of this.followers) follower.start() + for (const follower of this.followers) { + follower.start() + if (this.acknowledged) follower.push({ kind: 'refresh' }, '') + } + this.acknowledged = true break case 'change': { const key = keyOf(frame.change.absolutePath) @@ -217,9 +223,9 @@ function assertNever(frame: never): never { */ export class ChangeFeed { /** Live feeds only: a feed removes itself when its stream closes. */ - private readonly sessions = new Map() + private readonly sessions = new Map() /** Streams still closing, by session: the session's next feed opens after its predecessor has settled. */ - private readonly closing = new Map>() + private readonly closing = new Map>() /** * @param remote - the Remote face carrying `$stream` and `workspaceFiles.changes`. @@ -242,8 +248,8 @@ export class ChangeFeed { * @param signal - ends the follow. * @returns a single-consumer subscription with Host-path binding and explicit disposal. */ - follow(sessionId: SessionId, signal: AbortSignal): Follower { - const feed = signal.aborted ? undefined : this.feedOf(sessionId) + follow(sessionId: SessionId, path: string, signal: AbortSignal): Follower { + const feed = signal.aborted ? undefined : this.feedOf(sessionId, path) const leave = (): void => { signal.removeEventListener('abort', leave) follower.end() @@ -268,18 +274,19 @@ export class ChangeFeed { await Promise.all(this.closing.values()) } - private feedOf(sessionId: SessionId): SessionFeed { - const existing = this.sessions.get(sessionId) + private feedOf(sessionId: SessionId, path: string): SessionFeed { + const key = JSON.stringify([sessionId, path]) + const existing = this.sessions.get(key) if (existing !== undefined) return existing - const feed = new SessionFeed(this.remote, sessionId, this.closing.get(sessionId), (closed) => { - this.sessions.delete(sessionId) + const feed = new SessionFeed(this.remote, sessionId, path, this.closing.get(key), (closed) => { + this.sessions.delete(key) // A dispose that rejects is still a settled close: nothing remains to wait for. const tracked: Promise = closed.then(() => undefined, () => undefined).then(() => { - if (this.closing.get(sessionId) === tracked) this.closing.delete(sessionId) + if (this.closing.get(key) === tracked) this.closing.delete(key) }) - this.closing.set(sessionId, tracked) + this.closing.set(key, tracked) }) - this.sessions.set(sessionId, feed) + this.sessions.set(key, feed) return feed } } diff --git a/packages/api/workspace-files/src/client/provider.ts b/packages/api/workspace-files/src/client/provider.ts index 7816332d2e..bc793eda5d 100644 --- a/packages/api/workspace-files/src/client/provider.ts +++ b/packages/api/workspace-files/src/client/provider.ts @@ -57,7 +57,7 @@ export function createFileResourceProvider( } const { sessionId, path } = resolved.value // Queue changes delivered to this Client while stat is pending. - const notices = changes.follow(sessionId, signal) + const notices = changes.follow(sessionId, path, signal) const stat = (): Promise> => remote.workspaceFiles.stat(sessionId, path, signal) // Read through a call: a plain `signal.aborted` is narrowed to `false` by // the first check and would read as always-false after the later awaits. @@ -66,7 +66,11 @@ export function createFileResourceProvider( // on the file, so a write can still bring the file live. let current: WorkspaceFileStat | undefined try { - if (!await notices.ready || aborted()) return + if (!await notices.ready) { + if (!aborted()) yield await stat() + return + } + if (aborted()) return const first = await stat() if (aborted()) return if (first.ok) { @@ -84,9 +88,6 @@ export function createFileResourceProvider( // Frames report observations: holding this version already means the // consumer learns nothing new. if (notice.version === current.version) continue - current = { ...current, version: notice.version } - yield { ok: true, value: current } - continue } // A Host notice may mean stale content. const again = await stat() diff --git a/packages/api/workspace-files/src/client/types.ts b/packages/api/workspace-files/src/client/types.ts index dcd6e74e31..b17b47e7d4 100644 --- a/packages/api/workspace-files/src/client/types.ts +++ b/packages/api/workspace-files/src/client/types.ts @@ -47,4 +47,4 @@ export type WorkspaceFileEdit = | { readonly kind: 'absent' } /** What one follower of a path receives: a Host write. */ -export type WorkspaceFileNotice = WorkspaceFileEdit +export type WorkspaceFileNotice = WorkspaceFileEdit | { readonly kind: 'refresh' } diff --git a/packages/api/workspace-files/src/index.ts b/packages/api/workspace-files/src/index.ts index 64e91c7fbf..11e771327f 100644 --- a/packages/api/workspace-files/src/index.ts +++ b/packages/api/workspace-files/src/index.ts @@ -39,6 +39,7 @@ import type { WorkspaceFileStat, WorkspaceFileText, WorkspaceFileWatchFrame, + WorkspaceWatchRequest, } from './types.ts' export type * from './types.ts' @@ -361,8 +362,10 @@ export class WorkspaceFiles extends TypertRemoteService { * root is resolved, then queued and live observations in emission order. */ @Remote({ mode: 'stream' }) - changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable { - return this.feed.follow(workspaceFileScope.workspaceRoot, signal) + changes( + workspaceFileScope: WorkspaceFileScope, request: WorkspaceWatchRequest, signal: AbortSignal, + ): AsyncIterable { + return this.feed.follow(workspaceFileScope.workspaceRoot, request, signal) } /** Apply the page defaults and caps here, so the request never carries them implicitly. */ diff --git a/packages/api/workspace-files/src/types.ts b/packages/api/workspace-files/src/types.ts index 93b3725775..d313608730 100644 --- a/packages/api/workspace-files/src/types.ts +++ b/packages/api/workspace-files/src/types.ts @@ -142,8 +142,14 @@ export type WorkspaceFileWatchFrame = | { readonly kind: 'ready' } | { readonly kind: 'change'; readonly change: WorkspaceFileChange } +export interface WorkspaceWatchRequest { + readonly path: string + readonly kind: 'file' | 'directory' +} + declare module '@deepseek-ai/dsh-typert-protocol' { interface RemoteErrorDetailsMap { + 'workspace-file/watch-unsupported': { readonly path: string } /** No entry exists at that path inside the workspace. */ 'workspace-file/not-found': { readonly path: string } /** The directory listing path resolves outside the session's workspace root. */ diff --git a/packages/api/workspace-files/tests/change-feed.client.spec.ts b/packages/api/workspace-files/tests/change-feed.client.spec.ts index 3bae81a440..e088e1cb87 100644 --- a/packages/api/workspace-files/tests/change-feed.client.spec.ts +++ b/packages/api/workspace-files/tests/change-feed.client.spec.ts @@ -16,7 +16,7 @@ function harness() { const remote = new FakeRemote() const feed = new ChangeFeed(remote) const follow = (sessionId: SessionId, path: string, controller = new AbortController()) => { - const follower = feed.follow(sessionId, controller.signal) + const follower = feed.follow(sessionId, path, controller.signal) follower.bind(path) return { it: follower[Symbol.asyncIterator](), controller } } @@ -27,10 +27,10 @@ describe('ChangeFeed — one Host stream per session', () => { it('starts a later follower from the existing session acknowledgement without opening another stream', async () => { const { remote, feed } = harness() const controller = new AbortController() - const first = feed.follow(S1, controller.signal) + const first = feed.follow(S1, '/w/second.txt', controller.signal) try { await expect(first.ready).resolves.toBe(true) - const second = feed.follow(S1, controller.signal) + const second = feed.follow(S1, '/w/second.txt', controller.signal) await expect(second.ready).resolves.toBe(true) expect(remote.calls).toEqual(['changes', 'accept']) expect(remote.opened).toHaveLength(1) @@ -195,8 +195,8 @@ describe('ChangeFeed — a follower ends', () => { it('ends every follower and disposes the session stream for an unknown wire frame kind', async () => { const { remote, feed } = harness() const controller = new AbortController() - const first = feed.follow(S1, controller.signal) - const second = feed.follow(S1, controller.signal) + const first = feed.follow(S1, '/w/a.txt', controller.signal) + const second = feed.follow(S1, '/w/a.txt', controller.signal) const firstIterator = first[Symbol.asyncIterator]() const secondIterator = second[Symbol.asyncIterator]() try { @@ -236,7 +236,7 @@ describe('ChangeFeed — a follower ends', () => { const { remote, feed } = harness() const controller = new AbortController() controller.abort() - const it = feed.follow(S1, controller.signal)[Symbol.asyncIterator]() + const it = feed.follow(S1, '/w/a.txt', controller.signal)[Symbol.asyncIterator]() await expect(it.next()).resolves.toEqual({ done: true, value: undefined }) await settle() expect(remote.opened).toEqual([]) diff --git a/packages/api/workspace-files/tests/changes.spec.ts b/packages/api/workspace-files/tests/changes.spec.ts index 1eaa43cc5e..a43b35d576 100644 --- a/packages/api/workspace-files/tests/changes.spec.ts +++ b/packages/api/workspace-files/tests/changes.spec.ts @@ -38,7 +38,7 @@ function open( service: WorkspaceFiles, controller = new AbortController(), ): { next(): Promise>; controller: AbortController } { - const iterator = service.changes(harness.scope, controller.signal)[Symbol.asyncIterator]() + const iterator = service.changes(harness.scope, { kind: 'directory', path: harness.workspace }, controller.signal)[Symbol.asyncIterator]() closeStreams.push(async () => { controller.abort() await iterator.return?.() @@ -245,7 +245,7 @@ describe('workspaceFiles.changes — ending', () => { it('stops delivering to a generation the consumer returned from', async () => { const service = harness.endpoint() const controller = new AbortController() - const iterator = service.changes(harness.scope, controller.signal)[Symbol.asyncIterator]() + const iterator = service.changes(harness.scope, { kind: 'directory', path: harness.workspace }, controller.signal)[Symbol.asyncIterator]() closeStreams.push(async () => { controller.abort() await iterator.return?.() diff --git a/packages/api/workspace-files/tests/fake-remote.client.ts b/packages/api/workspace-files/tests/fake-remote.client.ts index c34ca31256..c94f33d87e 100644 --- a/packages/api/workspace-files/tests/fake-remote.client.ts +++ b/packages/api/workspace-files/tests/fake-remote.client.ts @@ -5,7 +5,7 @@ */ import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { WorkspaceFileWatchFrame, WorkspaceFileStat } from '../src/types.ts' +import type { WorkspaceFileWatchFrame, WorkspaceFileStat, WorkspaceWatchRequest } from '../src/types.ts' import type { SupervisedStream, SupervisedStreamOptions, WorkspaceFilesRemote } from '../src/client/remote.ts' /** One scripted Host `changes` generation: frames pushed by the spec, ended by abort. */ @@ -154,7 +154,7 @@ export class FakeRemote implements WorkspaceFilesRemote { for (const waiter of this.statWaiters.get(index) ?? []) waiter(stat) this.statWaiters.delete(index) }), - changes: (sessionId: SessionId, signal?: AbortSignal): AsyncIterable => { + changes: (sessionId: SessionId, _request: WorkspaceWatchRequest, signal?: AbortSignal): AsyncIterable => { this.calls.push('changes') if (signal === undefined) throw new Error('the feed must hand its signal to the Host stream') const source = new Source(signal) 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 46757a6287..490ec8e67c 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css @@ -311,6 +311,11 @@ color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); } + +.tool[data-textpreview-tool='auto-refresh'][aria-pressed='true'] { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-hover); +} .viewerTool { flex: none; width: auto; diff --git a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx index 432da7dd17..c26685cf2e 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx @@ -16,7 +16,8 @@ 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 { - FileTypeIcon, IconNowrapFillRegular, IconRefreshOutlineRegular, IconWrapFillRegular, Menu, Tooltip, classifyFileType, + FileTypeIcon, IconNowrapFillRegular, IconPauseOutlineRegular, IconPlayOutlineRegular, + 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' @@ -96,6 +97,7 @@ export type TextPreviewProps = export function TextPreview({ useTabInfo, useResource, useStore, actions, loadPage, reloadPages, loadAll, reloadAll, prepareRenderer, useDocumentPreviews, renderSlot, t, + addResource, setResources, }: TextPreviewProps): ReactNode { const { tab } = useTabInfo() const { navigation, signal } = tab @@ -116,6 +118,15 @@ export function TextPreview({ const mode = selected?.loading const contentRendererId = mode === 'renderer' ? selected?.id : undefined const current = (state?.mode ?? 'text-pages') === mode && state?.contentRendererId === contentRendererId ? state : undefined + const add = useCallback((address: string, version?: string) => { + addResource(tab.id, address, signal, version) + }, [addResource, tab.id, signal]) + const set = useCallback((addresses: readonly string[]) => { + setResources(tab.id, [tab.contentId, ...addresses], signal) + }, [setResources, tab.id, tab.contentId, signal]) + useEffect(() => { + set([]) + }, [set, selected?.id]) const bodyRef = useRef(null) const scrollportRef = useRef(null) const storedScrollTopRef = useRef(0) @@ -195,6 +206,19 @@ export function TextPreview({ const rendererReload = useCallback((): void => { if (canRead && selected !== undefined) prepareRenderer(tab.id, signal, selected.id, meta.value?.version, true) }, [canRead, prepareRenderer, tab.id, signal, selected?.id, meta.value?.version]) + const observedVersion = meta.value?.version + const changed = (current?.version !== undefined && observedVersion !== undefined + && observedVersion !== current.version && observedVersion !== current.observedVersion) + || state?.resourcesDirty === true + const reload = useCallback((): void => { + if (!canRead) return + if (mode === 'text-pages') reloadPages(tab.id, file, signal, observedVersion) + else if (mode === 'bytes-complete') reloadAll(tab.id, file, signal, observedVersion) + else rendererReload() + }, [canRead, mode, reloadPages, reloadAll, rendererReload, tab.id, file, signal, observedVersion]) + useEffect(() => { + if (state?.autoRefresh && changed && current !== undefined && !current.loading && meta.status === 'live') reload() + }, [state?.autoRefresh, changed, current?.loading, meta.status, reload]) const content = useMemo((): DocumentContent | undefined => { if (mode === 'renderer') { if (current === undefined) return undefined @@ -243,19 +267,10 @@ export function TextPreview({ } const next = loadedThrough + 1 const { name } = pathPartsOf(displayPath) - const observedVersion = meta.value?.version - const changed = current?.version !== undefined && observedVersion !== undefined - && observedVersion !== current.version && observedVersion !== current.observedVersion const loadNext = (): void => { if (!canRead || current?.loading || current?.eof) return loadPage(tab.id, file, next, signal, meta.value?.version) } - const reload = (): void => { - if (!canRead) return - if (mode === 'text-pages') reloadPages(tab.id, file, signal, meta.value?.version) - else if (mode === 'bytes-complete') reloadAll(tab.id, file, signal, meta.value?.version) - else rendererReload() - } return (
{meta.failure !== undefined && hasContent @@ -326,6 +341,15 @@ export function TextPreview({ )} {content !== undefined && renderSlot('sidebar.right.tab.document.action', { content }, { entryKey: selected.id, hookContext: useTabInfo })} +
diff --git a/packages/client/ui-sidebar-documentpreview/src/client/store.ts b/packages/client/ui-sidebar-documentpreview/src/client/store.ts index f34b7e2dbb..755b523867 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/store.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/store.ts @@ -31,6 +31,8 @@ export interface TextPage { /** One tab's pages and view. */ export interface TextTabState { + autoRefresh: boolean + resourcesDirty: boolean /** Explicit viewer choice for this tab; absence follows automatic matching. */ rendererId?: string /** Current display-loading mode; absent before the first read. */ @@ -72,6 +74,8 @@ export interface TextState { */ export function fresh(): TextTabState { return { + autoRefresh: true, + resourcesDirty: false, loadRevision: 0, version: undefined, observedVersion: undefined, @@ -92,6 +96,8 @@ function bucket(state: TextState, tabId: TabId): TextTabState { /** The preview store's write set; every action names the tab it writes. */ type TextActions = { + toggledAutoRefresh: (draft: TextState, tabId: TabId) => void + resourceChanged: (draft: TextState, tabId: TabId) => void selected: (draft: TextState, tabId: TabId, rendererId: string | undefined) => void loading: (draft: TextState, tabId: TabId, mode?: DocumentLoadMode, observedVersion?: string, contentRendererId?: string) => void rendered: (draft: TextState, tabId: TabId, revision: number, version: string) => void @@ -116,6 +122,11 @@ export function createTextStore(): EngineStoreHandle { return defineStore({ init: (): TextState => ({ byTab: {} }), actions: { + toggledAutoRefresh: (d, tabId: TabId) => { + const state = bucket(d, tabId) + state.autoRefresh = !state.autoRefresh + }, + resourceChanged: (d, tabId: TabId) => { bucket(d, tabId).resourcesDirty = true }, /** @param d - draft. @param tabId - owning tab. @param rendererId - manual choice, or automatic selection. */ selected: (d, tabId: TabId, rendererId: string | undefined) => { if (rendererId === undefined) delete bucket(d, tabId).rendererId @@ -191,6 +202,7 @@ export function createTextStore(): EngineStoreHandle { */ reset: (d, tabId: TabId) => { const state = bucket(d, tabId) + state.resourcesDirty = false state.loadRevision++ state.pages = {} delete state.complete diff --git a/packages/client/ui-sidebar-documentpreview/tests/face.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/face.client.spec.ts index 9a728c0844..fa1574afe5 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/face.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/face.client.spec.ts @@ -14,7 +14,7 @@ import { textFace } from '../src/client/face.ts' import type { DocumentFileBytes, ReadDocumentBytes, ReadWorkspaceFilePage } from '../src/client/rpc.ts' import { hostFileOf } from '../src/client/rpc.ts' import { createTextStore } from '../src/client/store.ts' -import { ABSOLUTE_PATH, FILE, PATH, SESSION, failure, page } from './fixtures.client.ts' +import { ABSOLUTE_PATH, FILE, PATH, SESSION, createResources, failure, page } from './fixtures.client.ts' import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' const TAB_1 = 'tab-1' as TabId @@ -94,7 +94,7 @@ function bench(sessionId = 'other-session' as SessionId) { // The store's own `forget`, counted: the record's end must forget a tab exactly once. const forget = vi.fn(instance.actions.forget) // Injected for another session on purpose: the address's session must win. - const face = textFace(read, bytes)(sessionId, { ...instance.actions, forget }) + const face = textFace(read, bytes, createResources())(sessionId, { ...instance.actions, forget }) /** Settle the oldest outstanding read, or the oldest one for `offset`. */ const settle = async (result: RemoteResult, offset?: number): Promise => { const at = offset === undefined ? 0 : pending.findIndex(call => call.offset === offset) @@ -403,7 +403,7 @@ it.each([new Error('invalid bytes'), 'invalid bytes'])('reports an active comple const controller = new AbortController() const read = vi.fn().mockRejectedValue(failure) try { - textFace(vi.fn(), read)(SESSION, instance.actions).loadAll(TAB_1, FILE, controller.signal) + textFace(vi.fn(), read, createResources())(SESSION, instance.actions).loadAll(TAB_1, FILE, controller.signal) await Promise.resolve() expect(instance.getSnapshot().byTab[TAB_1]?.failure).toMatchObject({ code: 'gateway/internal', message: 'invalid bytes' }) } finally { controller.abort() } @@ -413,7 +413,7 @@ it('ignores a complete-read rejection after renderer-owned loading takes over', const instance = createTextStore().create() const controller = new AbortController() const pending = Promise.withResolvers>>() - const face = textFace(vi.fn(), () => pending.promise)(SESSION, instance.actions) + const face = textFace(vi.fn(), () => pending.promise, createResources())(SESSION, instance.actions) try { face.loadAll(TAB_1, FILE, controller.signal) face.prepareRenderer(TAB_1, controller.signal, 'office') diff --git a/packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts b/packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts index bbc9c2dea6..d34bd4bc5a 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts @@ -8,6 +8,8 @@ * not the slot runtime. */ import { onTestFinished, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { ResourceRegistry } from '../../resources/src/client/resources.ts' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import type { Mock } from 'vitest' import { act } from '@testing-library/react' @@ -29,6 +31,12 @@ import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' type BodySlot = PropsRenderSlots<'sidebar.right.tab.document'>['renderSlot'] +export function createResources(): ResourceRegistry { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + return new ResourceRegistry(ctx) +} + /** Preserve the body-slot callback used by component fixtures. */ export function documentSlots(body: BodySlot): TextPreviewProps['renderSlot'] { return (name: string, owner: unknown, options?: unknown) => name === 'sidebar.right.tab.document' @@ -124,7 +132,7 @@ export function harness(script: Record> const read = vi.fn((_session, _path, offset) => Promise.resolve(pages[offset] ?? failure('workspace-file/not-found', { path: PATH }))) const bytes = vi.fn() - const face = textFace(read, bytes)(SESSION, instance.actions) + const face = textFace(read, bytes, createResources())(SESSION, instance.actions) const current = { version: 'v1' as string | undefined, failure: undefined as RemoteFailure | undefined, snapshot: meta('v1', undefined) } const refresh = (): void => { current.snapshot = meta(current.version, current.failure) } const useResource = vi.fn<() => ResourceSnapshot>(() => current.snapshot) @@ -162,6 +170,7 @@ export function harness(script: Record> reloadPages: face.reloadPages, prepareRenderer: face.prepareRenderer, loadAll: face.loadAll, reloadAll: face.reloadAll, + addResource: face.addResource, setResources: face.setResources, useDocumentPreviews: () => definitions, renderSlot, t, diff --git a/packages/client/ui-sidebar-documentpreview/tests/html-read-relative.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/html-read-relative.client.spec.ts index a7bd5a70f5..e4c7656808 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/html-read-relative.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/html-read-relative.client.spec.ts @@ -12,7 +12,7 @@ describe('HTML relative file reader', () => { const readRelated = vi.fn().mockResolvedValue({ ok: true, value }) const tab = new AbortController() const loading = new AbortController() - const read = createReadHtmlRelative(readRelated, ADDRESS, tab.signal) + const read = createReadHtmlRelative(readRelated, ADDRESS, tab.signal, vi.fn()) await expect(read('../a%20b.js?v=1#fragment', loading.signal)).resolves.toEqual({ ...value, data: new Uint8Array([120]) }) const signal = readRelated.mock.calls[0]?.[2] expect(readRelated).toHaveBeenCalledExactlyOnceWith(ADDRESS, '../a b.js', signal) @@ -26,7 +26,7 @@ describe('HTML relative file reader', () => { ok: false, error: new RemoteError('workspace-file/outside-workspace', 'outside workspace', { path: '../x.js' }), }) const signal = new AbortController().signal - const read = createReadHtmlRelative(readRelated, ADDRESS, signal) + const read = createReadHtmlRelative(readRelated, ADDRESS, signal, vi.fn()) for (const path of ['', '/x.js', 'file:///x.js', '%2Fx.js', 'C:/x.js', '..\\x.js', '%00.js', '%ZZ.js']) { await expect(read(path, signal)).rejects.toThrow() } @@ -38,7 +38,7 @@ describe('HTML relative file reader', () => { const controller = new AbortController() controller.abort() const readRelated = vi.fn() - const read = createReadHtmlRelative(readRelated, ADDRESS, controller.signal) + const read = createReadHtmlRelative(readRelated, ADDRESS, controller.signal, vi.fn()) await expect(read('x.js', new AbortController().signal)).rejects.toMatchObject({ name: 'AbortError' }) expect(readRelated).not.toHaveBeenCalled() }) @@ -47,7 +47,7 @@ describe('HTML relative file reader', () => { const pending = Promise.withResolvers>>() const readRelated = vi.fn().mockReturnValue(pending.promise) const loading = new AbortController() - const read = createReadHtmlRelative(readRelated, ADDRESS, new AbortController().signal) + const read = createReadHtmlRelative(readRelated, ADDRESS, new AbortController().signal, vi.fn()) const result = read('./late.js', loading.signal) const rejected = expect(result).rejects.toMatchObject({ name: 'AbortError' }) loading.abort() diff --git a/packages/client/ui-sidebar-documentpreview/tests/markdown-registration.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/markdown-registration.client.spec.tsx index 4e328a1977..1441718b91 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/markdown-registration.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/markdown-registration.client.spec.tsx @@ -61,6 +61,7 @@ describe('Markdown implementation registration', () => { content: { kind: 'text', text: '# Notes\n\n```ts\nconst value = 1\n```', pages: [], eof: true }, wrap: false, scrollportRef: vi.fn(), + addResource: vi.fn(), setResources: vi.fn(), }, { entryKey: MARKDOWN_BODY_ID, hookContext: useTabInfo, diff --git a/packages/client/ui-sidebar-files/package.json b/packages/client/ui-sidebar-files/package.json index 10768f4300..20d74ac18c 100644 --- a/packages/client/ui-sidebar-files/package.json +++ b/packages/client/ui-sidebar-files/package.json @@ -58,6 +58,7 @@ "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", 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 a49f6688a3..a069eec6a8 100644 --- a/packages/client/ui-sidebar-files/src/client/FilesBody.module.css +++ b/packages/client/ui-sidebar-files/src/client/FilesBody.module.css @@ -201,6 +201,11 @@ background: var(--dsw-alias-interactive-bg-hover); } +.tool[aria-pressed='true'] { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-hover); +} + /* The folder sheet before the chip's text; the chip's title row centres it on the text's line and spaces it. */ .titleIcon { diff --git a/packages/client/ui-sidebar-files/src/client/FilesBody.tsx b/packages/client/ui-sidebar-files/src/client/FilesBody.tsx index 9d3fb42f48..ba33b433c4 100644 --- a/packages/client/ui-sidebar-files/src/client/FilesBody.tsx +++ b/packages/client/ui-sidebar-files/src/client/FilesBody.tsx @@ -17,6 +17,7 @@ import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' import type { PropsLocale, PropsRuntime, PropsStore, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import { FileTypeIcon, IconFolderCloseRegular, IconFolderOpenRegular, IconRefreshOutlineRegular, classifyFileType, + IconPauseOutlineRegular, IconPlayOutlineRegular, } from '@deepseek-ai/dsh-client-ui-primitives' import { fileAddressFor, pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path' import type { WorkspaceDirectoryEntry } from '@deepseek-ai/dsh-api-workspace-files/types' @@ -159,6 +160,7 @@ function Level({ path, tree }: { path: string; tree: TreeContext }): ReactNode { 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')}
  • } @@ -168,7 +170,7 @@ function Level({ path, tree }: { path: string; tree: TreeContext }): ReactNode { /** 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, load, toggle, t, + useTabInfo, sessionId, useSessions, useStore, actions, start, refresh, setAutoRefresh, toggle, t, }: FilesBodyProps): ReactNode { const { tab } = useTabInfo() const { signal, actions: tabActions } = tab @@ -213,7 +215,7 @@ export function FilesBody({ if (state === undefined) return null const tree: TreeContext = { state, - onToggle: (path) => { toggle(tab.id, path, state.levels[path] !== undefined, signal) }, + onToggle: (path) => { toggle(tab.id, path, state.expanded, signal) }, // Every row is under the tree's root, so its address is session-relative. onOpen: (path) => { tabActions.openResource(fileAddressFor(sessionId, state.root, path)) }, t, @@ -221,8 +223,7 @@ export function FilesBody({ // Reload drops every level and asks again for the expanded ones; a collapsed // level is fetched again the next time it opens. const reload = (): void => { - actions.reset(tab.id) - for (const path of state.expanded) load(tab.id, path, signal) + refresh(tab.id) } const { directory, name } = pathPartsOf(state.root) return ( @@ -235,6 +236,14 @@ export function FilesBody({ {name} + @@ -215,13 +214,11 @@ export function FilesBody({ if (state === undefined) return null const tree: TreeContext = { state, - onToggle: (path) => { toggle(tab.id, path, state.expanded, signal) }, + 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. onOpen: (path) => { tabActions.openResource(fileAddressFor(sessionId, state.root, path)) }, t, } - // Reload drops every level and asks again for the expanded ones; a collapsed - // level is fetched again the next time it opens. const reload = (): void => { refresh(tab.id) } diff --git a/packages/client/ui-sidebar-files/src/client/directory-node.ts b/packages/client/ui-sidebar-files/src/client/directory-node.ts index 8bc3e2612a..dc81bd237d 100644 --- a/packages/client/ui-sidebar-files/src/client/directory-node.ts +++ b/packages/client/ui-sidebar-files/src/client/directory-node.ts @@ -89,7 +89,7 @@ export class DirectoryNode { /** Queue a reread, coalescing with active work. @returns completion of the active read and its coalesced rereads. */ refresh(): Promise { this.dirty = true - this.reading ??= this.read().finally(() => { this.reading = undefined }) + this.reading ??= this.read() return this.reading } @@ -124,20 +124,24 @@ export class DirectoryNode { private async read(): Promise { const readAgain = (): boolean => this.dirty && this.automatic && !this.signal.aborted - do { - this.dirty = false - const level = await this.load(this.path, this.signal) - if (this.signal.aborted || level === undefined) return - this.initialized = true - const directories = new Set(level.entries.filter(entry => entry.type === 'directory') - .map(entry => `${this.path.replace(/[/\\]+$/, '')}/${entry.name}`)) - for (const path of this.children.keys()) { - if (!directories.has(path)) await this.collapse(path) - } - for (const path of directories) { - if (this.restore.includes(path)) this.expand(path, this.restore) - } - this.restore = [] - } while (readAgain()) + try { + do { + this.dirty = false + const level = await this.load(this.path, this.signal) + if (this.signal.aborted || level === undefined) return + this.initialized = true + const directories = new Set(level.entries.filter(entry => entry.type === 'directory') + .map(entry => `${this.path.replace(/[/\\]+$/, '')}/${entry.name}`)) + for (const path of this.children.keys()) { + if (!directories.has(path)) await this.collapse(path) + } + for (const path of directories) { + if (this.restore.includes(path)) this.expand(path, this.restore) + } + this.restore = [] + } while (readAgain()) + } finally { + this.reading = undefined + } } } diff --git a/packages/client/ui-sidebar-files/src/client/face.ts b/packages/client/ui-sidebar-files/src/client/face.ts index baab5fa93b..d086821d5f 100644 --- a/packages/client/ui-sidebar-files/src/client/face.ts +++ b/packages/client/ui-sidebar-files/src/client/face.ts @@ -1,7 +1,7 @@ /** * The tree's asynchronous half: listing directories into the store. * - * The component never awaits anything. It calls `start` / `load` / `toggle`, and + * 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. @@ -47,7 +47,7 @@ export function createWatch(remote: ClientRemote): WatchWorkspaceDirectory { if (aborted()) return const stream = remote.$stream({ name: `directory ${path}`, - open: lifetime => remote.workspaceFiles.changes(sessionId, { kind: 'directory', path }, lifetime), + open: lifetime => remote.workspaceFiles.changes(sessionId, path, lifetime), ended: () => new Error(`Directory watch ended: ${path}`), }) const abort = (): void => { void stream.dispose() } @@ -114,7 +114,9 @@ export function childPath(parent: string, name: string): string { /** The tree's injected business face, as the body 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. @@ -133,11 +135,12 @@ export interface FilesInjected { /** * Open or collapse one directory, listing it the first time it opens. * @param tabId - the tab 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, path: string, expanded: readonly string[], signal: AbortSignal) => void + readonly toggle: (tabId: TabId, parentPath: string, path: string, expanded: readonly string[], signal: AbortSignal) => void } /** @@ -195,14 +198,14 @@ export function filesFace( (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', String(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, path, expanded, signal) { + toggle(tabId, parentPath, path, expanded, signal) { if (signal.aborted) return - const parent = roots.get(tabId)?.find(path.slice(0, path.lastIndexOf('/'))) + const parent = roots.get(tabId)?.find(parentPath) if (parent === undefined) return if (expanded.includes(path)) void parent.collapse(path) else parent.expand(path, expanded) diff --git a/packages/client/ui-sidebar-files/tests/directory-node.client.spec.ts b/packages/client/ui-sidebar-files/tests/directory-node.client.spec.ts index 4ca7de0ce2..98026478c3 100644 --- a/packages/client/ui-sidebar-files/tests/directory-node.client.spec.ts +++ b/packages/client/ui-sidebar-files/tests/directory-node.client.spec.ts @@ -59,6 +59,18 @@ async function listReady(node: DirectoryNode, script: ScriptedList, level: DirLe } describe('DirectoryNode', () => { + it('rereads an invalidation arriving between read completion and refresh cleanup', async () => { + const pending = Promise.withResolvers() + const load = vi.fn().mockReturnValueOnce(pending.promise).mockResolvedValue(EMPTY) + const node = new DirectoryNode(ROOT, load, async function* () {}, vi.fn(), new AbortController().signal) + onTestFinished(() => node.close()) + const reading = node.refresh() + pending.resolve(EMPTY) + queueMicrotask(() => { void node.refresh() }) + await reading + expect(load).toHaveBeenCalledTimes(2) + }) + it('opens one watch per expanded directory and waits for each ready before its first list', async () => { const { node, script } = mount() expect(node.open()).toBe(node) 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 55a5fb7fcf..4477f3469f 100644 --- a/packages/client/ui-sidebar-files/tests/face.client.spec.ts +++ b/packages/client/ui-sidebar-files/tests/face.client.spec.ts @@ -30,6 +30,19 @@ function mount() { } 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) + await h.watches.ready(root) + await h.settle({ ok: true, value: LEVEL }) + h.face.toggle(TAB, root, child, [root], h.controller.signal) + const stream = await h.watches.ready(child) + expect(h.snapshot()?.expanded).toEqual([root, child]) + expect(h.list).toHaveBeenLastCalledWith(SESSION, child, stream.signal) + await h.settle({ ok: true, value: { entries: [], truncated: false } }) + }) + 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) @@ -61,19 +74,19 @@ describe('filesFace', () => { face.start(TAB, ROOT, signal) const rootStream = await watches.ready(ROOT) await settle({ ok: true, value: LEVEL }) - face.toggle(TAB, child, [ROOT], signal) + face.toggle(TAB, ROOT, child, [ROOT], signal) 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, child, [ROOT, child], signal) + face.toggle(TAB, ROOT, child, [ROOT, child], signal) 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, child, [ROOT], signal) + face.toggle(TAB, ROOT, child, [ROOT], signal) expect(snapshot()!.levels[child]).toEqual({ kind: 'ready', level: LEVEL }) expect(list).toHaveBeenCalledTimes(2) const reopened = await watches.ready(child, 1) @@ -121,18 +134,18 @@ describe('filesFace', () => { const stream = await h.watches.ready(ROOT) await h.settle({ ok: true, value: LEVEL }) const before = h.snapshot() - h.face.toggle(TAB, `${ROOT}/src/nested`, [ROOT], h.controller.signal) + h.face.toggle(TAB, `${ROOT}/src`, `${ROOT}/src/nested`, [ROOT], h.controller.signal) expect(h.snapshot()).toBe(before) expect(h.watches.opened.map(watch => watch.path)).toEqual([ROOT]) h.controller.abort() await stream.released.promise - h.face.toggle(TAB, `${ROOT}/src`, [ROOT], h.controller.signal) + h.face.toggle(TAB, ROOT, `${ROOT}/src`, [ROOT], h.controller.signal) expect(h.snapshot()).toBeUndefined() expect(h.list).toHaveBeenCalledTimes(1) expect(h.watches.opened).toHaveLength(1) }) - it('records a watch failure beside the fallback directory listing', async () => { + it.each([new Error('watch disconnected'), 'watch disconnected'])('records a watch failure without an Error prefix: %s', async (failure) => { const h = mount() const reported = Promise.withResolvers() const unsubscribe = h.instance.subscribe(() => { @@ -142,13 +155,13 @@ describe('filesFace', () => { onTestFinished(unsubscribe) h.face.start(TAB, ROOT, h.controller.signal) const stream = await h.watches.forPath(ROOT) - stream.fail(new Error('watch disconnected')) + stream.fail(failure) expect((await h.waitForList(0)).path).toBe(ROOT) await h.settle({ ok: true, value: LEVEL }) await reported.promise expect(h.snapshot()!.levels[ROOT]).toMatchObject({ kind: 'ready', level: LEVEL, - failure: { code: 'gateway/internal', message: 'Error: watch disconnected' }, + failure: { code: 'gateway/internal', message: 'watch disconnected' }, }) await stream.released.promise }) @@ -197,7 +210,7 @@ describe('filesFace', () => { face.start(TAB, ROOT, controller.signal) await watches.ready(ROOT) await settle({ ok: true, value: LEVEL }) - face.toggle(TAB, child, snapshot()!.expanded, controller.signal) + face.toggle(TAB, ROOT, child, snapshot()!.expanded, controller.signal) await watches.ready(child) await settle({ ok: true, value: LEVEL }) actions.loaded(TAB, collapsed, LEVEL) diff --git a/packages/client/ui-sidebar-files/tests/scripted-watch.client.ts b/packages/client/ui-sidebar-files/tests/scripted-watch.client.ts index 55c99be9ed..136d70c06c 100644 --- a/packages/client/ui-sidebar-files/tests/scripted-watch.client.ts +++ b/packages/client/ui-sidebar-files/tests/scripted-watch.client.ts @@ -4,7 +4,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { WatchWorkspaceDirectory } from '../src/client/face.ts' type Event = 'ready' | 'change' -type Frame = { kind: 'event'; value: Event; delivered: () => void } | { kind: 'error'; error: Error } +type Frame = { kind: 'event'; value: Event; delivered: () => void } | { kind: 'error'; error: unknown } /** One directory subscription; delivering an event waits for the consumer's next pull. */ export class DirectoryWatch implements AsyncIterable { @@ -27,7 +27,7 @@ export class DirectoryWatch implements AsyncIterable { }) } - fail(error: Error): void { + fail(error: unknown): void { this.queue.push({ kind: 'error', error }) this.wake?.() } diff --git a/packages/client/ui-sidebar-files/tests/watch.client.spec.ts b/packages/client/ui-sidebar-files/tests/watch.client.spec.ts index e90cca1034..00218e65de 100644 --- a/packages/client/ui-sidebar-files/tests/watch.client.spec.ts +++ b/packages/client/ui-sidebar-files/tests/watch.client.spec.ts @@ -30,7 +30,7 @@ describe('createWatch', () => { const h = mount() const first = h.iterator.next() const watch = await h.remote.waitForChanges(0) - expect(watch).toMatchObject({ sessionId: SESSION, kind: 'directory', path: ROOT }) + expect(watch).toMatchObject({ sessionId: SESSION, path: ROOT }) expect(h.supervise.mock.calls[0]?.[0].name).toBe(`directory ${ROOT}`) watch.source.push({ kind: 'ready' }) await expect(first).resolves.toEqual({ done: false, value: 'ready' }) 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 c8acefdf82..d2c50baf26 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -2699,7 +2699,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Content and viewing inputs shared by document bodies and nested PDF presentation. */\nexport interface DocumentBodyOwner {\n readonly addResource: (address: string) => void\n readonly setResources: (addresses: readonly string[]) => void\n /** Original file address, also readable through the standard useResource hook. */\n readonly resourceAddress: string\n /** Ordinary file content or a renderer-owned loading request; text accumulates until eof. */\n readonly content: DocumentContent\n /** The document toolbar\'s current wrapping preference. */\n readonly wrap: boolean\n /** Report a renderer-owned scrollport; passing `null` restores the shared body as the owner. */\n readonly scrollportRef: RefCallback\n}', + '/** Content and viewing inputs shared by document bodies and nested PDF presentation. */\nexport interface DocumentBodyOwner {\n /** Observe a file read by this renderer. @param address - complete file resource address. */\n readonly addResource: (address: string) => void\n /** Replace this renderer\'s dependencies. @param addresses - complete file resource addresses. */\n readonly setResources: (addresses: readonly string[]) => void\n /** Original file address, also readable through the standard useResource hook. */\n readonly resourceAddress: string\n /** Ordinary file content or a renderer-owned loading request; text accumulates until eof. */\n readonly content: DocumentContent\n /** The document toolbar\'s current wrapping preference. */\n readonly wrap: boolean\n /** Report a renderer-owned scrollport; passing `null` restores the shared body as the owner. */\n readonly scrollportRef: RefCallback\n}', ], ownerPropsReferences: [ 'DocumentContent', @@ -2736,7 +2736,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.document\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.document\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:47', + source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:51', }, { key: 'sidebar.right.tab.document.action', @@ -2753,7 +2753,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/**\n * Ordinary file contents, or a request for the selected renderer to load its content.\n * Byte arrays are transient UI input, never persisted layout or Session data.\n */\nexport type DocumentContent =\n | { readonly kind: \'text\'; readonly text: string; readonly pages: readonly DocumentTextPage[]; readonly eof: boolean }\n | { readonly kind: \'bytes\'; readonly data: Uint8Array }\n | {\n readonly kind: \'renderer\'\n /** Changes on reload or implementation replacement; retained contents belong to one revision. */\n readonly revision: number\n /** Report the displayed source version; stale revisions cannot update the owner. @param version - loaded source version. */\n readonly loaded: (version: string) => void\n /** Cancel the current load and start a new revision. */\n readonly reload: () => void\n }', + '/**\n * Ordinary file contents, or a request for the selected renderer to load its content.\n * Byte arrays are transient UI input, never persisted layout or Session data.\n */\nexport type DocumentContent =\n | { readonly kind: \'text\'; readonly text: string; readonly pages: readonly DocumentTextPage[]; readonly eof: boolean }\n | { readonly kind: \'bytes\'; readonly data: Uint8Array }\n | {\n readonly kind: \'renderer\'\n /** Changes on reload or implementation replacement; retained contents belong to one revision. */\n readonly revision: number\n /** Report the displayed source version; stale revisions cannot update the owner. @param version - loaded source version. */\n readonly loaded: (version: string) => void\n /** End a failed load; a later file change can start another revision. */\n readonly failed: () => void\n /** Cancel the current load and start a new revision. */\n readonly reload: () => void\n }', ], ownerPropsReferences: [ 'DocumentTextPage', @@ -2784,7 +2784,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.document.action\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.document.action\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:83', + source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:87', }, { key: 'sidebar.right.tab.document.actions', @@ -2840,7 +2840,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.document.actions\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.document.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:62', + source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:66', }, { key: 'sidebar.right.tab.document.office.pdf', @@ -2857,7 +2857,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Content and viewing inputs shared by document bodies and nested PDF presentation. */\nexport interface DocumentBodyOwner {\n readonly addResource: (address: string) => void\n readonly setResources: (addresses: readonly string[]) => void\n /** Original file address, also readable through the standard useResource hook. */\n readonly resourceAddress: string\n /** Ordinary file content or a renderer-owned loading request; text accumulates until eof. */\n readonly content: DocumentContent\n /** The document toolbar\'s current wrapping preference. */\n readonly wrap: boolean\n /** Report a renderer-owned scrollport; passing `null` restores the shared body as the owner. */\n readonly scrollportRef: RefCallback\n}', + '/** Content and viewing inputs shared by document bodies and nested PDF presentation. */\nexport interface DocumentBodyOwner {\n /** Observe a file read by this renderer. @param address - complete file resource address. */\n readonly addResource: (address: string) => void\n /** Replace this renderer\'s dependencies. @param addresses - complete file resource addresses. */\n readonly setResources: (addresses: readonly string[]) => void\n /** Original file address, also readable through the standard useResource hook. */\n readonly resourceAddress: string\n /** Ordinary file content or a renderer-owned loading request; text accumulates until eof. */\n readonly content: DocumentContent\n /** The document toolbar\'s current wrapping preference. */\n readonly wrap: boolean\n /** Report a renderer-owned scrollport; passing `null` restores the shared body as the owner. */\n readonly scrollportRef: RefCallback\n}', ], ownerPropsReferences: [ 'DocumentContent', @@ -2944,7 +2944,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.document.unpreviewable\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.document.unpreviewable\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:74', + source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:78', }, { 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 481c00ecde..5108162d90 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -978,7 +978,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { signature: 'abstract watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise>', description: 'Observe one file or a directory\'s direct entries in this provider\'s execution world.', - parameters: [{ name: 'target', description: 'resolved file or directory, including an absent path to observe for creation.' }, { name: 'changed', description: 'invalidation callback; an error reports that observation failed.' }, { name: 'signal', description: 'cancels watcher initialization; the caller closes an initialized watcher.' }], + parameters: [{ name: 'target', description: 'resolved file or directory, including an absent path to observe for creation.' }, { name: 'changed', description: 'invalidation callback; errors can be reported during or after initialization.' }, { name: 'signal', description: 'cancels watcher initialization; the caller closes an initialized watcher.' }], returns: 'a promise resolving once observation is active, with an asynchronous close function.', throws: ['when the provider does not support watching or cannot initialize the watcher.'], }, @@ -3337,9 +3337,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the directory\'s children in the backend\'s stable name order, bounded by the entry cap.', }, { - signature: '@Remote({ mode: \'stream\' }) changes( workspaceFileScope: WorkspaceFileScope, request: WorkspaceWatchRequest, signal: AbortSignal, ): AsyncIterable', + signature: '@Remote({ mode: \'stream\' }) changes(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): AsyncIterable', description: 'Watch one file or a directory\'s direct entries in the Session\'s filesystem. Files use the backend\'s read authority; directories remain workspace-scoped.', - parameters: [{ name: 'workspaceFileScope', description: 'header-derived workspace root for the Session identity on the wire.' }, { name: 'request', description: 'target path and file or directory watch intent.' }, { name: 'signal', description: 'generation cancellation.' }], + parameters: [{ name: 'workspaceFileScope', description: 'header-derived workspace root for the Session identity on the wire.' }, { name: 'path', description: 'target path; the Host determines its type and confines directories to the workspace.' }, { name: 'signal', description: 'generation cancellation.' }], returns: '`ready` once the target watch is active, then current metadata for queued and live invalidations.', throws: ['RemoteError when watching is unavailable or a directory is outside the workspace.'], }, @@ -7252,10 +7252,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'WorkspaceView', declaration: 'export interface WorkspaceView {\n readonly workspaceId: WorkspaceId;\n readonly path: string;\n readonly title: string;\n readonly sessionIds: readonly SessionId[];\n readonly createdAt: string;\n readonly updatedAt: string;\n}', }, - { - name: 'WorkspaceWatchRequest', - declaration: 'export interface WorkspaceWatchRequest {\n readonly path: string;\n readonly kind: \'file\' | \'directory\';\n}', - }, ] /** The inherited `ctx` API (cordis core + loader/hmr/timer), in curated order. */ diff --git a/packages/fs/fs-local/README.i18n.yaml b/packages/fs/fs-local/README.i18n.yaml index 8317bf4ecd..02ce9fc46c 100644 --- a/packages/fs/fs-local/README.i18n.yaml +++ b/packages/fs/fs-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/fs/fs-local/README.md -README.md: e781abd2032868a3768a41eb01ce5aac52f8c835 -README.zh.md: bf21f92a48c7c9d804908ee8bc25b2be8daa8967 +README.md: 6584fade3517637726f9bae4e7ca692ffffcca01 +README.zh.md: cc1ab560a071a92b624a1580fa82902a2da08d99 diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index e781abd203..6584fade35 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use `dsh-fs-local` to read, list, atomically write, and edit files on the host filesystem. Relative paths resolve from a configurable base directory, while absolute paths and parent traversal remain unrestricted. Paths and symlinks that reach the same file share one identity. Writes preserve file permissions, and optional version guards reject stale overwrites. Choose this package for direct host access; use `fs-sandbox` for confined mutations. +Use `dsh-fs-local` to read, list, watch, atomically write, and edit files on the host filesystem. Relative paths resolve from a configurable base directory, while absolute paths and parent traversal remain unrestricted. Paths and symlinks that reach the same file share one identity. Writes preserve file permissions, and optional version guards reject stale overwrites. Choose this package for direct host access; use `fs-sandbox` for confined mutations. ## Table of Contents @@ -52,7 +52,9 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a Read any regular UTF-8 text file whole or as a stream, read raw bytes up to a cap you choose or in a byte window, and list one directory level in stable name order. Create or replace a file atomically, and apply a literal text edit atomically; both mutations serialize per file, so concurrent writers never interleave. The version guard is optional: omit it for unconditional create-or-overwrite, or supply it to fail when the file changed since you last observed it. -Failures are typed `FsError`s with stable codes — `FS_NOT_FOUND`, `FS_NOT_TEXT` (binary content), `FS_STALE_VERSION` (changed since observation), `FS_EDIT_NOT_FOUND` or `FS_AMBIGUOUS_EDIT` (no unique literal match), and others — so callers branch on the code, never on message text. A missing target on an edit reports `FS_STALE_VERSION` whether or not the version guard is supplied. +Read, listing, and mutation failures are typed `FsError`s with stable codes — `FS_NOT_FOUND`, `FS_NOT_TEXT` (binary content), `FS_STALE_VERSION` (changed since observation), `FS_EDIT_NOT_FOUND` or `FS_AMBIGUOUS_EDIT` (no unique literal match), and others — so callers branch on the code, never on message text. A missing target on an edit reports `FS_STALE_VERSION` whether or not the version guard is supplied. + +Chokidar observes one file or a directory's direct entries through OS events, without polling or recursive watching. File watches cover in-place writes, atomic replacement, deletion, and same-path recreation while the parent directory remains. ----- @@ -90,7 +92,7 @@ Each edit probes, verifies the version guard before literal matching (so stale e ### Ownership and invariants -Raw I/O is Cordis-free and independently unit-tested in `src/fsio.ts`; `src/index.ts` stays thin wiring. `config.cwd` is a resolution default only — containment is the job of `fs-sandbox` or a `tools/execute` permission plugin. Cancellation is a best-effort `AbortSignal` checked before and after each asynchronous probe. +Raw I/O is Cordis-free and independently unit-tested in `src/fsio.ts`; `src/index.ts` stays thin wiring. `config.cwd` is a resolution default only — containment is the job of `fs-sandbox` or a `tools/execute` permission plugin. Cancellation is a best-effort `AbortSignal` checked before and after each asynchronous probe. For watches, the signal cancels initialization; after readiness, the caller must await the returned close function. @@ -127,6 +129,8 @@ No direct invalidation; the named consumer owns any request-prefix changes. These limits define when the local backend is a poor fit or needs special operational care. They are current package constraints, not a general filesystem comparison or a task backlog. - **`config.cwd` is not a sandbox** — it is a resolution default, not containment: absolute paths and `..` escape it. Enforce containment with a stricter `ctx.fs` backend or a permission plugin on the `tools/execute` waterfall. +- **Initially missing targets** — Chokidar can report readiness before the parent-directory watch is attached; creation immediately after readiness can be missed. +- **Linux parent-directory recreation** — restoring observation after a parent directory is deleted and recreated is deferred; same-path file recreation while its parent remains is supported. - **Version tokens depend on filesystem metadata** — they combine device, inode, size, nanosecond mtime, and nanosecond ctime; a storage layer that cannot update any of those facts for a rewrite can still defeat the stale guard. - **`editText` holds the whole file (plus the edited copy) in memory** — streaming exists only on the read path. - **A sub-limit overwrite still buffers a contextual basis** — `writeText` may retain up to just below `config.diffBasisMaxBytes` of prior text in addition to the caller-owned replacement; the bound does not cap the returned `after` value or the whole-file presentation fallback. diff --git a/packages/fs/fs-local/README.zh.md b/packages/fs/fs-local/README.zh.md index bf21f92a48..cc1ab560a0 100644 --- a/packages/fs/fs-local/README.zh.md +++ b/packages/fs/fs-local/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -使用 `dsh-fs-local` 可在宿主文件系统上读取、列出、原子写入和编辑文件。相对路径从可配置的基准目录解析,而绝对路径和父目录遍历不受限制。到达同一文件的路径和符号链接共享一个身份。写入保留文件权限,可选版本防护会拒绝陈旧覆盖。直接访问宿主文件时选择本包;需要约束变更时使用 `fs-sandbox`。 +使用 `dsh-fs-local` 可在宿主文件系统上读取、列出、监听、原子写入和编辑文件。相对路径从可配置的基准目录解析,而绝对路径和父目录遍历不受限制。到达同一文件的路径和符号链接共享一个身份。写入保留文件权限,可选版本防护会拒绝陈旧覆盖。直接访问宿主文件时选择本包;需要约束变更时使用 `fs-sandbox`。 ## 目录 @@ -52,7 +52,9 @@ kind: "package-reference" 完整或流式读取任意普通 UTF-8 文本文件,按你选择的上限或按字节窗口读取原始字节,并按稳定名称顺序列出一层目录。原子地创建或替换文件,并原子地应用字面量文本编辑;两个变更操作都按文件串行化,并发写入方绝不会交错。版本防护是可选的:省略它即无条件创建或覆盖,提供它则在文件自上次观察以来发生变化时失败。 -失败是携带稳定错误码的类型化 `FsError`——`FS_NOT_FOUND`、`FS_NOT_TEXT`(二进制内容)、`FS_STALE_VERSION`(自观察以来已变化)、`FS_EDIT_NOT_FOUND` 或 `FS_AMBIGUOUS_EDIT`(无唯一字面量匹配)等——因此调用方依据错误码分支,绝不解析消息文本。编辑遇到缺失目标时,无论是否提供版本防护,都报告 `FS_STALE_VERSION`。 +读取、列出与变更操作的失败是携带稳定错误码的类型化 `FsError`——`FS_NOT_FOUND`、`FS_NOT_TEXT`(二进制内容)、`FS_STALE_VERSION`(自观察以来已变化)、`FS_EDIT_NOT_FOUND` 或 `FS_AMBIGUOUS_EDIT`(无唯一字面量匹配)等——因此调用方依据错误码分支,绝不解析消息文本。编辑遇到缺失目标时,无论是否提供版本防护,都报告 `FS_STALE_VERSION`。 + +Chokidar 通过 OS 事件观察单个文件或目录的直接子项,不使用轮询或递归监听。父目录保持存在时,文件监听覆盖原地写入、原子替换、删除和同路径重建。 ----- @@ -90,7 +92,7 @@ kind: "package-reference" ### 归属与不变式 -原始 I/O 不依赖 Cordis,在 `src/fsio.ts` 中独立单元测试;`src/index.ts` 保持为轻量接线。`config.cwd` 只是解析默认值——约束是 `fs-sandbox` 或 `tools/execute` 权限插件的工作。取消是尽力而为的 `AbortSignal`,在每次异步探测前后检查。 +原始 I/O 不依赖 Cordis,在 `src/fsio.ts` 中独立单元测试;`src/index.ts` 保持为轻量接线。`config.cwd` 只是解析默认值——约束是 `fs-sandbox` 或 `tools/execute` 权限插件的工作。取消是尽力而为的 `AbortSignal`,在每次异步探测前后检查。对于监听,signal 取消初始化;就绪后,调用方必须等待返回的关闭函数完成。 @@ -127,6 +129,8 @@ kind: "package-reference" 这些限制说明本地后端何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用文件系统对比或任务积压。 - **`config.cwd` 不是沙箱**:它是解析默认值,而非约束;绝对路径和 `..` 可以逃逸。请使用更严格的 `ctx.fs` 后端或 `tools/execute` waterfall(瀑布式事件)上的权限插件实施约束。 +- **初始缺失目标**:Chokidar 可能在父目录监听建立前报告就绪;紧随就绪通知的创建可能漏报。 +- **Linux 父目录重建**:父目录删除并重建后的监听恢复仍延期;父目录保持存在时,同路径文件重建仍受支持。 - **版本 token 依赖文件系统元数据**:它们组合设备、inode、大小、纳秒级 mtime 与纳秒级 ctime;如果存储层在重写时无法更新其中任何一项事实,仍可能绕过陈旧防护。 - **`editText` 会把整个文件及编辑后的副本保存在内存中**:只有读取路径支持流式处理。 - **低于上限的覆写仍会缓冲上下文基础**:`writeText` 除调用方持有的替换内容外,最多还会保留略低于 `config.diffBasisMaxBytes` 的旧文本;该上限不限制返回的 `after` 值,也不限制整文件展示回退。 diff --git a/packages/fs/fs-sandbox/README.i18n.yaml b/packages/fs/fs-sandbox/README.i18n.yaml index 24bd8907dd..b23f4379e1 100644 --- a/packages/fs/fs-sandbox/README.i18n.yaml +++ b/packages/fs/fs-sandbox/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/fs/fs-sandbox/README.md -README.md: e126e71573f4ce1480543d0a94cfe8512f03472b -README.zh.md: b224296711ae7c43849fa2a426a2a6e79c99f65e +README.md: fde670f9a51fafb29b8d7057517ff26f551e9ff4 +README.zh.md: ff58cd0a4eb2094e138fa24da03a371ff2054c99 diff --git a/packages/fs/fs-sandbox/README.md b/packages/fs/fs-sandbox/README.md index e126e71573..fde670f9a5 100644 --- a/packages/fs/fs-sandbox/README.md +++ b/packages/fs/fs-sandbox/README.md @@ -47,7 +47,7 @@ The effective mode comes from the calling session's override or escalation grant ### Observable success and failures -Reads, listings, and metadata work exactly as with `fs-local`. A denied mutation returns an `FS_SANDBOX_DENIED` error carrying the effective mode; through the tools the model sees `[sandbox: file access denied under mode]` plus the one-approved-wider retry hint, identical to bash's denials. A session with an approved escalation may retry the same operation at a strictly wider mode for that one call. +Reads, listings, metadata, and read-only watches work exactly as with `fs-local`; the mutation fence does not restrict observation. A denied mutation returns an `FS_SANDBOX_DENIED` error carrying the effective mode; through the tools the model sees `[sandbox: file access denied under mode]` plus the one-approved-wider retry hint, identical to bash's denials. A session with an approved escalation may retry the same operation at a strictly wider mode for that one call. ----- diff --git a/packages/fs/fs-sandbox/README.zh.md b/packages/fs/fs-sandbox/README.zh.md index b224296711..ff58cd0a4e 100644 --- a/packages/fs/fs-sandbox/README.zh.md +++ b/packages/fs/fs-sandbox/README.zh.md @@ -47,7 +47,7 @@ kind: "package-reference" ### 可观察的成功与失败 -读取、列出与元数据操作与 `fs-local` 完全一致。被拒绝的变更返回携带有效模式的 `FS_SANDBOX_DENIED` 错误;经工具,模型会看到 `[sandbox: file access denied under mode]` 及唯一一次获批更宽权限的重试提示,与 bash 的拒绝完全相同。获得批准升级的会话可以在该次调用中以严格更宽的模式重试同一操作。 +读取、列出、元数据操作与只读监听均与 `fs-local` 完全一致;变更围栏不限制观察。被拒绝的变更返回携带有效模式的 `FS_SANDBOX_DENIED` 错误;经工具,模型会看到 `[sandbox: file access denied under mode]` 及唯一一次获批更宽权限的重试提示,与 bash 的拒绝完全相同。获得批准升级的会话可以在该次调用中以严格更宽的模式重试同一操作。 ----- diff --git a/packages/fs/fs/README.i18n.yaml b/packages/fs/fs/README.i18n.yaml index 8a8c0dbc63..f82aaf06fb 100644 --- a/packages/fs/fs/README.i18n.yaml +++ b/packages/fs/fs/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/fs/fs/README.md -README.md: e36e400d53cc581eb19733c9b3ccd890f34932a2 -README.zh.md: 3efc2106a9200ee84ab1a859a19fdb5b1631f056 +README.md: 549da2a42a70c0ac06bf50870076fceb400cbf78 +README.zh.md: c9b91913554c66f727289130d6d50c522ba60d91 diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index e36e400d53..549da2a42a 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use `dsh-fs` when an application needs consistent filesystem operations across host, confined, or remote execution environments. It lets consumers resolve stable file identities, map shared host files where supported, perform bounded text and byte reads, list directories, and apply atomic text writes and literal edits. Version guards are optional, so a backend works without policy enforcement; callers can supply a guard to reject a mutation after the file changes. Choose `fs-local` or `fs-sandbox` for host execution. Model-facing filesystem tools are provided separately by `dsh-tool-fs`. +Use `dsh-fs` when an application needs consistent filesystem operations across host, confined, or remote execution environments. It lets consumers resolve stable file identities, map shared host files where supported, perform bounded text and byte reads, list directories, watch targets, and apply atomic text writes and literal edits. Version guards are optional, so a backend works without policy enforcement; callers can supply a guard to reject a mutation after the file changes. Choose `fs-local` or `fs-sandbox` for host execution. Model-facing filesystem tools are provided separately by `dsh-tool-fs`. ## Table of Contents @@ -33,7 +33,9 @@ Pick [`fs-local`](../fs-local/README.md) for ordinary host files or [`fs-sandbox ### What the service lets you do -Through `ctx.fs` you can resolve any path to a stable target identity, read a whole text file or stream it in chunks, read raw bytes up to an explicit cap, list one directory level, atomically create or replace a file, and apply a literal text edit atomically. The version guard on both mutations is optional: omit it for unconditional create-or-overwrite, or supply it to fail when the file changed since you last observed it. Every operation returns data or a typed `FsError` carrying a stable code such as `FS_NOT_FOUND`, `FS_STALE_VERSION`, or `FS_AMBIGUOUS_EDIT`, so callers branch on the code, never on message text. +Through `ctx.fs` you can resolve any path to a stable target identity, read a whole text file or stream it in chunks, read raw bytes up to an explicit cap, list one directory level, atomically create or replace a file, and apply a literal text edit atomically. The version guard on both mutations is optional: omit it for unconditional create-or-overwrite, or supply it to fail when the file changed since you last observed it. Read, listing, and mutation failures are typed `FsError`s with stable codes such as `FS_NOT_FOUND`, `FS_STALE_VERSION`, or `FS_AMBIGUOUS_EDIT`, so callers branch on the code, never on message text. + +`watch(target, changed, signal)` reports invalidations for one file or a directory's direct entries. It resolves once observation is ready with an asynchronous close function that the caller must await. The signal cancels initialization; unsupported providers reject without polling. ----- @@ -49,7 +51,7 @@ This section explains the design decisions behind the contract and points at the The contract is built on one separation and three commitments: -- **Contract over mechanism.** The service names what a storage layer can do — resolve, stat, read, list, write, edit — and never how it stores bytes. Backends own target identity, execution-world coordinates, decoding, binary rejection, and atomicity. +- **Contract over mechanism.** The service names what a storage layer can do — resolve, stat, read, list, watch, write, edit — and never how it stores bytes. Backends own target identity, execution-world coordinates, decoding, binary rejection, and atomicity. - **Policy stays off the base class.** Observed-state, read-before-edit, and version-guarded mutations are a plugin's job (`dsh-fs-observation-policy`), added by supplying the optional guard — so a sandboxed or remote backend inherits no model-facing observation policy. - **`editText` stays on the seam.** Version check, literal match, and atomic rewrite share one critical section, so error attribution and one-wins/one-stale concurrency stay correct; a remote backend may implement it as a native compare-and-edit. - **Bounds live at this seam.** `readBytes` requires `maxBytes` and fails with `FS_TOO_LARGE` rather than truncating, so no backend ever buffers an unbounded file. `readByteRange` is bounded by its window instead: a backend transfers at most the requested `length` beyond the prefix it skips, so the caller's cap on `length` is the guard. @@ -72,7 +74,7 @@ The package declares three events so the emitter (`dsh-tool-fs`) and the policy ### Invariants - `targetKey` and `version` are branded opaque ids: consumers must not parse or interpret them; only `displayPath` is for model/UI output. -- Failures are typed `FsError`s with stable codes, never ad-hoc message strings. +- Read, listing, and mutation failures are typed `FsError`s with stable codes, never ad-hoc message strings; watch initialization may reject with an ordinary exception. - The seam arms no I/O deadline; cancellation is an optional per-primitive `AbortSignal`. @@ -110,7 +112,7 @@ No direct invalidation; the named consumer owns any request-prefix changes. These limits define when the contract is a poor fit or needs special operational care. They are current package constraints, not a general filesystem comparison or a task backlog. - **Text-only mutations by contract** — text reads and both mutations reject binary or non-UTF-8 content with `FS_NOT_TEXT`; `readBytes` and `readByteRange` are the raw-byte primitives, and binary-safe mutations remain deferred. -- **Thirteen primitives only** — no delete, rename, copy, or watch; `listDir` lists a single level, with recursion, globbing, pagination, and search out of scope ([directory-listing note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md)). +- **No delete, rename, or copy** — `listDir` lists a single level, with recursion, globbing, pagination, and search out of scope ([directory-listing note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md)). - **No I/O deadline** — the seam arms no timeout; cancellation is a best-effort optional `AbortSignal` per primitive ([fs family stance](../README.md)). - **Resolve-then-operate costs a remote backend two round-trips per tool call** — folding or caching resolution is left to such a backend. diff --git a/packages/fs/fs/README.zh.md b/packages/fs/fs/README.zh.md index 3efc2106a9..c9b9191355 100644 --- a/packages/fs/fs/README.zh.md +++ b/packages/fs/fs/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -应用需要在宿主、受限或远程执行环境中使用一致的文件系统操作时,选择 `dsh-fs`。消费方可以解析稳定的文件身份、在受支持时映射共享宿主文件、执行有界的文本与字节读取、列出目录,并原子地写入文本及执行字面量编辑。版本防护是可选的,因此后端无需策略强制也能工作;调用方可以提供防护,在文件变化后拒绝变更。在宿主执行时选择 `fs-local` 或 `fs-sandbox`。面向模型的文件系统工具由 `dsh-tool-fs` 单独提供。 +应用需要在宿主、受限或远程执行环境中使用一致的文件系统操作时,选择 `dsh-fs`。消费方可以解析稳定的文件身份、在受支持时映射共享宿主文件、执行有界的文本与字节读取、列出目录、监听目标,并原子地写入文本及执行字面量编辑。版本防护是可选的,因此后端无需策略强制也能工作;调用方可以提供防护,在文件变化后拒绝变更。在宿主执行时选择 `fs-local` 或 `fs-sandbox`。面向模型的文件系统工具由 `dsh-tool-fs` 单独提供。 ## 目录 @@ -33,7 +33,9 @@ kind: "package-reference" ### 服务能做什么 -通过 `ctx.fs`,你可以把任意路径解析为稳定的目标身份、完整读取或分片流式读取文本文件、按显式上限读取原始字节、列出一层目录、原子地创建或替换文件,并原子地应用字面量文本编辑。两个变更操作上的版本防护都是可选的:省略它即无条件创建或覆盖,提供它则在文件自上次观察以来发生变化时失败。每个操作要么返回数据,要么抛出携带稳定错误码(如 `FS_NOT_FOUND`、`FS_STALE_VERSION`、`FS_AMBIGUOUS_EDIT`)的类型化 `FsError`,调用方依据错误码分支,绝不解析消息文本。 +通过 `ctx.fs`,你可以把任意路径解析为稳定的目标身份、完整读取或分片流式读取文本文件、按显式上限读取原始字节、列出一层目录、原子地创建或替换文件,并原子地应用字面量文本编辑。两个变更操作上的版本防护都是可选的:省略它即无条件创建或覆盖,提供它则在文件自上次观察以来发生变化时失败。读取、列出与变更操作的失败使用携带稳定错误码(如 `FS_NOT_FOUND`、`FS_STALE_VERSION`、`FS_AMBIGUOUS_EDIT`)的类型化 `FsError`,调用方依据错误码分支,绝不解析消息文本。 + +`watch(target, changed, signal)` 报告单个文件或目录直接子项的失效通知。观察就绪后,它返回调用方必须等待完成的异步关闭函数。signal 取消初始化;不支持监听的提供方直接拒绝,不使用轮询。 ----- @@ -49,7 +51,7 @@ kind: "package-reference" 该约定建立在一个分离与三项承诺之上: -- **约定高于机制。** 服务只命名存储层能做什么——解析、stat、读取、列出、写入、编辑——绝不规定如何存储字节。后端拥有目标身份、执行世界坐标、解码、二进制拒绝与原子性。 +- **约定高于机制。** 服务只命名存储层能做什么——解析、stat、读取、列出、监听、写入、编辑——绝不规定如何存储字节。后端拥有目标身份、执行世界坐标、解码、二进制拒绝与原子性。 - **策略不放在基类上。** 已观察状态、编辑前读取与版本防护的变更是插件(`dsh-fs-observation-policy`)的职责,通过提供可选防护来添加——因此沙箱化或远程后端不会继承任何面向模型的观察策略。 - **`editText` 留在 seam 上。** 版本校验、字面量匹配与原子重写共享同一个临界区,错误归因与一方胜出/一方陈旧的并发语义因此保持正确;远程后端也可以将其实现为原生比较并编辑操作。 - **界限制在此 seam 上。** `readBytes` 要求 `maxBytes`,并以 `FS_TOO_LARGE` 失败而不是截断,因此任何后端都不会无界缓冲文件。`readByteRange` 则以窗口为界:后端最多传输所请求的 `length` 字节(外加为到达 `offset` 而跳过的前缀),因此由调用方对 `length` 的上限承担防护。 @@ -72,7 +74,7 @@ kind: "package-reference" ### 不变式 - `targetKey` 与 `version` 是带品牌的不透明 id:消费方不得解析或解释它们;只有 `displayPath` 用于模型/UI 输出。 -- 失败是携带稳定错误码的类型化 `FsError`,绝不是临时拼写的消息字符串。 +- 读取、列出与变更操作的失败是携带稳定错误码的类型化 `FsError`,绝不是临时拼写的消息字符串;监听初始化可以用普通异常拒绝。 - 该 seam 不设 I/O deadline;取消是每个原语上可选的 `AbortSignal`。 @@ -110,7 +112,7 @@ kind: "package-reference" 这些限制说明该约定何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用文件系统对比或任务积压。 - **变更操作约定只支持文本**:文本读取和两个变更操作都以 `FS_NOT_TEXT` 拒绝二进制/非 UTF-8 内容;`readBytes` 与 `readByteRange` 是原始字节原语,二进制安全的变更操作仍延期。 -- **只有十三个原语**:没有删除、重命名、复制或监视;`listDir` 只列出一层,递归、glob、分页与搜索不在范围内(见[目录列出笔记](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md))。 +- **没有删除、重命名或复制**:`listDir` 只列出一层,递归、glob、分页与搜索不在范围内(见[目录列出笔记](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md))。 - **没有 I/O deadline**:该 seam 不启动超时;取消只是每个原语上尽力而为的可选 `AbortSignal`(见[fs 能力族立场](../README.zh.md))。 - **先解析后操作使远程后端每次工具调用需要两次往返**:折叠或缓存解析由这种后端自行决定。 diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index 4c1bfbce56..6f4ebe4e50 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -91,7 +91,7 @@ export abstract class FileSystem extends Service { /** * Observe one file or a directory's direct entries in this provider's execution world. * @param target - resolved file or directory, including an absent path to observe for creation. - * @param changed - invalidation callback; an error reports that observation failed. + * @param changed - invalidation callback; errors can be reported during or after initialization. * @param signal - cancels watcher initialization; the caller closes an initialized watcher. * @returns a promise resolving once observation is active, with an asynchronous close function. * @throws when the provider does not support watching or cannot initialize the watcher. diff --git a/packages/ssh/fs-ssh/README.i18n.yaml b/packages/ssh/fs-ssh/README.i18n.yaml index 08caee9ac6..9c53eeb146 100644 --- a/packages/ssh/fs-ssh/README.i18n.yaml +++ b/packages/ssh/fs-ssh/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ssh/fs-ssh/README.md -README.md: 1f403796792611abc438f140356e442fbb357cde -README.zh.md: 7e77afe6c87ed7725dea87215c9a3647d650ae95 +README.md: a950726df972952a8430ef0d00ba455f2e0de11e +README.zh.md: fa69aaee52c705b094de1b0f4860109023c2069f diff --git a/packages/ssh/fs-ssh/README.md b/packages/ssh/fs-ssh/README.md index 1f40379679..a950726df9 100644 --- a/packages/ssh/fs-ssh/README.md +++ b/packages/ssh/fs-ssh/README.md @@ -67,6 +67,7 @@ This provider contributes no request-prefix content. Its consumers own model-vis - Whole-text reads and individual byte windows are limited to 8 MiB by the helper. Use text streaming or multiple byte windows for larger reads; other JSON transfers also obey the connection’s frame cap. +- Filesystem watching is unsupported: `watch()` throws without polling or opening a local watcher for a remote path. Ordinary reads and consumer-owned manual refresh remain available. - Remote file URLs are execution coordinates, not host filesystem handles or Web download links. diff --git a/packages/ssh/fs-ssh/README.zh.md b/packages/ssh/fs-ssh/README.zh.md index 7e77afe6c8..fa69aaee52 100644 --- a/packages/ssh/fs-ssh/README.zh.md +++ b/packages/ssh/fs-ssh/README.zh.md @@ -67,6 +67,7 @@ kind: "package-reference" - 辅助程序将整段文本读取及单次字节窗口限制为 8 MiB。更大的读取需使用文本流或多个字节窗口;其他 JSON 传输也受连接消息大小上限约束。 +- 不支持文件系统监听:`watch()` 直接抛错,不使用轮询,也不为远程路径建立本地监听。普通读取与消费方提供的手动刷新仍可用。 - 远端文件 URL 是执行坐标,不是主机文件系统句柄或 Web 下载链接。 diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index f4f16f38d9..c656007e4f 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -832,7 +832,6 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { WorkspaceFileBytes: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', WorkspaceFileChange: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', WorkspaceFileWatchFrame: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', - WorkspaceWatchRequest: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', WorkspaceFileRange: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', WorkspaceFileStat: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', WorkspaceFileText: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md', From 5b13cba35c573c808394f8b844b50cbbbf900735 Mon Sep 17 00:00:00 2001 From: imccyu Date: Fri, 18 Sep 2026 23:27:28 +0800 Subject: [PATCH 06/10] test(workspace-files): cover initially missing watch targets --- .../api/workspace-files/tests/changes.spec.ts | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/api/workspace-files/tests/changes.spec.ts b/packages/api/workspace-files/tests/changes.spec.ts index 5d28b78774..c0e6a012ae 100644 --- a/packages/api/workspace-files/tests/changes.spec.ts +++ b/packages/api/workspace-files/tests/changes.spec.ts @@ -1,7 +1,7 @@ /** Target-scoped Host changes, fresh filesystem metadata, and watcher ownership. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Mock, MockInstance } from 'vitest' -import { mkdir, rm, writeFile } from 'node:fs/promises' +import { mkdir, rename, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import type { FileSystem, FsObservation, FsTarget } from '@deepseek-ai/dsh-fs' @@ -421,6 +421,26 @@ describe('workspaceFiles.changes — backends and access', () => { await expect(stream.next()).resolves.toEqual({ done: true, value: undefined }) }) + it('observes creation of a missing outside file through the real local watcher', async () => { + watch.mockRestore() + const path = join(harness.outside, 'created.txt') + const staging = join(harness.outside, 'staged.txt') + // Publish complete bytes so an early watcher stat cannot observe a partial write. + await writeFile(staging, 'new file contents') + const service = harness.endpoint() + const stream = open(service, path) + await ready(stream) + const pending = stream.next() + await rename(staging, path) + const current = await service.stat(harness.scope, path, stream.controller.signal) + await expect(pending).resolves.toEqual({ + done: false, + value: { kind: 'change', change: { absolutePath: current.absolutePath, version: current.version } }, + }) + await stream.close() + await expect(stream.next()).resolves.toEqual({ done: true, value: undefined }) + }) + it.each(['create', 'modify', 'remove'] as const)( 'reports an external direct-child %s through the real local watcher', async (operation) => { From a77250603caed2ee4ce451eed39c1ec7e7f10cb9 Mon Sep 17 00:00:00 2001 From: imccyu Date: Fri, 18 Sep 2026 23:45:02 +0800 Subject: [PATCH 07/10] fix(fs): watch parent before acknowledging missing files --- packages/fs/fs-local/README.i18n.yaml | 4 +- packages/fs/fs-local/README.md | 3 +- packages/fs/fs-local/README.zh.md | 3 +- packages/fs/fs-local/src/index.ts | 15 ++++-- packages/fs/fs-local/tests/watch.spec.ts | 65 ++++++++++++++++++++---- 5 files changed, 72 insertions(+), 18 deletions(-) diff --git a/packages/fs/fs-local/README.i18n.yaml b/packages/fs/fs-local/README.i18n.yaml index 02ce9fc46c..1a4ca47419 100644 --- a/packages/fs/fs-local/README.i18n.yaml +++ b/packages/fs/fs-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/fs/fs-local/README.md -README.md: 6584fade3517637726f9bae4e7ca692ffffcca01 -README.zh.md: cc1ab560a071a92b624a1580fa82902a2da08d99 +README.md: 9c005443eb064a03b3050c9ae82295c794315239 +README.zh.md: 3adc249afe68b4cd8b56c68101baafec47ffb981 diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 6584fade35..9c005443eb 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -54,7 +54,7 @@ Read any regular UTF-8 text file whole or as a stream, read raw bytes up to a ca Read, listing, and mutation failures are typed `FsError`s with stable codes — `FS_NOT_FOUND`, `FS_NOT_TEXT` (binary content), `FS_STALE_VERSION` (changed since observation), `FS_EDIT_NOT_FOUND` or `FS_AMBIGUOUS_EDIT` (no unique literal match), and others — so callers branch on the code, never on message text. A missing target on an edit reports `FS_STALE_VERSION` whether or not the version guard is supplied. -Chokidar observes one file or a directory's direct entries through OS events, without polling or recursive watching. File watches cover in-place writes, atomic replacement, deletion, and same-path recreation while the parent directory remains. +Chokidar observes one file or a directory's direct entries through OS events, without polling or recursive watching. Files use a filtered parent-directory watch, so readiness also covers creation of an initially missing file. File watches cover in-place writes, atomic replacement, deletion, and same-path recreation while the parent directory remains. ----- @@ -129,7 +129,6 @@ No direct invalidation; the named consumer owns any request-prefix changes. These limits define when the local backend is a poor fit or needs special operational care. They are current package constraints, not a general filesystem comparison or a task backlog. - **`config.cwd` is not a sandbox** — it is a resolution default, not containment: absolute paths and `..` escape it. Enforce containment with a stricter `ctx.fs` backend or a permission plugin on the `tools/execute` waterfall. -- **Initially missing targets** — Chokidar can report readiness before the parent-directory watch is attached; creation immediately after readiness can be missed. - **Linux parent-directory recreation** — restoring observation after a parent directory is deleted and recreated is deferred; same-path file recreation while its parent remains is supported. - **Version tokens depend on filesystem metadata** — they combine device, inode, size, nanosecond mtime, and nanosecond ctime; a storage layer that cannot update any of those facts for a rewrite can still defeat the stale guard. - **`editText` holds the whole file (plus the edited copy) in memory** — streaming exists only on the read path. diff --git a/packages/fs/fs-local/README.zh.md b/packages/fs/fs-local/README.zh.md index cc1ab560a0..3adc249afe 100644 --- a/packages/fs/fs-local/README.zh.md +++ b/packages/fs/fs-local/README.zh.md @@ -54,7 +54,7 @@ kind: "package-reference" 读取、列出与变更操作的失败是携带稳定错误码的类型化 `FsError`——`FS_NOT_FOUND`、`FS_NOT_TEXT`(二进制内容)、`FS_STALE_VERSION`(自观察以来已变化)、`FS_EDIT_NOT_FOUND` 或 `FS_AMBIGUOUS_EDIT`(无唯一字面量匹配)等——因此调用方依据错误码分支,绝不解析消息文本。编辑遇到缺失目标时,无论是否提供版本防护,都报告 `FS_STALE_VERSION`。 -Chokidar 通过 OS 事件观察单个文件或目录的直接子项,不使用轮询或递归监听。父目录保持存在时,文件监听覆盖原地写入、原子替换、删除和同路径重建。 +Chokidar 通过 OS 事件观察单个文件或目录的直接子项,不使用轮询或递归监听。文件使用筛选到目标的父目录监听,因此就绪时也能接收起初缺失文件的创建事件。父目录保持存在时,文件监听覆盖原地写入、原子替换、删除和同路径重建。 ----- @@ -129,7 +129,6 @@ Chokidar 通过 OS 事件观察单个文件或目录的直接子项,不使用 这些限制说明本地后端何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用文件系统对比或任务积压。 - **`config.cwd` 不是沙箱**:它是解析默认值,而非约束;绝对路径和 `..` 可以逃逸。请使用更严格的 `ctx.fs` 后端或 `tools/execute` waterfall(瀑布式事件)上的权限插件实施约束。 -- **初始缺失目标**:Chokidar 可能在父目录监听建立前报告就绪;紧随就绪通知的创建可能漏报。 - **Linux 父目录重建**:父目录删除并重建后的监听恢复仍延期;父目录保持存在时,同路径文件重建仍受支持。 - **版本 token 依赖文件系统元数据**:它们组合设备、inode、大小、纳秒级 mtime 与纳秒级 ctime;如果存储层在重写时无法更新其中任何一项事实,仍可能绕过陈旧防护。 - **`editText` 会把整个文件及编辑后的副本保存在内存中**:只有读取路径支持流式处理。 diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 8ae48790a7..17033e512b 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -8,7 +8,7 @@ import { Context } from '@deepseek-ai/cordis' import { constants as bufferConstants } from 'node:buffer' import { once } from 'node:events' import { watch } from 'chokidar' -import { isAbsolute, relative, resolve, sep } from 'node:path' +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path' import { pathToFileURL } from 'node:url' import z from '@deepseek-ai/schemastery' import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs' @@ -68,8 +68,17 @@ const MAX_DIFF_BASIS_BYTES = Math.min( export class LocalFileSystem extends FileSystem { override async watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise> { signal.throwIfAborted() - const watcher = watch(this.processPath(target), { ignoreInitial: true, depth: 0 }) - watcher.on('all', () => { changed() }) + const path = resolve(this.processPath(target)) + const directory = (await this.stat(target, signal))?.type === 'directory' + signal.throwIfAborted() + const root = directory ? path : dirname(path) + const watcher = watch(root, { + ignoreInitial: true, depth: 0, + ignored: entry => !directory && resolve(entry) !== root && resolve(entry) !== path, + }) + watcher.on('all', (_event, entry) => { + if (directory || resolve(entry) === path) changed() + }) watcher.on('error', (error) => { changed(error instanceof Error ? error : new Error(String(error))) }) try { await once(watcher, 'ready', { signal }) diff --git a/packages/fs/fs-local/tests/watch.spec.ts b/packages/fs/fs-local/tests/watch.spec.ts index 4217d16c83..dae8a3daa4 100644 --- a/packages/fs/fs-local/tests/watch.spec.ts +++ b/packages/fs/fs-local/tests/watch.spec.ts @@ -1,6 +1,7 @@ /** Watch initialization and asynchronous release at the Chokidar adapter. */ import { Context } from '@deepseek-ai/cordis' -import { FsTargetKey } from '@deepseek-ai/dsh-fs' +import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' +import { dirname, join, resolve } from 'node:path' import * as chokidar from 'chokidar' import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { LocalFileSystem } from '../src/index.ts' @@ -12,32 +13,48 @@ vi.mock('chokidar', async (importOriginal) => { afterEach(() => { vi.restoreAllMocks() }) -async function setup() { +async function setup(kind: 'file' | 'directory' | 'missing' = 'file') { const watcher = new chokidar.FSWatcher() onTestFinished(() => watcher.close()) - const watch = vi.mocked(chokidar.watch).mockReset().mockReturnValue(watcher) + const started = Promise.withResolvers() + const watch = vi.mocked(chokidar.watch).mockReset().mockImplementation(() => { + started.resolve() + return watcher + }) const ctx = new Context() onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(LocalFileSystem) - const target = { targetKey: FsTargetKey('/workspace/file.txt'), displayPath: 'file.txt' } + const path = resolve('/workspace/file.txt') + const target = { targetKey: FsTargetKey(path), displayPath: 'file.txt' } + const stat = vi.spyOn(ctx.fs, 'stat').mockResolvedValue(kind === 'missing' + ? undefined : { type: kind, version: FsVersion('v1'), size: 1 }) const changed = vi.fn<(error?: Error) => void>() const controller = new AbortController() onTestFinished(() => { controller.abort() }) - return { watcher, watch, fs: ctx.fs, target, changed, controller } + return { watcher, watch, fs: ctx.fs, target, changed, controller, path, stat, started: started.promise } } describe('local filesystem watch', () => { - it('waits for ready, forwards invalidation, and awaits watcher closure', async () => { - const h = await setup() + it.each(['file', 'missing'] as const)('waits for the parent watch of a %s target and awaits closure', async (kind) => { + const h = await setup(kind) const pending = h.fs.watch(h.target, h.changed, h.controller.signal) - expect(h.watch).toHaveBeenCalledExactlyOnceWith('/workspace/file.txt', { ignoreInitial: true, depth: 0 }) + await h.started + expect(h.watch).toHaveBeenCalledExactlyOnceWith(dirname(h.path), { ignoreInitial: true, depth: 0, ignored: expect.any(Function) }) + const ignored = h.watch.mock.calls[0]![1]!.ignored + if (typeof ignored !== 'function') throw new Error('Expected a target filter') + expect(ignored(dirname(h.path))).toBe(false) + expect(ignored(h.path)).toBe(false) + expect(ignored(join(dirname(h.path), 'unrelated.txt'))).toBe(true) let ready = false void pending.then(() => { ready = true }) await Promise.resolve(undefined) expect(ready).toBe(false) h.watcher.emit('ready') const close = await pending - h.watcher.emit('all', 'change', '/workspace/file.txt') + h.watcher.emit('all', 'addDir', dirname(h.path)) + h.watcher.emit('all', 'change', join(dirname(h.path), 'unrelated.txt')) + expect(h.changed).not.toHaveBeenCalled() + h.watcher.emit('all', 'change', h.path) expect(h.changed).toHaveBeenCalledExactlyOnceWith() const closed = Promise.withResolvers() onTestFinished(() => { closed.resolve(undefined) }) @@ -53,6 +70,33 @@ describe('local filesystem watch', () => { expect(h.watcher.closed).toBe(true) }) + it('watches a directory itself without filtering its direct entries', async () => { + const h = await setup('directory') + const pending = h.fs.watch(h.target, h.changed, h.controller.signal) + await h.started + expect(h.watch.mock.calls[0]![0]).toBe(h.path) + const ignored = h.watch.mock.calls[0]![1]!.ignored + if (typeof ignored !== 'function') throw new Error('Expected a target filter') + expect(ignored(join(h.path, 'child.txt'))).toBe(false) + h.watcher.emit('ready') + const close = await pending + h.watcher.emit('all', 'change', join(h.path, 'child.txt')) + expect(h.changed).toHaveBeenCalledExactlyOnceWith() + await close() + }) + + it('does not acquire a watcher when cancelled during metadata lookup', async () => { + const h = await setup() + const metadata = Promise.withResolvers() + h.stat.mockReturnValueOnce(metadata.promise) + const pending = h.fs.watch(h.target, h.changed, h.controller.signal) + const rejected = expect(pending).rejects.toThrow() + h.controller.abort() + metadata.resolve(undefined) + await rejected + expect(h.watch).not.toHaveBeenCalled() + }) + it('rejects an already-cancelled initialization without acquiring a watcher', async () => { const h = await setup() h.controller.abort() @@ -64,6 +108,7 @@ describe('local filesystem watch', () => { const h = await setup() const pending = h.fs.watch(h.target, h.changed, h.controller.signal) const rejected = expect(pending).rejects.toThrow() + await h.started h.controller.abort() await rejected expect(h.watcher.closed).toBe(true) @@ -75,6 +120,7 @@ describe('local filesystem watch', () => { const error = new Error('watch failed') const pending = h.fs.watch(h.target, h.changed, h.controller.signal) const rejected = expect(pending).rejects.toBe(error) + await h.started h.watcher.emit('error', error) await rejected expect(h.changed).toHaveBeenCalledExactlyOnceWith(error) @@ -84,6 +130,7 @@ describe('local filesystem watch', () => { it('normalizes watcher errors after initialization', async () => { const h = await setup() const pending = h.fs.watch(h.target, h.changed, h.controller.signal) + await h.started h.watcher.emit('ready') const close = await pending const error = new Error('watch failed') From 7e09291f15ff9277a80b3103547bdff3b9cb0665 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sat, 19 Sep 2026 00:10:11 +0800 Subject: [PATCH 08/10] fix(sidebar): preserve expansion during directory restoration --- ...-file-and-directory-auto-refresh.i18n.yaml | 6 +++ ...sidebar-file-and-directory-auto-refresh.md | 53 +++++++++---------- ...ebar-file-and-directory-auto-refresh.zh.md | 53 +++++++++---------- ...-file-and-directory-auto-refresh.i18n.yaml | 6 --- .../client/ui-sidebar-files/README.i18n.yaml | 4 +- packages/client/ui-sidebar-files/README.md | 3 +- packages/client/ui-sidebar-files/README.zh.md | 3 +- .../src/client/directory-node.ts | 9 ++++ .../ui-sidebar-files/src/client/face.ts | 15 ++++-- .../tests/face.client.spec.ts | 49 +++++++++++++++++ packages/fs/fs-local/tests/watch.spec.ts | 6 +-- 11 files changed, 129 insertions(+), 78 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.i18n.yaml rename .agents/notes/{proposed => implemented}/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md (87%) rename .agents/notes/{proposed => implemented}/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md (88%) delete mode 100644 .agents/notes/proposed/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.i18n.yaml 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 new file mode 100644 index 0000000000..70ee32d07a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md +2026-09-17-sidebar-file-and-directory-auto-refresh.md: f33be6a8d45756d3bac83d0a6e21a4110543e3b2 +2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md: 88fe6e7abb4027ec8b5c4fab4f1ad07477aef6fa diff --git a/.agents/notes/proposed/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 similarity index 87% rename from .agents/notes/proposed/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md rename to .agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md index 930ae76213..f33be6a8d4 100644 --- a/.agents/notes/proposed/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 @@ -1,6 +1,6 @@ # Agent Note: On-demand watching and automatic refresh for Sidebar previews and file trees -Status: proposed +Status: implemented English | [中文](2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md) @@ -19,9 +19,9 @@ The starting implementation has both read paths but no complete automatic-refres `Resource` holds file metadata, not preview contents. Document Preview or its selected renderer reads the contents. Metadata updates and content reloads therefore remain separate responsibilities: the generic Resource acquires no content cache, and renderers do not directly manage filesystem watchers. -## Proposal +## Decision -Connect on-demand observation through the existing FS, Workspace file stream, Resource, and Sidebar components. Files and directories share OS watching and the existing Remote stream transport, but consume them differently. +On-demand observation connects the existing FS, Workspace file stream, Resource, and Sidebar components. Files and directories share OS watching and the existing Remote stream transport, but consume them differently. - Document Preview follows the current file. Its Resource publishes new metadata, and the preview invokes its existing reload. The HTML root and referenced CSS/JS files join one `ResourceGroup`; any member change invalidates the whole HTML preview. - Files manages reads and watchers through a directory-node tree. Each open node loads and watches only its direct entries. The Client loads and subscribes to exactly the subtree levels it opens, without traversing unopened descendants. @@ -30,17 +30,17 @@ Connect on-demand observation through the existing FS, Workspace file stream, Re - Local Chokidar watches use OS events, without polling the whole Workspace or recursively scanning all descendants by default. - Both panels retain manual refresh and automatic-refresh functionality, but temporarily hide the automatic-refresh icon button. State and toggle logic remain; new Tabs default to enabled. Hiding the control does not stop observation or automatic refresh. -This change only connects notifications to existing reload/list operations; it does not add retention of previous preview content after a failed reload. The following sections distinguish existing types from new objects and describe responsibilities, data flow, and acceptance scope. An acceptance item is not complete until it passes. +Notifications invoke existing reload/list operations; a failed reload does not retain previous preview content. The following sections describe the implementation's responsibilities, data flow, and verification. -## Class and file changes +## Implementation and responsibilities ### FS and Host -| Class or type | File | Intended change | +| Class or type | File | Responsibility | |---|---|---| | `FileSystem` | [fs/src/index.ts](../../../../packages/fs/fs/src/index.ts) | Declare abstract single-target `watch(target, changed, signal)`: a file observes itself; a directory observes its direct entries. Resolve with an async close function once ready. Unsupported providers explicitly throw, without a new FS error code | | `FsTarget` | [fs/src/types.ts](../../../../packages/fs/fs/src/types.ts) | Reuse the existing target type, without another FS watch-event system or upper-layer parsing of `targetKey` | -| `LocalFileSystem` | [fs-local/src/index.ts](../../../../packages/fs/fs-local/src/index.ts) | Adapt Chokidar, normalize local events into FS invalidation, and await watcher closure; declare the dependency in `fs-local` | +| `LocalFileSystem` | [fs-local/src/index.ts](../../../../packages/fs/fs-local/src/index.ts) | Watches a file's parent with target-only filtering, or the directory itself for directory targets; awaits Chokidar readiness and closure; owns the Chokidar dependency | | `SandboxedFileSystem` | [fs-sandbox/src/index.ts](../../../../packages/fs/fs-sandbox/src/index.ts) | Inherit local read-only observation without duplicating the watcher; retain its write and edit policy checks | | `SshFileSystem` | [fs-ssh/src/index.ts](../../../../packages/ssh/fs-ssh/src/index.ts) | `watch()` explicitly throws an ordinary exception; the Host converts it to a `workspace-file/watch-unsupported` `RemoteError`, without importing or checking an `FsError` runtime class across packages or passing remote `processPath()` values to local Chokidar | | `WorkspaceFiles` | [workspace-files/src/index.ts](../../../../packages/api/workspace-files/src/index.ts) | Use `changes(scope, path, signal)` with only the target path. Host stat determines whether directory containment applies, including after type changes; ordinary files retain file-read authority | @@ -50,18 +50,18 @@ This change only connects notifications to existing reload/list operations; it d ### Client and Sidebar -| Class, function, or type | File | Intended change | +| Class, function, or type | File | Responsibility | |---|---|---| | `ChangeFeed`, `SessionFeed`, `Follower` | [workspace-files/src/client/change-feed.ts](../../../../packages/api/workspace-files/src/client/change-feed.ts) | Key streams by Session and target path; reuse readiness, path binding, cancellation, and reconnect logic without reorganizing classes just to rename them | | `createFileResourceProvider` | [workspace-files/src/client/provider.ts](../../../../packages/api/workspace-files/src/client/provider.ts) | Pass the address's Session and path to the target stream; publish complete metadata initially and after notifications, rather than replacing `version` while retaining stale `bytes` | | `ResourceRegistry`, `ResourceProvider`, `UseResource` | [Resource definitions](../../../../packages/client/resources/src/client/contract.ts), [ResourceRegistry](../../../../packages/client/resources/src/client/resources.ts) | Keep existing streaming, retention counts, and subscriptions; add neither a second `onChange` nor a generic content-reload API | | `TextPreview` | [TextPreview.tsx](../../../../packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx) | Invoke existing reload when a Resource or group changes and automatic refresh is enabled; retain errors and manual refresh, with the separate automatic-refresh button temporarily hidden | | `textFace`, `TabReads`, `createTextStore` | [Preview face](../../../../packages/client/ui-sidebar-documentpreview/src/client/face.ts), [Preview store](../../../../packages/client/ui-sidebar-documentpreview/src/client/store.ts) | Own one group per Tab; add default-enabled `autoRefresh` and pending-refresh flag `resourcesDirty`; reuse content-read generations and `loadRevision` | -| New package-private `ResourceGroup` | [resource-group.ts](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/resource-group.ts) | Observe 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 | +| 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` | -| New package-private `DirectoryNode` | [directory-node.ts](../../../../packages/client/ui-sidebar-files/src/client/directory-node.ts) | Represent one open directory, owning its stream, read state, and open children; handle local refresh, expansion, recursive closure, and propagation of automatic-refresh state | +| 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 | | 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 | @@ -98,7 +98,7 @@ Resource path, version, and size come from the same stat. Keep the existing smal - `FileSystem.watch()` accepts a target resolved by its provider; the FS provider owns the meaning of local or remote paths. - File watching retains file-read access rules. Existing previews may read some paths outside the Workspace through the selected FS; directory-tree containment must not be imposed on those files. - Directory watching retains `list()`'s Workspace scope and path checks, including after target type changes, without widening browsing authority. -- File watching covers in-place writes, replacement by a temporary file, deletion, and recreation at the same path while the parent directory remains. Chokidar may internally observe the necessary parent directory, but the public watch still targets the specified file. +- File watching covers in-place writes, replacement by a temporary file, deletion, and recreation at the same path while the parent directory remains. The local provider watches that parent directly, filters entries and events to the target file, and waits for the parent watcher to be ready before reporting readiness, including when the file does not yet exist. - A directory watch observes itself and direct entries, without recursively opening unexpanded children. Creation, deletion, renaming, type changes, and visible metadata changes of direct entries invalidate that level. - Local watching uses OS events. Deployment controls such as write-stability time or event-coalescing windows, if needed, belong in their owner's configuration rather than scattered component constants. - OS watching drives UI refresh only. It does not fabricate an agent's authoritative `fs/observed` read observation or change the observed-version policy for editing. @@ -217,7 +217,7 @@ The Files runtime is itself an on-demand directory tree. It does not first const | Listing update | Retain open children still present; close deleted, renamed, or file-replaced branches without reopening every node | | Close tree | Closing the root recursively releases the active tree, without traversing another global watcher registry | -`createFilesStore.expanded` may retain user expansion preferences, but is not another watcher registry. Closing a parent cannot retain active descendant nodes merely because their preferences remain. Reopening first reads the parent, then progressively restores still-present children. A deep descendant's collapse click can be ignored while its parent node is still being restored; this interaction fix is deferred. +`createFilesStore.expanded` retains user expansion preferences, but is not another watcher registry. Closing a parent cannot retain active descendant nodes merely because their preferences remain. Reopening first reads the parent, then progressively restores still-present children. Expand and collapse actions update the store and pending restoration even when the parent node is not yet active; restored descendants follow the latest preferences instead of reopening a collapsed branch. ```text workspace/ watch @@ -284,7 +284,7 @@ Observation, reads, and presentation need a few explicit ordering rules, not dis - 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. - 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; the proposal does not promise an atomic snapshot for every read. +- 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. ## Alternatives considered @@ -300,17 +300,11 @@ Observation, reads, and presentation need a few explicit ordering rules, not dis **Call Chokidar directly in the Workspace API.** The API does not own the execution world; SSH paths may exist only remotely. The actual FS provider must implement watching. -## Acceptance criteria +## Verification -Implementation can proceed in the following order, retaining the same explicit-target observation model throughout. +The owning FS, Workspace API, preview, and Files tests exercise watch lifetimes, metadata updates, and automatic refresh. The [Web document-preview test](../../../../apps/web/tests/document-preview.e2e.ts) exercises previews through the shipped Web composition. Session event logs, persistence formats, and model input are unchanged. -1. FS definition and local provider: file and nonrecursive directory watches, readiness, errors, and async release; the sandboxed local provider inherits them. -2. Workspace Remote: path-only requests, stat-based directory containment, file metadata frames, directory invalidation frames, and rereads after reconnect. -3. Document Preview: ResourceGroup, HTML CSS/JS membership, automatic-refresh control, and the three existing reload paths. -4. Files: the existing face with an on-demand `DirectoryNode` tree, automatic-refresh control, local refresh, recursive collapse release, and progressive rereads on reopening. -5. Update affected READMEs, JSDoc, generated Remote declarations, and user-output verification; leave Session event logs, persistence formats, and model input unchanged. - -| Acceptance scenario | Expected result | +| Scenario | Verified behavior | |---|---| | External in-place writes, atomic replacement, shell writes | Open previews refresh without depending on `fs/observed` | | Text, complete bytes, renderer-owned loading | All three modes refresh; old requests cannot overwrite new results, and Office failures allow a later file change to retry | @@ -322,17 +316,18 @@ Implementation can proceed in the following order, retaining the same explicit-t | An expanded child changes | Refresh only its level while preserving other levels and expansion preferences | | Unexpanded deep directories | No deep watcher or recursive traversal | | Collapse a parent | Release its watch and hidden descendants; an independently open file preview keeps watching | +| Collapse or reexpand a deep descendant during ancestor restoration | Record the latest preference immediately; restoration opens only branches still expanded | | Reopen, return to Files, or reconnect | Obtain current listings without depending on replay of missed events | | Delete/recreate a file, or replace a child directory with a file | File watches recover at the same path while the parent remains; directory listings release invalid subtrees | | Rapid writes, duplicate notifications, changes during reads or their completion | Converge on current state with finite rereads, without endless refresh | | Close Tab, unload plugin, cancel initialization | Close all owned watches even when no further frame is requested; late notifications and reads no longer write state | | Unsupported providers or out-of-workspace directories | Report unavailable observation or access errors explicitly, without watching the wrong execution world or widening directory authority | -Verification includes controlled readiness/cancellation tests, real local-watch integration on temporary directories, and preview/tree flows through the real Web composition. Real-file tests wait for watch readiness before acting and finish on observable state, not fixed sleeps. User-visible refresh updates the corresponding keyless replay or owner-local expected output; no GIF is produced. This section states acceptance scope; executed results belong in the PR. +Controlled streams and deferred reads verify readiness, cancellation, and expansion during restoration. Real local-watch tests use temporary directories, wait for readiness before acting, and finish on observable state rather than fixed sleeps. The Web preview test and owner-local UI tests cover user-visible behavior; executed command results belong in the PR. -## Risks +## Consequences -- Automatic watch recovery after a parent directory is deleted and recreated on Linux is deferred; this proposal does not promise that scenario. Same-path file recreation while the parent remains stays supported. +- Automatic watch recovery after a parent directory is deleted and recreated on Linux is deferred. Same-path file recreation while the parent remains stays supported. - OS file events are not durable messages. Disconnects and process restarts may lose notifications, so every stream generation obtains current state after readiness. - Open files and expanded directories increase watcher and stream counts. The first version controls this through on-demand lifetimes, without an advance cross-consumer deduplication service. - Automatic refresh may interrupt reading, especially HTML interactions and Office conversion. View preferences and coalescing remain, but arbitrary embedded-document runtime state is not preserved. @@ -341,10 +336,10 @@ Verification includes controlled readiness/cancellation tests, real local-watch ## Relationship to existing Agent Notes -This proposal adds automatic refresh and OS watching without replacing the overall Resource, file-read authorization, or renderer ownership design. The following records retain independent value; this note references them without editing or archiving them. +Automatic refresh and OS watching retain the Resource model, file-read authorization, and renderer ownership. The following records retain independent rationale; this note owns target-scoped observation, automatic reload, and directory-watch lifetimes. - [Client Resource model](../../implemented/architecture/2026-09-05-client-resource-model.md): retain addresses, providers, subscriptions, and lifetimes; existing streams carry the new behavior. -- [Workspace file service](../../implemented/architecture/2026-09-05-workspace-files-service.md): retain read and listing responsibilities; revise Session-scoped observation streams and event sources. -- [Document preview operations](../../implemented/architecture/2026-09-08-document-preview-operations.md): retain content-loading ownership and all three modes; change manual confirmation after file updates. +- [Workspace file service](../../implemented/architecture/2026-09-05-workspace-files-service.md): read and listing responsibilities remain; target-scoped OS observation replaces the Session-scoped operation-only stream. +- [Document preview operations](../../implemented/architecture/2026-09-08-document-preview-operations.md): content-loading ownership and all three modes remain; automatic refresh uses their existing reload paths. - [Workspace file-read authority](../../implemented/architecture/2026-09-09-workspace-file-read-authority.md): retain distinct access scopes for file reading and directory browsing; target watches follow their respective rules. -- [Sidebar text preview and file tree](../../implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md): retain per-Tab expansion, navigation, and scrolling; add automatic directory invalidation and watches for expanded nodes. +- [Sidebar text preview and file tree](../../implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md): per-Tab expansion, navigation, and scrolling remain; open nodes own automatic directory invalidation and watching. diff --git a/.agents/notes/proposed/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 similarity index 88% rename from .agents/notes/proposed/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md rename to .agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md index 9052d6a5f7..88fe6e7abb 100644 --- a/.agents/notes/proposed/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 @@ -1,6 +1,6 @@ # Agent Note: Sidebar 文件预览与目录树按需监听并自动刷新 -Status: proposed +Status: implemented [English](2026-09-17-sidebar-file-and-directory-auto-refresh.md) | 中文 @@ -19,9 +19,9 @@ Sidebar 的 Document Preview 和 Files 面板都需要反映磁盘上的最新 `Resource` 当前承载文件元数据,不持有预览正文。正文由 Document Preview 或它选择的渲染器读取。因此,“元数据更新”和“重新加载正文”应保持各自的职责,不把正文缓存搬进通用 Resource,也不要求每个渲染器直接接触文件系统监听。 -## 方案 +## 决策 -在现有 FS、Workspace 文件流、Resource 和 Sidebar 组件之间补齐按需监听。文件与目录共用系统监听能力和现有 Remote 流传输,但消费方式不同。 +按需监听连接现有 FS、Workspace 文件流、Resource 和 Sidebar 组件。文件与目录共用系统监听能力和现有 Remote 流传输,但消费方式不同。 - Document Preview:监听当前文件,Resource 发布新元数据,预览自动调用已有 reload。HTML 的根文件和引用的 CSS/JS 都加入同一个 `ResourceGroup`,任一成员变化都使整个 HTML 预览失效。 - Files 面板:以目录节点树管理读取和监听。每个已打开的目录节点只加载并监听自己的直接子项;Client 打开多少层子树,就逐层加载并订阅多少层,不提前遍历未打开的后代。 @@ -30,17 +30,17 @@ Sidebar 的 Document Preview 和 Files 面板都需要反映磁盘上的最新 - 使用本地 Chokidar 系统监听,不定时轮询整个 Workspace,不默认递归扫描所有后代。 - 保留两个面板的手动刷新入口和自动刷新开关功能,但暂时隐藏开关的 icon 按钮。开关状态及切换逻辑不变,新 Tab 默认开启;隐藏入口不关闭监控或自动刷新。 -本次只增加变化通知触发现有 reload/list,不增加刷新失败后保留旧预览内容的行为。下面按已有类型和新增对象说明职责、数据流及验收范围;未通过的验收项不视为已完成。 +变化通知触发现有 reload/list;刷新失败后不保留旧预览内容。下面说明实现对象的职责、数据流及验证。 -## 类与文件改动 +## 实现对象与职责 ### FS 与 Host -| 类或类型 | 所在文件 | 拟改动 | +| 类或类型 | 所在文件 | 职责 | |---|---|---| | `FileSystem` | [fs/src/index.ts](../../../../packages/fs/fs/src/index.ts) | 声明抽象的单目标 `watch(target, changed, signal)` 方法;文件观察自身,目录观察直接子项;就绪后返回异步关闭函数,不支持的提供方显式抛异常,不新增 FS 错误码 | | `FsTarget` | [fs/src/types.ts](../../../../packages/fs/fs/src/types.ts) | 沿用现有目标类型,不单独增加 FS 监听事件体系,也不让上层解析 `targetKey` | -| `LocalFileSystem` | [fs-local/src/index.ts](../../../../packages/fs/fs-local/src/index.ts) | 实现 Chokidar 适配;把本机事件归一化为 FS 监听通知,并在结束时等待 watcher 关闭;依赖声明在 `fs-local` | +| `LocalFileSystem` | [fs-local/src/index.ts](../../../../packages/fs/fs-local/src/index.ts) | 文件监听其父目录并过滤到目标,目录监听自身;等待 Chokidar 就绪和关闭;依赖声明在 `fs-local` | | `SandboxedFileSystem` | [fs-sandbox/src/index.ts](../../../../packages/fs/fs-sandbox/src/index.ts) | 继承本地只读监听能力,不复制 watcher 实现;保留自身对写入、编辑的策略检查 | | `SshFileSystem` | [fs-ssh/src/index.ts](../../../../packages/ssh/fs-ssh/src/index.ts) | `watch()` 显式抛出普通异常;Host 转成 `workspace-file/watch-unsupported` 的 `RemoteError`,不跨包导入或判断 `FsError` 实体,也不把远端 `processPath()` 传给本机 Chokidar | | `WorkspaceFiles` | [workspace-files/src/index.ts](../../../../packages/api/workspace-files/src/index.ts) | 使用仅携带目标路径的 `changes(scope, path, signal)`;Host stat 决定是否执行目录包含检查,目标类型变化后也检查,普通文件沿用文件读取权限 | @@ -50,18 +50,18 @@ Sidebar 的 Document Preview 和 Files 面板都需要反映磁盘上的最新 ### Client 与 Sidebar -| 类、函数或类型 | 所在文件 | 拟改动 | +| 类、函数或类型 | 所在文件 | 职责 | |---|---|---| | `ChangeFeed`、`SessionFeed`、`Follower` | [workspace-files/src/client/change-feed.ts](../../../../packages/api/workspace-files/src/client/change-feed.ts) | 将流的键改为 Session 加目标路径,继续复用既有就绪、路径绑定、取消和重连逻辑;此版不为了命名重排类结构 | | `createFileResourceProvider` | [workspace-files/src/client/provider.ts](../../../../packages/api/workspace-files/src/client/provider.ts) | 将文件地址中的 Session 与路径传给目标流;首次读取及后续通知都发布完整元数据,不能只换 `version` 而继续沿用旧 `bytes` | | `ResourceRegistry`、`ResourceProvider`、`UseResource` | [Resource 定义](../../../../packages/client/resources/src/client/contract.ts)、[ResourceRegistry](../../../../packages/client/resources/src/client/resources.ts) | 保持现有流、持有计数和订阅模型;不新增第二套 `onChange`,不为此增加通用正文 reload API | | `TextPreview` | [TextPreview.tsx](../../../../packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx) | Resource 或 group 变化且开关开启时自动触发现有 reload;保留错误状态与手动刷新,独立自动刷新按钮暂时隐藏 | | `textFace`、`TabReads`、`createTextStore` | [预览 face](../../../../packages/client/ui-sidebar-documentpreview/src/client/face.ts)、[预览存储](../../../../packages/client/ui-sidebar-documentpreview/src/client/store.ts) | 每个 Tab 持有一个 group,补充默认开启的 `autoRefresh` 和表示待刷新的 `resourcesDirty`;复用正文读取代次与 `loadRevision` | -| 新增的包内 `ResourceGroup` | [resource-group.ts](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/resource-group.ts) | 通过现有 `resources.source(address)` 观察成员;`add` 加入资源,`set` 更新完整成员列表,`close` 释放全部成员,不改变通用 Resource 服务 | +| 包内 `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` | -| 新增的包内 `DirectoryNode` | [directory-node.ts](../../../../packages/client/ui-sidebar-files/src/client/directory-node.ts) | 表示一个已打开的目录,拥有本目录变化流、读取状态和已打开子节点;负责局部刷新、展开、递归关闭,以及向子节点传递自动刷新状态 | +| 包内 `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) | 将展开、折叠、刷新和面板卸载交给注入回调;按节点展示结果渲染,不扫描整棵树生成监听集合,展示仍只读已有框架钩子与存储 | | Files 注册入口 | [Files client/index.ts](../../../../packages/client/ui-sidebar-files/src/client/index.ts) | 给现有 `filesFace` 增加目录变化流回调;异步副作用留在注入实现,不引入另一个功能插件的运行时导出 | @@ -98,7 +98,7 @@ Resource 发布的路径、版本和大小来自同一次 stat。先沿用现有 - `FileSystem.watch()` 接受本提供方解析出的目标;本机或远端路径的含义由 FS 提供方负责。 - 文件监听保持文件读取的访问规则。现有预览允许通过所选 FS 读取部分 Workspace 外的路径,不能把目录树的范围限制顺手强加给这些文件。 - 目录监听保持 `list()` 的 Workspace 范围及路径检查,目标类型变化后也执行检查,不因新增 watch 扩大目录浏览权限。 -- 父目录保持存在时,文件监听覆盖原地写入、临时文件替换、删除和同一路径重建。Chokidar 可在内部观察必要的父目录,但对外仍是指定文件的监听。 +- 父目录保持存在时,文件监听覆盖原地写入、临时文件替换、删除和同一路径重建。本地提供方直接监听父目录,将条目和事件过滤到目标文件,等待父目录 watcher 就绪后才报告就绪,目标文件尚不存在时也适用。 - 目录监听只关注该目录自身和直接子项,不递归打开尚未展开的子目录;直接子项的创建、删除、重命名、类型或可见元数据变化都使这一层失效。 - 本地实现采用系统事件模式;若需要调整写入稳定时间、事件合并窗口等部署参数,应由拥有它们的配置声明,不散落在组件里。 - 系统 watch 只驱动 UI 刷新,不把外部磁盘变化伪装成某个 agent 已读取文件的 `fs/observed`,不改变文件编辑的已观察版本策略。 @@ -217,7 +217,7 @@ Files 的运行时对象本身就是一棵按需打开的目录树,不先建 | 列表更新 | 保留仍存在的已打开子节点,关闭已删除、重命名或变为文件的分支;不重新打开所有节点 | | 整树关闭 | 对根节点执行关闭,自然递归释放整棵活动子树,不另遍历全局监听注册表 | -`createFilesStore.expanded` 可以继续保存用户的展开偏好,但不是另一份监听注册表。父目录关闭后,即使存储保留了后代展开偏好,也不能继续持有这些后代的活动节点。重新打开时先读取父目录,再对仍存在且需要恢复的子目录逐层建立节点;父节点尚在恢复时,深层后代的折叠点击可能被忽略,该交互修复仍延期。 +`createFilesStore.expanded` 保存用户的展开偏好,但不是另一份监听注册表。父目录关闭后,即使存储保留了后代展开偏好,也不能继续持有这些后代的活动节点。重新打开时先读取父目录,再对仍存在且需要恢复的子目录逐层建立节点。即使父节点尚未创建,展开和折叠也会更新存储与待恢复状态;后代恢复时遵循最新偏好,不会重新打开已折叠的分支。 ```text workspace/ watch @@ -284,7 +284,7 @@ Files 监听跟随 Tab 生命周期;折叠关闭对应子树,关闭 Tab 则 - 目录后台刷新保留当前列表,不另存没有显示用途的 `refreshing` 标记;只有没有可显示列表时才进入 `loading`,避免子树闪烁和监听反复重建。 - 目录折叠、删除或 Tab 关闭后,已取消读取的晚到结果不得重新创建状态或恢复监听。即使生成器停在已产出的帧上,取消也会关闭 Host watcher;流与插件拆除等待关闭完成。 - 父目录保持存在时,文件短暂消失不结束监听;正常文件变化与监听自身失败分别处理。同路径文件监听覆盖原子保存与删除重建。 -- 原有文件读取并非事务快照,stat 与正文读取之间仍可能发生写入;本方案保证后续通知触发刷新,不声称每次读取都获得原子快照。 +- 原有文件读取并非事务快照,stat 与正文读取之间仍可能发生写入;后续通知触发刷新,自动刷新不保证每次读取都获得原子快照。 ## 不采用的替代方案 @@ -300,17 +300,11 @@ Files 监听跟随 Tab 生命周期;折叠关闭对应子树,关闭 Tab 则 **在 Workspace API 中直接调用 Chokidar。** API 不拥有执行世界,SSH 路径可能只在远端存在。watch 必须由实际 FS 提供方实现。 -## 实施范围与验收 +## 验证 -实施可按下面的顺序推进,每一步都保留同一套目标明确的监听模型。 +FS、Workspace API、预览和 Files 各自的测试覆盖监听生命周期、元数据更新和自动刷新。[Web 文档预览测试](../../../../apps/web/tests/document-preview.e2e.ts) 从正式 Web 组合验证文件预览。会话事件日志、持久化格式和模型输入均不变。 -1. FS 定义和本地提供方:文件与非递归目录 watch、就绪、错误和异步释放;沙箱本地提供方继承。 -2. Workspace Remote:仅传路径的请求、基于 stat 的目录包含检查、文件元数据帧、目录失效帧,以及重连后的重新读取。 -3. Document Preview:ResourceGroup、HTML 的 CSS/JS 成员、自动刷新开关,以及三种现有 reload 路径。 -4. Files:现有 face 加按需 `DirectoryNode` 树、自动刷新开关、节点局部刷新、折叠递归释放和重新展开逐层补读。 -5. 同步受影响 README、JSDoc、Remote 生成声明和用户输出验证;不修改会话事件日志、持久化格式或模型输入。 - -| 验收场景 | 预期结果 | +| 场景 | 已验证行为 | |---|---| | 外部原地写入、原子替换、Shell 写入 | 打开的文件预览自动更新,不依赖 `fs/observed` | | 文本、完整字节、自主加载渲染器 | 三种模式都刷新,旧请求不能覆盖新结果,Office 失败后允许后续文件变化重试 | @@ -322,17 +316,18 @@ Files 监听跟随 Tab 生命周期;折叠关闭对应子树,关闭 Tab 则 | 已展开子目录变化 | 只刷新对应目录层,保留其他层与展开偏好 | | 未展开的深层目录 | 不创建深层 watcher,不递归遍历 | | 折叠父目录 | 释放它和不可见后代的监听;文件预览若仍打开则独立继续监听 | +| 祖先恢复期间折叠或重新展开深层后代 | 立即记录最新偏好,恢复时只打开仍需展开的分支 | | 重新展开、切回 Files、连接恢复 | 重新读取当前列表,不依赖错过事件的回放 | | 文件删除后重建,或子目录替换为文件 | 父目录保持存在时,文件监听在同一路径恢复;目录列表释放失效子树 | | 快速写入、重复通知、读取中或收尾时再修改 | 有限次补读并收敛到最新状态,不无限刷新 | | 关闭 Tab、卸载插件、取消初始化 | 即使不再拉取下一帧,所属监听也完全关闭;晚到通知和读取不再写入状态 | | 不支持监听的提供方、越界目录请求 | 明确报告支持状态或访问错误,不观察错误执行世界或扩大目录权限 | -验证应包含可控事件源下的就绪与取消测试、真实临时目录上的本地 watch 集成测试,以及从真实 Web 组合进入的文件预览和目录树动线。真实文件测试等待 watcher 就绪后再操作,通过观察到的状态结束,不靠固定 sleep 猜测事件时机。用户可见自动刷新应更新对应的无密钥回放或拥有者本地预期输出,不制作 GIF;这里列出验收范围,执行结果记录在 PR 中。 +可控事件源和延迟读取验证就绪、取消及恢复期间的展开操作。真实本地 watch 测试使用临时目录,等待 watcher 就绪后再操作,通过观察到的状态结束,不靠固定 sleep 猜测事件时机。Web 预览测试和包内 UI 测试覆盖用户可见行为;命令执行结果记录在 PR 中。 -## 风险与后续范围 +## 影响 -- Linux 上父目录删除并重建后的自动监听恢复仍延期,本提案不承诺该场景;父目录保持存在时,同路径文件重建仍受支持。 +- Linux 上父目录删除并重建后的自动监听恢复仍延期;父目录保持存在时,同路径文件重建仍受支持。 - OS 文件事件不是持久消息,断线或进程重启可能丢通知,因此每一代流就绪后都要重新取得当前状态。 - 多个已打开文件和展开目录会增加 watcher 与流数量;第一版用按需生命周期控制数量,不提前加入复杂的跨消费者去重服务。 - 自动刷新可能打断阅读,尤其是 HTML 内部交互和 Office 转换。保留视图偏好、合并连续变化,但不承诺保存任意嵌入文档的运行状态。 @@ -341,10 +336,10 @@ Files 监听跟随 Tab 生命周期;折叠关闭对应子树,关闭 Tab 则 ## 与已有 Agent Note 的关系 -本提案增加自动刷新和系统监听,不替代已有 Resource、文件读取授权和渲染器职责的整体设计。以下记录仍有独立价值,本文仅引用,不修改或归档它们。 +自动刷新和系统监听保留已有 Resource 模型、文件读取授权和渲染器职责。以下记录仍有独立的设计理由;本文负责目标级监听、自动 reload 和目录监听生命周期。 - [Client Resource 模型](../../implemented/architecture/2026-09-05-client-resource-model.zh.md):保留地址、提供方、订阅和持有期;新增行为由已有流承载。 -- [Workspace 文件服务](../../implemented/architecture/2026-09-05-workspace-files-service.zh.md):保留读取与列表接口职责;拟调整其 Session 范围观察流及事件来源。 -- [文档预览操作](../../implemented/architecture/2026-09-08-document-preview-operations.zh.md):保留正文加载所有权和三种加载方式;拟改变文件变化后的手动确认刷新行为。 +- [Workspace 文件服务](../../implemented/architecture/2026-09-05-workspace-files-service.zh.md):读取与列表接口职责不变;目标级系统监听替代仅转发操作观察的 Session 流。 +- [文档预览操作](../../implemented/architecture/2026-09-08-document-preview-operations.zh.md):正文加载所有权和三种加载方式不变;自动刷新使用其现有 reload 路径。 - [Workspace 文件读取授权](../../implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md):保留文件读取与目录浏览的不同访问范围;目标 watch 分别遵循对应规则。 -- [Sidebar 文本预览与文件树](../../implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md):保留按 Tab 的展开、导航和滚动状态;拟增加目录自动失效及展开期监听。 +- [Sidebar 文本预览与文件树](../../implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md):按 Tab 的展开、导航和滚动状态不变;已打开节点负责目录自动失效与监听。 diff --git a/.agents/notes/proposed/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.i18n.yaml b/.agents/notes/proposed/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.i18n.yaml deleted file mode 100644 index 6f9d9e7ad7..0000000000 --- a/.agents/notes/proposed/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md -2026-09-17-sidebar-file-and-directory-auto-refresh.md: 930ae762137ce43adeb30f47859808df17b1e27d -2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md: 9052d6a5f7dd9ca43511e94b7d3d2d01653f8d2b diff --git a/packages/client/ui-sidebar-files/README.i18n.yaml b/packages/client/ui-sidebar-files/README.i18n.yaml index 07c7e770b5..9edfb4b6cc 100644 --- a/packages/client/ui-sidebar-files/README.i18n.yaml +++ b/packages/client/ui-sidebar-files/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-files/README.md -README.md: e52b298814479c5e45f28d6a1f3b6158534ccdf9 -README.zh.md: b6824c4b47dd75ae540691bd286e0c15ecc4ccca +README.md: 491072c1daa95dc33cb268e7c825a95d738fae7c +README.zh.md: eceffb1c3ac0f7ba0437b23ee9e1ed6f905f5369 diff --git a/packages/client/ui-sidebar-files/README.md b/packages/client/ui-sidebar-files/README.md index e52b298814..491072c1da 100644 --- a/packages/client/ui-sidebar-files/README.md +++ b/packages/client/ui-sidebar-files/README.md @@ -45,7 +45,7 @@ A level cut by the endpoint's entry cap ends with a marker; an empty level says 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. -Each open `DirectoryNode` owns its target watch for the Tab lifetime; collapse closes that node and its hidden descendants. 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 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. ## Model Experience @@ -60,7 +60,6 @@ None; directory listings travel over the Remote and assemble no model request. - **Listing only.** No search, artifact filter, drag-and-drop, rename, context menu, or current-file highlight. -- **Restoration clicks.** A deep descendant's collapse click can be ignored while its parent node is still being restored; this interaction fix is deferred. - **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. diff --git a/packages/client/ui-sidebar-files/README.zh.md b/packages/client/ui-sidebar-files/README.zh.md index b6824c4b47..eceffb1c3a 100644 --- a/packages/client/ui-sidebar-files/README.zh.md +++ b/packages/client/ui-sidebar-files/README.zh.md @@ -45,7 +45,7 @@ kind: "package-reference" 状态保存在类型自己的存储里,按 tab id 分桶:`root`、`levels`(每个绝对路径的 loading / ready / failed)、`expanded`、`autoRefresh` 与 `scrollTop`——滚动期间偏移由正文自己记录,卸载时一次性写入。存储比 body 活得久,切到其他侧栏 tab 再切回来时树带着已加载的层重新挂载,滚动位置也随之恢复。owner 的 `signal` 终结一个桶:中止时忘掉该 tab,其后才结算的列表与卸载时的偏移提交都什么也不写。 -每个打开的 `DirectoryNode` 在 Tab 生命周期内持有自己的目标监听;折叠关闭该节点及其不可见后代。自动刷新默认开启;独立开关暂时隐藏,状态、文案、样式与切换逻辑保留。目录读取期间或收尾时收到的变化仍会留待下一次刷新。 +每个打开的 `DirectoryNode` 在 Tab 生命周期内持有自己的目标监听;折叠关闭该节点及其不可见后代。祖先恢复期间的展开状态变化会更新存储和待恢复节点,后代恢复时遵循最新展开偏好。自动刷新默认开启;独立开关暂时隐藏,状态、文案、样式与切换逻辑保留。目录读取期间或收尾时收到的变化仍会留待下一次刷新。 ## 模型体验 @@ -60,7 +60,6 @@ kind: "package-reference" - **只有列目录。**没有搜索、产物过滤、拖拽、重命名、右键菜单或当前文件高亮。 -- **恢复期间的点击。** 父节点尚在恢复时,深层后代的折叠点击可能被忽略;该交互修复仍延期。 - **只有一个根。**树以会话工作目录为根;没有办法浏览到它之上,而 Host 本来也拒绝工作区根之外的路径。 diff --git a/packages/client/ui-sidebar-files/src/client/directory-node.ts b/packages/client/ui-sidebar-files/src/client/directory-node.ts index dc81bd237d..fd5ebd7410 100644 --- a/packages/client/ui-sidebar-files/src/client/directory-node.ts +++ b/packages/client/ui-sidebar-files/src/client/directory-node.ts @@ -47,6 +47,15 @@ export class DirectoryNode { return undefined } + /** + * Update the expansion preferences used by pending directory listings. + * @param expanded - latest expansion preferences from the store. + */ + setExpanded(expanded: readonly string[]): void { + this.restore = expanded + for (const child of this.children.values()) child.setExpanded(expanded) + } + /** * Open a direct child using this node's lifetime and automatic-refresh setting. * @param path - absolute direct-child directory path. diff --git a/packages/client/ui-sidebar-files/src/client/face.ts b/packages/client/ui-sidebar-files/src/client/face.ts index d086821d5f..f1a5c13d96 100644 --- a/packages/client/ui-sidebar-files/src/client/face.ts +++ b/packages/client/ui-sidebar-files/src/client/face.ts @@ -133,7 +133,7 @@ export interface FilesInjected { */ readonly load: (tabId: TabId, path: string, signal: AbortSignal) => void /** - * Open or collapse one directory, listing it the first time it opens. + * Open or collapse one directory, retaining intent during ancestor restoration. * @param tabId - the tab being drawn. * @param parentPath - the listed parent directory's exact tree key. * @param path - absolute directory path. @@ -205,10 +205,15 @@ export function filesFace( load: (tabId, path, signal) => { void load(tabId, path, signal) }, toggle(tabId, parentPath, path, expanded, signal) { if (signal.aborted) return - const parent = roots.get(tabId)?.find(parentPath) - if (parent === undefined) return - if (expanded.includes(path)) void parent.collapse(path) - else parent.expand(path, expanded) + 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) }, } 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 4477f3469f..29de3825cc 100644 --- a/packages/client/ui-sidebar-files/tests/face.client.spec.ts +++ b/packages/client/ui-sidebar-files/tests/face.client.spec.ts @@ -98,6 +98,53 @@ describe('filesFace', () => { expect(snapshot()!.levels[child]).toEqual({ kind: 'ready', level: current }) }) + it.each([false, true])('keeps deep expansion intent while restoring an ancestor (reopen: %s)', async (reopen) => { + const h = mount() + const refresh = vi.spyOn(DirectoryNode.prototype, 'refresh') + onTestFinished(() => { refresh.mockRestore() }) + const src = `${ROOT}/src` + const components = `${src}/components` + const nested = `${components}/nested` + const srcLevel: DirLevel = { entries: [{ name: 'components', type: 'directory' }], truncated: false } + 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.start(TAB, ROOT, h.controller.signal) + 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) { + toggle(parent, path) + await h.watches.ready(path) + await h.settle({ ok: true, value: level }) + } + toggle(ROOT, src) + await Promise.all(h.watches.opened.slice(1).map(stream => stream.released.promise)) + toggle(ROOT, src) + await h.watches.ready(src, 1) + expect(h.outstanding()).toEqual([src]) + expect(h.watches.opened.filter(stream => !stream.signal.aborted).map(stream => stream.path)).toEqual([ROOT, src]) + + toggle(components, nested) + expect(h.snapshot()!.expanded).not.toContain(nested) + if (reopen) { + toggle(components, nested) + expect(h.snapshot()!.expanded).toContain(nested) + } + await h.settle({ ok: true, value: srcLevel }) + await h.watches.ready(components, 1) + const reading = refresh.mock.results.at(-1) + if (reading?.type !== 'return') throw new Error('Expected the restored directory to start listing') + await h.settle({ ok: true, value: componentsLevel }) + await reading.value + expect(h.watches.opened.filter(stream => stream.path === nested)).toHaveLength(reopen ? 2 : 1) + if (reopen) { + await h.watches.ready(nested, 1) + await h.settle({ ok: true, value: empty }) + } + }) + 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) @@ -130,6 +177,8 @@ describe('filesFace', () => { 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) + expect(h.snapshot()).toBeUndefined() h.face.start(TAB, ROOT, h.controller.signal) const stream = await h.watches.ready(ROOT) await h.settle({ ok: true, value: LEVEL }) diff --git a/packages/fs/fs-local/tests/watch.spec.ts b/packages/fs/fs-local/tests/watch.spec.ts index dae8a3daa4..9aca3120eb 100644 --- a/packages/fs/fs-local/tests/watch.spec.ts +++ b/packages/fs/fs-local/tests/watch.spec.ts @@ -16,9 +16,9 @@ afterEach(() => { vi.restoreAllMocks() }) async function setup(kind: 'file' | 'directory' | 'missing' = 'file') { const watcher = new chokidar.FSWatcher() onTestFinished(() => watcher.close()) - const started = Promise.withResolvers() + const started = Promise.withResolvers() const watch = vi.mocked(chokidar.watch).mockReset().mockImplementation(() => { - started.resolve() + started.resolve(undefined) return watcher }) const ctx = new Context() @@ -39,9 +39,9 @@ describe('local filesystem watch', () => { const h = await setup(kind) const pending = h.fs.watch(h.target, h.changed, h.controller.signal) await h.started - expect(h.watch).toHaveBeenCalledExactlyOnceWith(dirname(h.path), { ignoreInitial: true, depth: 0, ignored: expect.any(Function) }) const ignored = h.watch.mock.calls[0]![1]!.ignored if (typeof ignored !== 'function') throw new Error('Expected a target filter') + expect(h.watch).toHaveBeenCalledExactlyOnceWith(dirname(h.path), { ignoreInitial: true, depth: 0, ignored }) expect(ignored(dirname(h.path))).toBe(false) expect(ignored(h.path)).toBe(false) expect(ignored(join(dirname(h.path), 'unrelated.txt'))).toBe(true) From 072ea695b9547aed7b0519e6575628508c68f3db Mon Sep 17 00:00:00 2001 From: imccyu Date: Sat, 19 Sep 2026 01:16:12 +0800 Subject: [PATCH 09/10] fix(chat): retain bottom follow during stream updates --- apps/web/tests/chat-scroll-contract.e2e.ts | 26 ++++++++++++++++++- packages/client/ui-chat/README.i18n.yaml | 4 +-- packages/client/ui-chat/README.md | 2 +- packages/client/ui-chat/README.zh.md | 2 +- .../src/client/chat/ChatView.module.css | 5 ++++ .../ui-chat/src/client/chat/ChatView.tsx | 2 +- .../src/client/chat/use-chat-reading.ts | 9 ++++--- .../ui-chat/tests/chat-view.client.spec.tsx | 21 +++++++++++++++ 8 files changed, 61 insertions(+), 10 deletions(-) diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts index af9ebc875f..17485b10bc 100644 --- a/apps/web/tests/chat-scroll-contract.e2e.ts +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -93,6 +93,7 @@ interface ScrollWorld { interface ScrollWorldOptions { readonly failureShot: string + readonly paceMs?: number readonly replay?: ReplayOverrideDoc readonly seeds: readonly { fixture: ChatScrollFixture; id: string }[] } @@ -116,6 +117,22 @@ function textStream(first: string, done: string, deltaCount: number): StreamChun ] } +/** Hold replay text after the initial deltas until browser setup releases it. */ +function holdTextAfter(world: ScrollWorld, initialDeltas: number): () => void { + const gate = Promise.withResolvers() + const dispose = world.scaffold.ctx.on('llm/stream', async function* (_options, next) { + let deltas = 0 + for await (const chunk of next()) { + if (chunk.type === 'text-delta' && deltas++ === initialDeltas) await gate.promise + yield chunk + } + }) + return () => { + gate.resolve(undefined) + dispose() + } +} + function toolStream(): StreamChunk[] { const command = [ `: > ${TOOL_READY_FILE}`, @@ -159,7 +176,7 @@ async function launchScrollWorld(options: ScrollWorldOptions): Promise { }) const settled = world.scaffold.whenTurnSettled(60_000) + const releaseText = holdTextAfter(world, 1) try { const composer = world.page.locator('[data-composer-input][contenteditable="true"]').last() await composer.fill(LIVE_TEXT_PROMPT) @@ -551,6 +569,7 @@ describe('web e2e: long Chat scroll contract', () => { await wheelTranscript(world.page, 420) const readerAnchor = await visibleFlowAnchor(world.page) const chunksAfterAnchor = world.assistantFrames.filter(frame => frame.type === 'chunk').length + releaseText() await expect.poll( () => world.assistantFrames.filter(frame => frame.type === 'chunk').length, { timeout: 10_000 }, @@ -561,6 +580,7 @@ describe('web e2e: long Chat scroll contract', () => { await nextPaint(world.page) await expectSameFlowTop(world.page, readerAnchor) } finally { + releaseText() releaseHistory() } @@ -904,6 +924,7 @@ describe('web e2e: long Chat scroll contract', () => { it.skipIf(MODE === 'record')('touch-style fling scrolling owns streaming bottom-follow without wheel input', async () => { await withScrollWorld({ failureShot: 'web-e2e-chat-scroll-fling-stream', + paceMs: 0, replay: [ replayEntry(toolStream()), replayEntry(textStream(LIVE_FLING_FIRST, LIVE_FLING_DONE, 240)), @@ -915,6 +936,7 @@ describe('web e2e: long Chat scroll contract', () => { await openSeed(world.page, INPUTS_FIXTURE, INPUTS_FIXTURE.markers.assistant(INPUTS_FIXTURE.turns)) const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true }) const settled = world.scaffold.whenTurnSettled(60_000) + const releaseText = holdTextAfter(world, 16) let released = false try { const composer = world.page.locator('[data-composer-input][contenteditable="true"]').last() @@ -952,12 +974,14 @@ describe('web e2e: long Chat scroll contract', () => { await expectBottom(world.page) await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0) const chunksAtRepin = world.assistantFrames.filter(frame => frame.type === 'chunk').length + releaseText() await expect.poll( () => world.assistantFrames.filter(frame => frame.type === 'chunk').length, { timeout: 15_000 }, ).toBeGreaterThan(chunksAtRepin + 5) await expectBottom(world.page) } finally { + releaseText() if (!released) await writeFile(releasePath, 'release\n').catch(() => {}) } diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index d8e7c6416f..044e530fbc 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md -README.md: 7f3d905b026edeb538de87dc5e54e0f05cd3c3bc -README.zh.md: 118fc29fdb4960dfc1606d7e9cd0a1ccaea9843d +README.md: 901c97e620a843d5d87964dbfe021c3e33e6fe3f +README.zh.md: 5500f0935c508b1ddce7113cf06003528fc1574b diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 7f3d905b02..901c97e620 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -74,7 +74,7 @@ Settings → General exposes a persisted, localized `Normal` / `Compact` convers ## Scroll ownership -Chat restores semantic anchors across history prepend and renderer remounts. Pinned scroll deliveries without reader movement update follow ownership immediately, before subsequent layout changes can invalidate their floor. Reader movement remains pending until the sampling interval or `scrollend`, even inside the follow threshold, so layout growth cannot erase small scroll gestures. Submitting transcript input or steering immediately restores tail following and clears an older pending reader sample. While the reader is pinned to the floor, `ResizeObserver` follows the new floor and selects the latest loaded Turn without reading row geometry. Once the reader moves away, flow-height changes preserve the top position and the reading-line geometry selects the active Turn. Turn-rail previews paint above sticky Markdown code-block banners, while the rail frame remains inside the transcript band above the composer. +Chat restores semantic anchors across history prepend and renderer remounts, with browser scroll anchoring disabled on its scrollport only while following the tail. Pinned scroll deliveries without reader movement, and reader input that reaches the exact floor, update follow ownership immediately, before subsequent layout changes can invalidate their floor. Other reader movement remains pending until the sampling interval or `scrollend`, even inside the follow threshold, so layout growth cannot erase small scroll gestures. Submitting transcript input or steering immediately restores tail following and clears an older pending reader sample. While the reader is pinned to the floor, `ResizeObserver` follows the new floor and selects the latest loaded Turn without reading row geometry. Once the reader moves away, flow-height changes preserve the top position and the reading-line geometry selects the active Turn. Turn-rail previews paint above sticky Markdown code-block banners, while the rail frame remains inside the transcript band above the composer. The turn rail mounts only visible marks, overscan, and the focused mark's neighbors. Its fixed pitch and observed viewport size determine scroll offsets without reading the DOM scroll extent. Initial placement waits for the body's restored active Turn and the rail's first usable viewport size. The ref controls activate a Turn or scroll the rail independently; the transcript itself remains fully mounted. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 118fc29fdb..5500f0935c 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -74,7 +74,7 @@ Assistant Markdown 中的 HTTP(S) 链接在普通点击时会在该类型已注 ## 滚动归属 -Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点。没有读者移动的贴底滚动事件会立即更新跟随归属,避免后续布局变化使其底部位置失效。读者移动即使位于跟随阈值内,也保持待处理直到采样周期或 `scrollend`,防止布局增长抵消小幅滚动操作。提交正文输入或 steering 时立即恢复跟随底部,并清除先前待处理的读者滚动采样。读者跟随底部时,`ResizeObserver` 追随新的底部,并且无需读取行几何就选中最后一个已加载轮次;读者离开底部后,高度变化会保持顶部位置,再由阅读线几何选择活跃轮次。轮次导航预览位于 Markdown 代码块粘性头栏上方,而导航外框始终处于 composer 上方的 transcript 区域内。 +Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且只在跟随底部时禁用自己滚动区域的浏览器自动锚定。没有读者移动的贴底滚动事件,以及确实到达最底部的读者输入,会立即更新跟随归属,避免后续布局变化使其底部位置失效。其他读者移动即使位于跟随阈值内,也保持待处理直到采样周期或 `scrollend`,防止布局增长抵消小幅滚动操作。提交正文输入或 steering 时立即恢复跟随底部,并清除先前待处理的读者滚动采样。读者跟随底部时,`ResizeObserver` 追随新的底部,并且无需读取行几何就选中最后一个已加载轮次;读者离开底部后,高度变化会保持顶部位置,再由阅读线几何选择活跃轮次。轮次导航预览位于 Markdown 代码块粘性头栏上方,而导航外框始终处于 composer 上方的 transcript 区域内。 轮次轨道只挂载可见刻度、预加载范围及键盘焦点刻度的相邻项。固定间距和观察到的视口尺寸决定滚动偏移,不读取 DOM 滚动总高度。初始定位等待正文恢复活跃轮次,以及轨道首次获得可用视口尺寸。ref 控制接口分别支持激活轮次和仅滚动轨道;正文自身仍完整挂载。 diff --git a/packages/client/ui-chat/src/client/chat/ChatView.module.css b/packages/client/ui-chat/src/client/chat/ChatView.module.css index 0b1487cefb..298271f364 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.module.css +++ b/packages/client/ui-chat/src/client/chat/ChatView.module.css @@ -20,6 +20,11 @@ container-type: inline-size; } +.root[data-chat-following-tail] .scroll, +:global([data-conversation-scroll]):has(.root[data-chat-following-tail]) { + overflow-anchor: none; +} + :global([data-conversation-scroll]) .root { flex: 0 0 auto; min-height: auto; diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 83facf3576..257dad64ab 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -207,7 +207,7 @@ export function ChatView({ }) return ( -
    +
    {scroll.initialized && ( { - if (this.state.followingTail && !scroll.movedByReader) { + if ((!scroll.movedByReader && this.state.followingTail) + || (scroll.movedByReader && scroll.metrics.top >= scroll.metrics.floor)) { this.followTail() - this.sampled?.({ position: null, movedByReader: false, followingTail: true }) + this.sampled?.({ position: null, movedByReader: scroll.movedByReader, followingTail: true }) return } this.sampleTimer ??= window.setTimeout(this.flushSample, SCROLL_SAMPLE_INTERVAL_MS) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index a25692a2d5..9db032dc27 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -2428,6 +2428,7 @@ describe('ChatView', () => { readerScroll(scroller, 100) // far from bottom const backButton = view.getByLabelText('回到底部') expect(backButton).toBeTruthy() + expect(view.container.querySelector('[data-chat-following-tail]')).toBeNull() // Streaming growth must NOT drag a scrolled-away reader down. act(() => { h.setChat({ partial: { turn: 1, step: 1, blocks: [{ kind: 'text', text: 'grow' }] } }) @@ -2435,6 +2436,7 @@ describe('ChatView', () => { expect(scroller.scrollTop).toBe(100) fireEvent.click(backButton) expect(scroller.scrollTop).toBe(700) + expect(view.container.querySelector('[data-chat-following-tail]')).not.toBeNull() // At the bottom again: follow re-arms and the button unmounts. expect(view.queryByLabelText('回到底部')).toBeNull() }) @@ -2553,6 +2555,25 @@ describe('ChatView', () => { expect(scroller.scrollTop).toBe(680) }) + it('keeps following when reader input reaches the floor before growth and scrollend', () => { + const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) + const view = render() + const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + const metrics = installScrollMetrics(scroller, 1_000, 300) + readerScroll(scroller, 100) + expect(view.getByLabelText('回到底部')).toBeTruthy() + + scroller.scrollTop = 700 + fireEvent.scroll(scroller) + metrics.setHeight(1_030) + act(() => { h.setSession({ running: true }) }) + fireEvent(scroller, new Event('scrollend')) + + expect(scroller.scrollTop).toBe(730) + expect(view.queryByLabelText('回到底部')).toBeNull() + expect(h.chatScroll.read()).toBeNull() + }) + it('follows a new submission immediately while an earlier scroll sample is pending', () => { const nodes = [user(1, 'q'), assistant(2, 'a')] const h = makeHarness({ nodes }) From 5c0b43f16e85056e0def58fd9c47e9250e409bdd Mon Sep 17 00:00:00 2001 From: imccyu Date: Sat, 19 Sep 2026 01:16:12 +0800 Subject: [PATCH 10/10] test(web): include connection diagnostics in SVG failures --- apps/web/tests/present-svg.e2e.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/present-svg.e2e.ts b/apps/web/tests/present-svg.e2e.ts index 5c21f9aab8..7c856df6f9 100644 --- a/apps/web/tests/present-svg.e2e.ts +++ b/apps/web/tests/present-svg.e2e.ts @@ -27,6 +27,7 @@ describe('web e2e: requested SVG is explicitly delivered', () => { let tripwire: ReturnType let cwd: string let replayRoot: string | undefined + const connectionDiagnostics: string[] = [] beforeAll(async () => { let replayOverride: string | undefined @@ -48,6 +49,18 @@ describe('web e2e: requested SVG is explicitly delivered', () => { viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE, timezoneId: 'Asia/Shanghai', }) tripwire = watchConsole(page) + page.on('console', (message) => { + if (message.text().startsWith('[connection]')) connectionDiagnostics.push(message.text()) + }) + page.on('websocket', (socket) => { + const openedAt = Date.now() + let received = 0 + socket.on('framereceived', () => { received++ }) + socket.on('socketerror', (error) => { connectionDiagnostics.push(error) }) + socket.on('close', () => { + connectionDiagnostics.push(`WebSocket closed after ${Date.now() - openedAt}ms and ${received} received frames`) + }) + }) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]') await connectFreshWorkspaceZh(page, scaffold.workspaceCwd) @@ -110,7 +123,7 @@ describe('web e2e: requested SVG is explicitly delivered', () => { // The scaffold workspace is not a git repository, so the changed-files card lists the written SVG from the write call alone. expect(await page.locator('[data-changed-files]').count()).toBe(1) expect(tripwire.pageErrors).toEqual([]) - expect(tripwire.warnings).toEqual([]) + expect(tripwire.warnings, connectionDiagnostics.join('\n')).toEqual([]) }) it.skipIf(MODE === 'record')('replays the delivered file and Chinese conversation', async () => {