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 index be6032bc1c..6f9d9e7ad7 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md -2026-09-17-sidebar-file-and-directory-auto-refresh.md: 6102508281f0bc8a7ddaaedb32ced71260ebf04f -2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md: fd9bcf41c131753384f415974d16d211e4dd1c60 +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/.agents/notes/proposed/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md b/.agents/notes/proposed/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md index 6102508281..930ae76213 100644 --- a/.agents/notes/proposed/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md +++ b/.agents/notes/proposed/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md @@ -8,9 +8,9 @@ English | [中文](2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md) The Sidebar's Document Preview and Files panels need to reflect current disk state. Changes come from Harness file tools, shell commands, user editors, and other processes; observing only Harness file operations is insufficient. -Both read paths exist, but neither has a complete automatic-refresh path. +The starting implementation has both read paths but no complete automatic-refresh path. -| Object | Existing behavior | Gap | +| Object | Starting behavior | Gap | |---|---|---| | File Resource | `ResourceProvider.open()` supplies initial metadata and later changes; `ResourceRegistry` owns subscriptions and retention; components read through `useResource` | File changes do not come from an OS watcher | | Document Preview | `TextPreview` compares Resource and content versions and provides reload for three loading modes | A change only prompts the user to reload | @@ -43,10 +43,10 @@ This change only connects notifications to existing reload/list operations; it d | `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` | | `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) | Replace `changes(scope, signal)` with an explicit target request. File requests retain Session file resolution and access rules; directory requests retain `list()`'s Workspace restriction | +| `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 | | `WorkspaceChangeFeed` | [workspace-files/src/changes.ts](../../../../packages/api/workspace-files/src/changes.ts) | Establish the target watch in the existing follow path and emit readiness and changes. Retain the queue and operation-observation input; filter by target before emission without rewriting dispatch | -| `ChangeFollower` | [workspace-files/src/changes.ts](../../../../packages/api/workspace-files/src/changes.ts) | Receive existing operation observations and new OS watch notifications; handle watch errors and completed closure | -| `WorkspaceWatchRequest`, `WorkspaceFileWatchFrame` | [workspace-files/src/types.ts](../../../../packages/api/workspace-files/src/types.ts) | Add target path and file/directory intent; reuse `ready`/`change` frames, interpreted by each consumer's request, without a separate directory frame format | +| `ChangeFollower` | [workspace-files/src/changes.ts](../../../../packages/api/workspace-files/src/changes.ts) | Receive operation observations and OS notifications; handle watch errors, close independently of generator pulls, and await watcher closure | +| `WorkspaceFileWatchFrame` | [workspace-files/src/types.ts](../../../../packages/api/workspace-files/src/types.ts) | Reuse `ready`/`change` frames for a path-only request, without a request kind or separate directory frame format | ### Client and Sidebar @@ -58,8 +58,8 @@ This change only connects notifications to existing reload/list operations; it d | `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 | -| `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 before reading 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. Check cancellation of previous work, release of old objects, and retention of existing view preferences during refresh | +| `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 | | `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 | @@ -70,16 +70,16 @@ This change only connects notifications to existing reload/list operations; it d ### One stream per explicit target -The old `changes(sessionId)` cannot tell the Host which file or directory needs an OS watcher. Directory support calls for two explicit request variants, rather than inferring intent from the current `stat`. +Each request names one target path. The Host determines its current type through `stat`; the Client supplies no file/directory kind. ```text -changes(sessionId, { kind: 'file', path }, signal) -changes(sessionId, { kind: 'directory', path }, signal) +changes(sessionId, path, signal) +changes(scope, path, signal) ``` The Host method still receives resolved Session context through `WorkspaceFileScope`. The Client supplies a Session id; it cannot forge the Host's working directory or permissions. -`kind` expresses watch intent and does not change when the target temporarily disappears. A deleted file remains a watched file location; a deleted directory subscription does not become a file subscription. +The Host checks Workspace containment whenever the current target is a directory, including when an absent or ordinary-file target becomes a directory. Ordinary files outside the Workspace remain readable and watchable through the selected FS. | Notification | Contents | Consumer action | |---|---|---| @@ -97,8 +97,8 @@ 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, without widening browsing authority. -- File watching covers in-place writes, replacement by a temporary file, deletion, and recreation at the same path. Chokidar may internally observe the necessary parent directory, but the public watch still targets the specified file. +- 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. - 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. @@ -110,7 +110,7 @@ Files.openResource / file.link → Sidebar.openTab → TabDomain → Resource.pin → ResourceRegistry → createFileResourceProvider.open - → changes(sessionId, { kind: 'file', path }) + → changes(sessionId, path) → WorkspaceFiles → FsTarget → WorkspaceChangeFeed → FileSystem.watch → LocalFileSystem → Chokidar @@ -121,19 +121,19 @@ Editor / Shell / file tool → disk → WorkspaceChangeFeed → FileSystem.stat → Remote.change → Client.stat → ResourceSnapshot - → TextPreview.useResource + → ResourceGroup → TextPreview → reloadPages / reloadAll / prepareRenderer → DocumentContent / renderer revision → renderer ``` -Existing preview content reads may run in parallel with metadata initialization; watching does not require reordering all loading logic. The preview retains its existing content-version comparison. ResourceGroup only forwards member metadata changes, without separate pre-read and post-read version reconciliation. +Preview content reads may run in parallel with metadata initialization. ResourceGroup uses each member's first metadata as a baseline and forwards only later changes, without first-read version reconciliation. | Loading mode | Existing refresh entry | Result | |---|---|---| | `text-pages` | `reloadPages()` | Retire the old content generation and reread text pages without mixing versions | | `bytes-complete` | `reloadAll()` | Reread complete bytes for PDF, HTML, image, and other byte consumers | -| `renderer` | `prepareRenderer(..., reload = true)` | Increment `loadRevision`; the renderer cancels its previous load and requests again, as for Office conversion | +| `renderer` | `prepareRenderer(..., reload = true)` | Increment `loadRevision`; the renderer cancels its previous load and requests again. Office failures end loading through `failed()`, allowing later file changes to retry | Ordinary renderers need neither `watch()` nor a new reload interface. Changes to existing `DocumentContent` text, bytes, and revisions already drive reloading. @@ -147,20 +147,20 @@ theme.css Resource ──┼→ ResourceGroup → resourcesDirty → autoRefresh page.js Resource ───┘ ``` -- `addResource(address)` joins a dependency before reading. Existing members do not resubscribe, and no content-read version is passed or retained. +- `addResource(address)` joins a dependency after `readRelated` returns its Host-resolved `absolutePath`. Existing members do not resubscribe, and no content-read version is passed or retained. - `setResources(addresses)` commits the parsed dependency list. The preview owner always retains the root and releases other members absent from the new list. -- CSS/JS references use the existing HTML relative-file reader and the HTML's Session, without guessing local paths from the browser page URL. +- CSS/JS references use the HTML's Session and Host path resolution. A failed read also retains a string `error.details.path` supplied by the Host; without that path, the Client does not guess one. - A CSS or JS change reloads the complete HTML and creates new iframe content even when the HTML source itself is unchanged. -- Missing dependencies and recovery also change members. Parsing failures retain already discovered dependencies so recovery can trigger another load. -- A new member's initial metadata also invalidates the preview, accepting a possible extra refresh during initial dependency loading. Repeated metadata versions do not notify again, without first-read version reconciliation. +- Missing dependencies with a Host-reported path remain members. Parsing failures retain already discovered dependencies so their later changes can trigger another load. +- A new member's first metadata establishes a baseline without invalidating the preview. Only subsequent metadata changes notify; initial reads have no additional version reconciliation. - Group changes set only `resourcesDirty` to `true`. Refresh clears it; another change during reading sets it again. No notification counter or separate read generation is added. - Closing the preview Tab releases the whole group. Disabling automatic refresh only stops automatic rereads; the group continues observing and recording stale state. -This version covers direct stylesheet links and classic JS scripts already supported by the interactive HTML packer. It does not add CSS `@import`, ES module graph, or runtime network dependency discovery. Static safe preview reads no related files and therefore observes only the root. +This version covers direct stylesheet links and classic JS scripts already supported by the interactive HTML packer. It does not add CSS `@import`, ES module graph, or runtime network dependency discovery. A newly opened static safe preview reads no related files and observes only the root. Switching interactive HTML to static preview releases dependency watches and retains the root Resource. ## Automatic-refresh controls -Document Preview and Files each retain their existing reload button. The separate automatic-refresh icon control is temporarily hidden; its state, toggle logic, copy, and styles remain. New Tabs still default to `autoRefresh: true`. Hiding the control neither removes the feature nor makes it impossible to disable. +Document Preview and Files each retain their existing reload button. The separate automatic-refresh icon control is temporarily hidden; its state, toggle logic, copy, and styles remain. New Tabs still default to `autoRefresh: true`. Hiding the control does not change its state or stop observation. | State or action | Behavior | |---|---| @@ -178,7 +178,7 @@ Pause and play use existing icons; manual reload retains its circular arrow. No Files.open → FilesBody → Session.cwd + Tab → filesFace.start → DirectoryNode.open - → changes(sessionId, { kind: 'directory', path }) + → changes(sessionId, path) → Host.watch(depth: 0) → ready → DirectoryNode → list(sessionId, path) → createFilesStore.levels[path] @@ -208,16 +208,16 @@ The Files runtime is itself an on-demand directory tree. It does not first const | Node responsibility | Behavior | |---|---| -| Directory identity | Hold this directory's path and Session/Tab ownership; its listing contains only direct entries | +| Directory identity | Hold this directory's path and Session/Tab ownership; filesystem roots and Windows drive roots are valid roots, and listings contain only direct entries | | Directory watch | One open node holds one target stream; notifications invalidate only this node's listing | -| Directory read | Retain the active list generation and pending-reread state; publish results through store actions | +| Directory read | Retain the active list generation and pending rereads through read completion; publish results through store actions | | Open children | Hold child `DirectoryNode` objects by direct-child path; unexpanded directories remain listing entries without active nodes | | Expand | A directory-row action creates or reuses its child, which subscribes and reads; restoration opens only directories still present in the listing | | Collapse | Remove and close the active child; recursively close its descendants and release its watch and requests | | 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. If the user collapses a descendant during that read, remove its pending restoration too, so an older request cannot reopen it. +`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. ```text workspace/ watch @@ -239,7 +239,7 @@ The parent's watch still detects deletion, renaming, or replacement of a collaps | First open | Subscribe to the file Resource and read contents | Open the root node, which subscribes and lists | | Expand child | No effect | The parent opens the child; the child subscribes and reads direct entries, progressively restoring still-present descendants from preferences | | Collapse child | Open file previews keep their watches | Release that directory and its hidden descendants; expansion preferences and displayed cache may remain | -| Switch Tab or unmount panel | The Tab pin retains file metadata; an unmounted body does not reread, and refreshes on return according to version and toggle state | Retain expanded nodes and watchers for the Tab lifetime; do not introduce another panel-visibility lifetime | +| Switch Tab or unmount panel | The Tab pin retains file metadata; an unmounted body does not reread, and refreshes on return according to pending Resource changes and toggle state | Retain expanded nodes and watchers for the Tab lifetime; do not introduce another panel-visibility lifetime | | Manual refresh | Reuse the current preview reload without rebuilding the Resource system | Recursively refresh open children from the root, without entering collapsed directories or rebuilding all watchers | | Last holder closes | Resource abort ends the file stream; the Host awaits watcher closure | Tab closure ends every directory stream, cancels reads, and forgets Tab state | | Connection recovers | Stat again after the new stream's ready frame, discovering changes even without event replay | List every still-watched directory after its new stream's ready frame | @@ -268,7 +268,7 @@ Files watches follow Tab lifetimes. Collapse closes the corresponding subtree; T ### Deletion, renaming, and failure -- Deleting a file retains existing preview content with an unavailable notice; recreating its original path resumes reading. Renaming means disappearance at the old path and appearance at a new one, without rewriting the preview address from inode identity. +- Deleting a file retains existing preview content with an unavailable notice; recreating its original path while its parent remains resumes reading. Renaming means disappearance at the old path and appearance at a new one, without rewriting the preview address from inode identity. - Deleting or renaming a directory refreshes its parent, removes the old row, and releases the old subtree. A new directory appears under its new name without transferring old-path expansion preferences. - Automatic and manual refresh invoke the same existing reload, retaining its clearing, loading, and error presentation. No old-byte or old-iframe retention branch is added. Directory refresh retains the existing listing cache to avoid rebuilding active subtrees. - When a backend cannot watch, the Host converts initialization failure into a `workspace-file/watch-unsupported` `RemoteError`. The Client ends observation while continuing ordinary reads and manual refresh, without polling. External SSH filesystem changes do not automatically update previews. @@ -279,11 +279,11 @@ Observation, reads, and presentation need a few explicit ordering rules, not dis - Establish the watch before sending ready, then obtain initial stat/list. Do not discard changes arriving during a read; reread when necessary to converge on current state. - Events mean that a target may have changed, not exact replay of a user operation. Duplicate Chokidar events and consecutive writes may coalesce; files deduplicate versions, and directories coalesce pending refreshes. -- Each directory has at most one active list. Further invalidation during that request marks a pending reread, which runs after completion rather than building an arbitrary queue of refresh jobs. +- Each directory has at most one active list. Further invalidation during that request or its completion marks a pending reread, which runs afterwards rather than building an arbitrary queue of refresh jobs. - File refresh reuses read generations and renderer revisions. Consecutive notifications must not let old requests overwrite new content or endlessly reload an already handled version. - Background directory refresh retains the displayed listing without a display-unused `refreshing` flag. Only a directory without displayable entries enters `loading`, avoiding subtree flicker and repeated watcher reconstruction. -- After collapse, deletion, or Tab closure, late cancelled reads cannot recreate state or restore watchers. Stream release awaits actual Host watcher closure. -- Temporary disappearance must not permanently end observation. Ordinary file changes and watcher failures remain distinct; the same-path watch covers atomic saves and delete/recreate. +- 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. ## Alternatives considered @@ -305,7 +305,7 @@ Observation, reads, and presentation need a few explicit ordering rules, not dis Implementation can proceed in the following order, retaining the same explicit-target observation model throughout. 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: target requests, file metadata frames, directory invalidation frames, Client stream consumption, and rereads after reconnect. +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. @@ -313,24 +313,26 @@ Implementation can proceed in the following order, retaining the same explicit-t | Acceptance scenario | Expected result | |---|---| | 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 | +| 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 | +| First member metadata | Establish a baseline without an extra reload or first-read version reconciliation | | Only CSS/JS changes, with unchanged HTML root | The group reloads the complete HTML | | HTML removes a dependency reference | Updating membership releases the unreferenced Resource | | Disable automatic refresh, modify, then reload or reenable | Content remains while paused and catches up on manual action or reenabling; the controls remain independent | -| Root's direct entries are created, deleted, or renamed | The Files root level updates automatically | +| Root's direct entries are created, deleted, or renamed | The Files root level updates automatically, including when the workspace is a filesystem or Windows drive root | | 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 | | Reopen, return to Files, or reconnect | Obtain current listings without depending on replay of missed events | -| Delete/recreate or replace a directory with a file | Update presentation and release invalid subtrees; recover when the target returns | -| Rapid writes, duplicate notifications, changes during reads | Converge on current state with finite rereads, without endless refresh | -| Close Tab, unload plugin, cancel initialization | Close all owned watches; late notifications and reads no longer write state | +| 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. ## Risks +- 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. - 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. 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 index fd9bcf41c1..9052d6a5f7 100644 --- 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 @@ -8,9 +8,9 @@ Status: proposed Sidebar 的 Document Preview 和 Files 面板都需要反映磁盘上的最新状态。变化来源包括 Harness 文件工具、Shell 命令、用户编辑器,以及其他进程;只监听 Harness 自己的文件操作不够。 -目前两条读取路径都已存在,但缺少完整的自动刷新链路。 +起始实现已有两条读取路径,但缺少完整的自动刷新链路。 -| 对象 | 现有行为 | 缺口 | +| 对象 | 起点行为 | 缺口 | |---|---|---| | 文件 Resource | `ResourceProvider.open()` 提供初始元数据和后续变化,`ResourceRegistry` 管理订阅及持有期,组件通过 `useResource` 读取 | 文件变化源不是系统级 watch | | Document Preview | `TextPreview` 已比较 Resource 版本与正文版本,并提供三种加载模式的 reload | 检测到变化后仅提示用户点击重新加载 | @@ -43,10 +43,10 @@ Sidebar 的 Document Preview 和 Files 面板都需要反映磁盘上的最新 | `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) | `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, signal)` 改为目标明确的监听请求;文件请求沿用 Session 文件解析与访问规则,目录请求沿用 `list()` 的 Workspace 限制 | +| `WorkspaceFiles` | [workspace-files/src/index.ts](../../../../packages/api/workspace-files/src/index.ts) | 使用仅携带目标路径的 `changes(scope, path, signal)`;Host stat 决定是否执行目录包含检查,目标类型变化后也检查,普通文件沿用文件读取权限 | | `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` 帧,消费方根据自己的请求解释变化,目录不需要再增加一套帧格式 | +| `ChangeFollower` | [workspace-files/src/changes.ts](../../../../packages/api/workspace-files/src/changes.ts) | 接收操作观察与 OS 通知,处理监听错误;关闭独立于生成器拉取进度,并等待 watcher 关闭完成 | +| `WorkspaceFileWatchFrame` | [workspace-files/src/types.ts](../../../../packages/api/workspace-files/src/types.ts) | 仅传路径的请求沿用 `ready`/`change` 帧,不增加请求 kind 或独立目录帧格式 | ### Client 与 Sidebar @@ -58,8 +58,8 @@ Sidebar 的 Document Preview 和 Files 面板都需要反映磁盘上的最新 | `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。检查刷新时取消旧任务、释放旧对象及保留现有视图偏好 | +| `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) | 表示一个已打开的目录,拥有本目录变化流、读取状态和已打开子节点;负责局部刷新、展开、递归关闭,以及向子节点传递自动刷新状态 | | `createFilesStore`、`FilesTabState`、`LevelState` | [Files store](../../../../packages/client/ui-sidebar-files/src/client/store.ts) | 保留展开、滚动和目录显示数据;区分首次加载与已有内容上的刷新,避免每次后台重读都把子树卸载;不保存 watcher 或 AbortController | @@ -70,16 +70,16 @@ Sidebar 的 Document Preview 和 Files 面板都需要反映磁盘上的最新 ### 一条流对应一个明确目标 -原先 `changes(sessionId)` 无法告诉 Host 哪个文件或目录需要系统监听。新增目录需求后,建议把请求显式分成以下两种,而不是让后端根据当前 `stat` 猜测。 +每个请求指定一个目标路径;Host 通过 `stat` 确定当前实际类型,Client 不传文件/目录 kind。 ```text -changes(sessionId, { kind: 'file', path }, signal) -changes(sessionId, { kind: 'directory', path }, signal) +changes(sessionId, path, signal) +changes(scope, path, signal) ``` Host 方法仍通过 `WorkspaceFileScope` 接收解析后的 Session 上下文。Client 传 Session id,不能自行伪造 Host 的工作目录或权限。 -`kind` 是监听意图,不随目标暂时消失而变化。例如,文件删除后仍然监听这个文件位置,目录删除后也不能把原目录订阅解释成普通文件订阅。 +当前目标是目录时,Host 就检查 Workspace 包含关系;原本缺失或为普通文件的目标变成目录后也执行检查。Workspace 外的普通文件仍可通过所选 FS 读取与监听。 | 通知 | 内容 | 消费方动作 | |---|---|---| @@ -97,8 +97,8 @@ Resource 发布的路径、版本和大小来自同一次 stat。先沿用现有 - `FileSystem.watch()` 接受本提供方解析出的目标;本机或远端路径的含义由 FS 提供方负责。 - 文件监听保持文件读取的访问规则。现有预览允许通过所选 FS 读取部分 Workspace 外的路径,不能把目录树的范围限制顺手强加给这些文件。 -- 目录监听保持 `list()` 的 Workspace 范围及路径检查,不因新增 watch 扩大目录浏览权限。 -- 文件监听需要覆盖原地写入、临时文件替换、删除和同一路径重建。Chokidar 可在内部观察必要的父目录,但对外仍是指定文件的监听。 +- 目录监听保持 `list()` 的 Workspace 范围及路径检查,目标类型变化后也执行检查,不因新增 watch 扩大目录浏览权限。 +- 父目录保持存在时,文件监听覆盖原地写入、临时文件替换、删除和同一路径重建。Chokidar 可在内部观察必要的父目录,但对外仍是指定文件的监听。 - 目录监听只关注该目录自身和直接子项,不递归打开尚未展开的子目录;直接子项的创建、删除、重命名、类型或可见元数据变化都使这一层失效。 - 本地实现采用系统事件模式;若需要调整写入稳定时间、事件合并窗口等部署参数,应由拥有它们的配置声明,不散落在组件里。 - 系统 watch 只驱动 UI 刷新,不把外部磁盘变化伪装成某个 agent 已读取文件的 `fs/observed`,不改变文件编辑的已观察版本策略。 @@ -110,7 +110,7 @@ Files.openResource / file.link → Sidebar.openTab → TabDomain → Resource.pin → ResourceRegistry → createFileResourceProvider.open - → changes(sessionId, { kind: 'file', path }) + → changes(sessionId, path) → WorkspaceFiles → FsTarget → WorkspaceChangeFeed → FileSystem.watch → LocalFileSystem → Chokidar @@ -121,19 +121,19 @@ Editor / Shell / file tool → disk → WorkspaceChangeFeed → FileSystem.stat → Remote.change → Client.stat → ResourceSnapshot - → TextPreview.useResource + → ResourceGroup → TextPreview → reloadPages / reloadAll / prepareRenderer → DocumentContent / renderer revision → renderer ``` -现有预览正文可以与元数据初始化并行读取,不必为了 watch 重排所有加载逻辑。预览保留原有正文版本比较;ResourceGroup 只转发成员元数据变化,不另做读前、读后版本对账。 +预览正文可以与元数据初始化并行读取。ResourceGroup 以各成员首次元数据为基线,只转发后续变化,不做首读版本对账。 | 加载模式 | 已有刷新入口 | 刷新结果 | |---|---|---| | `text-pages` | `reloadPages()` | 废弃旧正文代次,重新读取文本页;不能混合不同版本的页 | | `bytes-complete` | `reloadAll()` | 重新读取完整字节,PDF、HTML、图片等消费新字节 | -| `renderer` | `prepareRenderer(..., reload = true)` | 增加 `loadRevision`,渲染器取消旧加载并重新请求,例如 Office 转换 | +| `renderer` | `prepareRenderer(..., reload = true)` | 增加 `loadRevision`,渲染器取消旧加载并重新请求;Office 失败通过 `failed()` 结束加载,后续文件变化可重试 | 普通渲染器不需要添加 `watch()` 或新的 reload 接口。已有 `DocumentContent` 中的文本、字节和 revision 变化已经能驱动重新加载。 @@ -147,20 +147,20 @@ theme.css Resource ──┼→ ResourceGroup → resourcesDirty → autoRefresh page.js Resource ───┘ ``` -- `addResource(address)` 在读取前将依赖加入 group;已有成员不重复订阅,不传递或保存正文读取版本。 +- `addResource(address)` 在 `readRelated` 返回 Host 解析的 `absolutePath` 后将依赖加入 group;已有成员不重复订阅,不传递或保存正文读取版本。 - `setResources(addresses)` 提交本次解析出的依赖列表;预览拥有者始终保留根文件,其余未再出现的成员释放订阅。 -- CSS/JS 的引用由原来的 HTML 相对文件读取逻辑解析,继续使用 HTML 所属 Session,不按浏览器页面地址猜测本机路径。 +- CSS/JS 引用使用 HTML 所属 Session,由 Host 解析路径。读取失败时也保留 Host 提供的字符串 `error.details.path`;没有该路径时,Client 不自行猜测。 - HTML 本身没有改变,但 CSS 或 JS 改变时,也重新加载整个 HTML,生成新的 iframe 内容。 -- 依赖缺失和恢复同样属于成员变化;解析失败时仍保留已经发现的依赖,后续恢复可以再次触发加载。 -- 新成员的首次元数据到达也触发失效,接受首次加载依赖时可能额外刷新;后续相同元数据版本不重复触发,不增加首读版本对账逻辑。 +- 缺失依赖有 Host 报告的路径时仍保留为成员;解析失败时保留已经发现的依赖,后续成员变化可以再次触发加载。 +- 新成员的首次元数据仅建立基线,不使预览失效;只有后续元数据变化才通知,首次读取不另做版本对账。 - group 变化只把 `resourcesDirty` 置为 `true`;开始刷新时清除,读取期间再次变化则重新置位。不累计次数,也不增加另一套读取代次。 - 关闭预览 Tab 时释放整个 group。关闭自动刷新仅停止自动重读,group 继续观察并记录陈旧状态。 -此版覆盖交互式 HTML 打包器现有支持的直接 stylesheet 链接和普通 JS 脚本,不顺手增加 CSS `@import`、ES module 图或运行时网络请求的依赖发现。静态安全预览不读取关联文件,因此只观察根文件。 +此版覆盖交互式 HTML 打包器现有支持的直接 stylesheet 链接和普通 JS 脚本,不顺手增加 CSS `@import`、ES module 图或运行时网络请求的依赖发现。新打开的静态安全预览不读取关联文件,只观察根文件。从交互式 HTML 切换到静态预览时释放依赖监听,只保留根 Resource。 ## 自动刷新开关 -Document Preview 和 Files 各自保留原 reload 按钮。独立自动刷新开关的 icon 入口暂时隐藏,状态、切换逻辑、文案和样式保留;新 Tab 仍默认 `autoRefresh: true`。隐藏只影响显示,不把功能删除或强制改为不可关闭。 +Document Preview 和 Files 各自保留原 reload 按钮。独立自动刷新开关的 icon 入口暂时隐藏,状态、切换逻辑、文案和样式保留;新 Tab 仍默认 `autoRefresh: true`。隐藏不改变开关状态,也不停止观察。 | 状态或操作 | 行为 | |---|---| @@ -178,7 +178,7 @@ Document Preview 和 Files 各自保留原 reload 按钮。独立自动刷新开 Files.open → FilesBody → Session.cwd + Tab → filesFace.start → DirectoryNode.open - → changes(sessionId, { kind: 'directory', path }) + → changes(sessionId, path) → Host.watch(depth: 0) → ready → DirectoryNode → list(sessionId, path) → createFilesStore.levels[path] @@ -208,16 +208,16 @@ Files 的运行时对象本身就是一棵按需打开的目录树,不先建 | 节点职责 | 具体行为 | |---|---| -| 本目录身份 | 持有该目录路径和所属 Session/Tab,目录列表只是它的直接子项 | +| 本目录身份 | 持有该目录路径和所属 Session/Tab,文件系统根与 Windows 盘根均可作为树根,列表只包含直接子项 | | 本目录监听 | 一个打开的节点持有一个目标变化流;收到通知只使本节点列表失效 | -| 本目录读取 | 持有有效 list 请求的代次与待补读状态,结果通过存储 actions 发布 | +| 本目录读取 | 持有有效 list 请求的代次,待补读状态持续到读取收尾后,结果通过存储 actions 发布 | | 已打开子目录 | 按直接子目录路径持有子 `DirectoryNode`;未展开目录只是列表中的目录项,不创建活动子节点 | | 展开 | 目录行触发展开时创建或复用对应子节点,再由子节点建立自己的监听与读取;恢复展开偏好时只打开列表中仍存在的目录 | | 折叠 | 关闭并移除对应活动子节点;子节点递归关闭后代,然后释放自己的监听和请求 | | 列表更新 | 保留仍存在的已打开子节点,关闭已删除、重命名或变为文件的分支;不重新打开所有节点 | | 整树关闭 | 对根节点执行关闭,自然递归释放整棵活动子树,不另遍历全局监听注册表 | -`createFilesStore.expanded` 可以继续保存用户的展开偏好,但不是另一份监听注册表。父目录关闭后,即使存储保留了后代展开偏好,也不能继续持有这些后代的活动节点。重新打开时先读取父目录,再对仍存在且需要恢复的子目录逐层建立节点;补读期间用户再次折叠后代时,同步删除待恢复项,旧请求不能把它重新打开。 +`createFilesStore.expanded` 可以继续保存用户的展开偏好,但不是另一份监听注册表。父目录关闭后,即使存储保留了后代展开偏好,也不能继续持有这些后代的活动节点。重新打开时先读取父目录,再对仍存在且需要恢复的子目录逐层建立节点;父节点尚在恢复时,深层后代的折叠点击可能被忽略,该交互修复仍延期。 ```text workspace/ watch @@ -239,7 +239,7 @@ workspace/ watch | 首次打开 | 打开文件 Resource 监听并读取正文 | 打开根节点,由根节点建立监听并读取列表 | | 展开子目录 | 无影响 | 父节点打开对应子节点;子节点监听并读取直接子项,按偏好逐层恢复仍存在的后代 | | 折叠子目录 | 已打开的文件预览继续监听 | 释放该目录及其当前不可见后代的监听;可保留展开偏好和显示缓存 | -| 切走 Tab 或面板卸载 | Tab pin 继续持有文件元数据流;正文组件不在时不主动重读,切回来按版本与开关状态补刷新 | 此版沿用 Tab 生命周期,保留已展开目录节点和监听;不另外引入面板显隐生命周期 | +| 切走 Tab 或面板卸载 | Tab pin 继续持有文件元数据流;正文组件不在时不主动重读,切回来按待处理 Resource 变化与开关状态补刷新 | 此版沿用 Tab 生命周期,保留已展开目录节点和监听;不另外引入面板显隐生命周期 | | 手动刷新 | 重用当前预览 reload,不重建整套 Resource | 从根节点递归刷新已打开子节点,不进入折叠目录,也不重建全部 watcher | | 关闭最后一个持有者 | Resource abort,文件流结束,Host 等待 watcher 关闭 | Tab 关闭时终止全部目录流、取消读取并删除 Tab 状态 | | 连接恢复 | 新流 ready 后重新 stat,即使断线期间没有可回放事件也能发现变化 | 新流 ready 后重新 list 所有仍需观察的目录 | @@ -268,7 +268,7 @@ Files 监听跟随 Tab 生命周期;折叠关闭对应子树,关闭 Tab 则 ### 删除、重命名和失败 -- 文件删除后,已有预览内容保留并显示不可用提示;原路径重建后自动恢复读取。重命名视为旧路径消失和新路径出现,不根据 inode 自动改写预览地址。 +- 文件删除后,已有预览内容保留并显示不可用提示;父目录保持存在时,原路径重建后自动恢复读取。重命名视为旧路径消失和新路径出现,不根据 inode 自动改写预览地址。 - 目录删除或重命名后,父目录刷新移除旧行,并释放旧子树监听。新目录按新名称显示,不擅自转移旧路径的展开偏好。 - 自动刷新和手动刷新调用同一套已有 reload,沿用清空旧结果、加载及错误展示行为;不增加保留旧字节或旧 iframe 的分支。目录刷新保留现有列表缓存,避免重建活动子树。 - 后端不支持 watch 时,Host 把初始化异常转成 `workspace-file/watch-unsupported` 的 `RemoteError`;客户端结束监听,继续普通读取与手动刷新,不增加轮询。SSH 的外部磁盘变化不会自动更新预览。 @@ -279,11 +279,11 @@ Files 监听跟随 Tab 生命周期;折叠关闭对应子树,关闭 Tab 则 - 先建立 watch,再发送 ready,然后取得初始 stat/list。读取过程中到达的变化不能丢弃;必要时再读一次,使最终结果收敛到最新状态。 - 事件表示“目标可能变化”,不是一次用户操作的精确回放。Chokidar 的重复通知和连续写入可以合并;文件按版本去重,目录按待刷新状态合并。 -- 同一目录最多有一次有效 list 在途;在途期间再次失效,只记录需要补读,完成后读取最新状态,不累计任意长度的刷新任务队列。 +- 同一目录最多有一次有效 list 在途;在途或读取收尾期间再次失效,只记录需要补读,随后读取最新状态,不累计任意长度的刷新任务队列。 - 文件自动刷新复用读取代次和 renderer revision。连续通知不能让旧请求覆盖新内容,也不能因同一已处理版本而无限 reload。 - 目录后台刷新保留当前列表,不另存没有显示用途的 `refreshing` 标记;只有没有可显示列表时才进入 `loading`,避免子树闪烁和监听反复重建。 -- 目录折叠、删除或 Tab 关闭后,已取消读取的晚到结果不得重新创建状态或恢复监听。释放流时要等待 Host watcher 真正关闭。 -- 文件或目录短暂消失不能让监听永远结束;正常文件变化与监听自身失败必须分开处理。原子保存与删除重建由同一路径的监听覆盖。 +- 目录折叠、删除或 Tab 关闭后,已取消读取的晚到结果不得重新创建状态或恢复监听。即使生成器停在已产出的帧上,取消也会关闭 Host watcher;流与插件拆除等待关闭完成。 +- 父目录保持存在时,文件短暂消失不结束监听;正常文件变化与监听自身失败分别处理。同路径文件监听覆盖原子保存与删除重建。 - 原有文件读取并非事务快照,stat 与正文读取之间仍可能发生写入;本方案保证后续通知触发刷新,不声称每次读取都获得原子快照。 ## 不采用的替代方案 @@ -305,7 +305,7 @@ Files 监听跟随 Tab 生命周期;折叠关闭对应子树,关闭 Tab 则 实施可按下面的顺序推进,每一步都保留同一套目标明确的监听模型。 1. FS 定义和本地提供方:文件与非递归目录 watch、就绪、错误和异步释放;沙箱本地提供方继承。 -2. Workspace Remote:目标请求、文件元数据帧、目录失效帧,以及 Client 流消费和重连后的重新读取。 +2. Workspace Remote:仅传路径的请求、基于 stat 的目录包含检查、文件元数据帧、目录失效帧,以及重连后的重新读取。 3. Document Preview:ResourceGroup、HTML 的 CSS/JS 成员、自动刷新开关,以及三种现有 reload 路径。 4. Files:现有 face 加按需 `DirectoryNode` 树、自动刷新开关、节点局部刷新、折叠递归释放和重新展开逐层补读。 5. 同步受影响 README、JSDoc、Remote 生成声明和用户输出验证;不修改会话事件日志、持久化格式或模型输入。 @@ -313,24 +313,26 @@ Files 监听跟随 Tab 生命周期;折叠关闭对应子树,关闭 Tab 则 | 验收场景 | 预期结果 | |---|---| | 外部原地写入、原子替换、Shell 写入 | 打开的文件预览自动更新,不依赖 `fs/observed` | -| 文本、完整字节、自主加载渲染器 | 三种模式都刷新,旧请求不能覆盖新结果 | +| 文本、完整字节、自主加载渲染器 | 三种模式都刷新,旧请求不能覆盖新结果,Office 失败后允许后续文件变化重试 | +| 成员首次元数据 | 仅建立基线,不额外 reload,也不做首读版本对账 | | HTML 根文件不变,仅 CSS/JS 改变 | group 触发整个 HTML 重新加载 | | HTML 删除一条依赖引用 | 本次 group 更新后释放不再引用的成员 | | 关闭自动刷新后修改,再手动刷新或重新开启 | 暂停期间保持内容,手动操作或重新开启后补刷新;按钮职责独立 | -| 根目录直接子项新增、删除、重命名 | Files 根层自动更新 | +| 根目录直接子项新增、删除、重命名 | Files 根层自动更新,工作区为文件系统根或 Windows 盘根时也适用 | | 已展开子目录变化 | 只刷新对应目录层,保留其他层与展开偏好 | | 未展开的深层目录 | 不创建深层 watcher,不递归遍历 | | 折叠父目录 | 释放它和不可见后代的监听;文件预览若仍打开则独立继续监听 | | 重新展开、切回 Files、连接恢复 | 重新读取当前列表,不依赖错过事件的回放 | -| 删除后重建、目录替换为文件 | 正确更新显示并释放失效子树监听,目标重建后能恢复 | -| 快速写入、重复通知、读取中再修改 | 有限次补读并收敛到最新状态,不无限刷新 | -| 关闭 Tab、卸载插件、取消初始化 | 所属监听完全关闭;晚到通知和读取不再写入状态 | +| 文件删除后重建,或子目录替换为文件 | 父目录保持存在时,文件监听在同一路径恢复;目录列表释放失效子树 | +| 快速写入、重复通知、读取中或收尾时再修改 | 有限次补读并收敛到最新状态,不无限刷新 | +| 关闭 Tab、卸载插件、取消初始化 | 即使不再拉取下一帧,所属监听也完全关闭;晚到通知和读取不再写入状态 | | 不支持监听的提供方、越界目录请求 | 明确报告支持状态或访问错误,不观察错误执行世界或扩大目录权限 | 验证应包含可控事件源下的就绪与取消测试、真实临时目录上的本地 watch 集成测试,以及从真实 Web 组合进入的文件预览和目录树动线。真实文件测试等待 watcher 就绪后再操作,通过观察到的状态结束,不靠固定 sleep 猜测事件时机。用户可见自动刷新应更新对应的无密钥回放或拥有者本地预期输出,不制作 GIF;这里列出验收范围,执行结果记录在 PR 中。 ## 风险与后续范围 +- Linux 上父目录删除并重建后的自动监听恢复仍延期,本提案不承诺该场景;父目录保持存在时,同路径文件重建仍受支持。 - OS 文件事件不是持久消息,断线或进程重启可能丢通知,因此每一代流就绪后都要重新取得当前状态。 - 多个已打开文件和展开目录会增加 watcher 与流数量;第一版用按需生命周期控制数量,不提前加入复杂的跨消费者去重服务。 - 自动刷新可能打断阅读,尤其是 HTML 内部交互和 Office 转换。保留视图偏好、合并连续变化,但不承诺保存任意嵌入文档的运行状态。 diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 9f7694f864..e2fad15435 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -387,6 +387,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + await expect.poll(() => html.locator('#parent-result').innerText()).toBe('parent-blocked') expect(await html.locator('#parent-result').getAttribute('data-error')).toBe('SecurityError') expect(await page.locator('html').getAttribute('data-document-preview-escape')).toBeNull() + expect(previewNetworkRequests).toBe(1) const beforeStyleSave = await iframe.getAttribute('src') await writeFile(join(cwd, 'local.css'), '#local-result { color: rgb(56, 34, 12); }') await expect.poll(() => html.locator('#local-result').evaluate(node => getComputedStyle(node).color)).toBe('rgb(56, 34, 12)') diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 847b03b2f8..ba1ff6a95c 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 225e84e8b2ef0ba20f168b002472496fb5b388c0 -config-catalog.zh.md: 997de0d412f1328595abbe5a6fe7f9feb2a193a8 +config-catalog.md: c439dcf876922bc01d0ac4872f2101246ee53ed1 +config-catalog.zh.md: e3d76addd80421759db5c6ec896d9837a05532b4 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 225e84e8b2..c439dcf876 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -300,7 +300,7 @@ export interface Config { } ``` -Source: [`packages/api/workspace-files/src/index.ts:70`](../packages/api/workspace-files/src/index.ts) +Source: [`packages/api/workspace-files/src/index.ts:69`](../packages/api/workspace-files/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 997de0d412..e3d76addd8 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -302,7 +302,7 @@ export interface Config { } ``` -来源:[`packages/api/workspace-files/src/index.ts:70`](../packages/api/workspace-files/src/index.ts) +来源:[`packages/api/workspace-files/src/index.ts:69`](../packages/api/workspace-files/src/index.ts) diff --git a/docs/subsystems/filesystem.i18n.yaml b/docs/subsystems/filesystem.i18n.yaml index 13346bcc29..06d2e4aa51 100644 --- a/docs/subsystems/filesystem.i18n.yaml +++ b/docs/subsystems/filesystem.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 docs/subsystems/filesystem.md -filesystem.md: 72e3113cad4c59312165effcd3ffa73bec5f7e9e -filesystem.zh.md: 6ba14c99859ffb22e318fd6627d1b1e61bdcf0ce +filesystem.md: a4ed5460ac333b57520b241f31f5c809e574f858 +filesystem.zh.md: 6f06c140c1a9a2c685aebd6339cd1ee97d4303bb diff --git a/docs/subsystems/filesystem.md b/docs/subsystems/filesystem.md index 72e3113cad..a4ed5460ac 100644 --- a/docs/subsystems/filesystem.md +++ b/docs/subsystems/filesystem.md @@ -295,7 +295,7 @@ Abstract filesystem provider. Targets must preserve identity across aliases; rea /** * 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/docs/subsystems/filesystem.zh.md b/docs/subsystems/filesystem.zh.md index 6ba14c9985..6f06c140c1 100644 --- a/docs/subsystems/filesystem.zh.md +++ b/docs/subsystems/filesystem.zh.md @@ -295,7 +295,7 @@ Abstract filesystem provider. Targets must preserve identity across aliases; rea /** * 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/docs/subsystems/sidebar-right.i18n.yaml b/docs/subsystems/sidebar-right.i18n.yaml index 2d2a10c137..a1164e5891 100644 --- a/docs/subsystems/sidebar-right.i18n.yaml +++ b/docs/subsystems/sidebar-right.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 docs/subsystems/sidebar-right.md -sidebar-right.md: 663139313edb3b2341874c05030faa8c26998944 -sidebar-right.zh.md: bdab35cb88bb62c33e447b67592858c0c92f7a4c +sidebar-right.md: 9ad6bd63ed63c5c59dbe29cd1229a6412ad22de2 +sidebar-right.zh.md: d0a967f400ea80ab696e24e1b4f46d7d8664eff8 diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md index 663139313e..9ad6bd63ed 100644 --- a/docs/subsystems/sidebar-right.md +++ b/docs/subsystems/sidebar-right.md @@ -111,11 +111,11 @@ The `text` tab is the shared Document Preview owner. Its [root registration](../ The [registry](../../packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts) records unique `id`, `extensions`, localized `title()`, `loading`, optional `priority`, and optional `wrap`. Case-insensitive suffix matching ranks `extension` (the default) before `builtin`, then longer suffixes before shorter ones, then registration order. Unlike tab-kind replacement, the registry keeps all implementations available; the toolbar lists matching alternatives and remembers the selection per tab. Unknown extensions use plain text. Suffixes declared in `binaryExtensions` suppress the plain-text alternative, as described in the [package README](../../packages/client/ui-sidebar-documentpreview/README.md#what-it-registers). `loading` is `text-pages`, `bytes-complete`, or `renderer`; `wrap` advertises support for the shared source-wrap control. -[`DocumentPreviewProps`](../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) derives from `PropsRuntime<'sidebar.right.tab.document'>`. The owner supplies the original `resourceAddress`, `content`, and current `wrap`: text content is `{ kind: 'text', text, pages: [{ offset, text, lines }], eof }`, with cumulative `text`; complete bytes are `{ kind: 'bytes', data }`, with `Uint8Array` data. These transient buffers are borrowed read-only and must not enter durable layout or Session JSON. PDF copies the bytes before Worker transfer, preserving the owner's buffer. The child receives the same framework-bound `useTabInfo` and the global metadata-only `useResource`. The parent reads through ordinary inject callbacks to `remote.workspaceFiles.read`/`readAll` and owns page appends, per-tab refresh, and loading status. HTML's own inject callback uses `readRelated`; Host code resolves paths. Markdown and code retain one incremental renderer across appends and settle at EOF; HTML and PDF receive complete bytes. +[`DocumentPreviewProps`](../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) derives from `PropsRuntime<'sidebar.right.tab.document'>`. The owner supplies the original `resourceAddress`, `content`, current `wrap`, and `addResource` / `setResources` dependency callbacks: text content is `{ kind: 'text', text, pages: [{ offset, text, lines }], eof }`, with cumulative `text`; complete bytes are `{ kind: 'bytes', data }`, with `Uint8Array` data. These transient buffers are borrowed read-only and must not enter durable layout or Session JSON. PDF copies the bytes before Worker transfer, preserving the owner's buffer. The child receives the same framework-bound `useTabInfo` and the global metadata-only `useResource`. The parent reads through ordinary inject callbacks to `remote.workspaceFiles.read`/`readAll` and owns page appends, per-tab refresh, and loading status. HTML's own inject callback uses `readRelated`; Host code resolves paths. Markdown and code retain one incremental renderer across appends and settle at EOF; HTML and PDF receive complete bytes. -Preview records its loaded version and the version observed when a read starts. Refresh rereads only that tab, without changing shared metadata or another tab's content. Reads are non-transactional; versions are opaque equality tokens, not ordered timestamps ([resource observation and Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md)). +Preview records its loaded version and the version observed when a read starts. Automatic refresh defaults to enabled; initial member metadata establishes a baseline, and later changes reload the tab. Refresh rereads only that tab, without changing shared metadata or another tab's content. Reads are non-transactional; versions are opaque equality tokens, not ordered timestamps ([resource observation and Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md)). -A renderer that owns loading receives `{ kind: 'renderer', revision, loaded, reload }` instead of file bytes. The body loads through its own injected callbacks, cancels on revision changes and unmount, and reports its displayed source version through `loaded(version)`. The parent ignores stale reports and retains the shared reload and source-change controls. Office uses this mode to request [Host-rendered PDFs](office-to-pdf.md); its own store and bounded cache retain converted bytes, while its body renders a nested PDF view. `OfficeFontAction` renders the missing-font warning before reload through `sidebar.right.tab.document.action`. The [package README](../../packages/client/ui-sidebar-documentpreview/README.md#what-it-registers) defines the loading lifecycle. +A renderer that owns loading receives `{ kind: 'renderer', revision, loaded, failed, reload }` instead of file bytes. The body loads through its own injected callbacks, cancels on revision changes and unmount, and reports its displayed source version through `loaded(version)`. A failed load calls `failed()` so a later file change can retry. The parent ignores stale reports and retains the shared reload and source-change controls. Office uses this mode to request [Host-rendered PDFs](office-to-pdf.md); its own store and bounded cache retain converted bytes, while its body renders a nested PDF view. `OfficeFontAction` renders the missing-font warning before reload through `sidebar.right.tab.document.action`. The [package README](../../packages/client/ui-sidebar-documentpreview/README.md#what-it-registers) defines the loading lifecycle. ## Resource model @@ -127,7 +127,7 @@ A resource stays open while it has a holder — a subscribed `useResource` or a ## Workspace Files -The Host `ctx.workspaceFiles` service and generated `workspaceFiles` Remote namespace read files allowed by the Session filesystem backend: `stat(path)` returns `{ absolutePath, version, bytes? }`; `read(path, { offset?, limit? })` returns one page of lines (`offset` 1-based, `limit` capped by the configured page size) as `{ …stat, offset, text, eof }`; `readBytes(path, { offset?, length? })` returns one raw byte window (`offset` 0-based, `length` capped by the configured byte limit) as base64 `{ …stat, offset, data, eof }` with no text decoding. `list(path)` remains inside the workspace root and returns a directory's direct children (`name`, `type: 'file' | 'directory' | 'other'`, `size?`) cut to the configured cap with `truncated` set. `changes()` likewise remains workspace-scoped and yields `{ kind: 'ready' }` once subscribed, then `{ kind: 'change', change }` frames whose payload is `{ absolutePath, version }` or `{ absolutePath, absent: true }` ([README](../../packages/api/workspace-files/README.md#use-this-package)). File operations reject final symlinks and enforce transfer caps; `read` additionally requires UTF-8 text. Failures use `workspace-file/*` codes ([failures](../../packages/api/workspace-files/README.md)). +The Host `ctx.workspaceFiles` service and generated `workspaceFiles` Remote namespace read files allowed by the Session filesystem backend: `stat(path)` returns `{ absolutePath, version, bytes? }`; `read(path, { offset?, limit? })` returns one page of lines (`offset` 1-based, `limit` capped by the configured page size) as `{ …stat, offset, text, eof }`; `readBytes(path, { offset?, length? })` returns one raw byte window (`offset` 0-based, `length` capped by the configured byte limit) as base64 `{ …stat, offset, data, eof }` with no text decoding. `list(path)` remains inside the workspace root and returns a directory's direct children (`name`, `type: 'file' | 'directory' | 'other'`, `size?`) cut to the configured cap with `truncated` set. `changes(path)` watches one target; the Host determines its type and confines directories to the workspace. It yields `{ kind: 'ready' }` once subscribed, then `{ kind: 'change', change }` frames whose payload is `{ absolutePath, version }` or `{ absolutePath, absent: true }` ([README](../../packages/api/workspace-files/README.md#use-this-package)). File reads reject final symlinks and enforce transfer caps; `read` additionally requires UTF-8 text. Failures use `workspace-file/*` codes ([failures](../../packages/api/workspace-files/README.md)). [`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md) registers the `file` provider, with `ResourceProtocolMap.file` directly naming `WorkspaceFileStat`. A Session address carries the authorizing Session and a relative or absolute path, passed unchanged to the Host for resolution. The provider waits for Host `ready` before stat and filters changes by `stat.absolutePath`. Bare `absolute` addresses have no authorizing Session and fail with `workspace-file/unknown-workspace`, without borrowing current or Tab Session. Any UI, including Global components, shares the observation for the same complete address. Preview's ordinary Remote callbacks use the Session in that address; Host `readAll` and `readRelated` remain, and Preview's `rpc.ts` decodes byte results. @@ -135,7 +135,7 @@ The Host `ctx.workspaceFiles` service and generated `workspaceFiles` Remote name - **`guide`** — `builtin`, opened as `openTab('guide')`. A muted compass sits above one capsule per contributed `guide` entry, in `order`; short lists show registered descriptions, and every missing icon uses the shipped placeholder. Picking a capsule opens the contributing type as a page in the guide tab's place. A pane holds at most one guide tab, and the strip's add control appears only while its pane has none. A new pane receives the registered default page: the sole guide entry directly, or the guide when the entry count is not one ([guide](../../packages/client/ui-sidebar-right/README.md#the-guide)). - **`text`** — `fallback`, `dsh-resource://file/**`, claiming Session addresses only. Document Preview observes metadata through `useResource<'file'>`, loads content through Remote callbacks, and owns renderer selection, the toolbar, per-tab refresh, scroll, and source navigation; unknown extensions render as plain text ([README](../../packages/client/ui-sidebar-documentpreview/README.md)). -- **`files`** — `builtin`, opened as `openTab('files')`. The workspace directory tree, listed lazily through `list`, opening a file with `tab.actions.openResource(fileAddressFor(sessionId, root, path))` into its own pane ([README](../../packages/client/ui-sidebar-files/README.md)). +- **`files`** — `builtin`, opened as `openTab('files')`. The workspace directory tree, listed lazily through `list` and watched by expanded directory, opening a file with `tab.actions.openResource(fileAddressFor(sessionId, root, path))` into its own pane ([README](../../packages/client/ui-sidebar-files/README.md)). - **`browser`** — multi-instance `builtin`, opened as `openTab('browser', { params: { url? } })`. Assistant Markdown delegates HTTP(S) links to this page type. It accepts public and loopback HTTP(S) targets under the default sandbox, rejects local files in favor of Document Preview, and uses application-known iframe history ([README](../../packages/client/ui-sidebar-browser/README.md)). - **`subagentchat`** — `builtin`, `dsh-resource://subagentchat/session/?parent=&mode=`. An explicitly addressed subagent Conversation retained by the resource provider and rendered through the shared Conversation Factory ([README](../../packages/client/ui-subagent/README.md)). diff --git a/docs/subsystems/sidebar-right.zh.md b/docs/subsystems/sidebar-right.zh.md index bdab35cb88..d0a967f400 100644 --- a/docs/subsystems/sidebar-right.zh.md +++ b/docs/subsystems/sidebar-right.zh.md @@ -111,11 +111,11 @@ Sidebar 声明四个扩展 slot;其文档 tab 另行声明下表中的 keyed [注册表](../../packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts)记录唯一的 `id`、`extensions`、本地化 `title()`、`loading`,以及可选的 `priority` 和 `wrap`。后缀匹配不区分大小写,先排 `extension`(缺省值)、再排 `builtin`,随后比较后缀长度(长者优先)与注册顺序。与 tab kind 替换不同,注册表保留所有实现;工具栏列出匹配的候选,按 tab 记住选择。未知扩展名使用纯文本。`binaryExtensions` 声明的后缀不提供纯文本备选,见[包 README](../../packages/client/ui-sidebar-documentpreview/README.zh.md#what-it-registers)。`loading` 为 `text-pages`、`bytes-complete` 或 `renderer`;`wrap` 声明是否支持共享的源码换行控件。 -[`DocumentPreviewProps`](../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) 派生自 `PropsRuntime<'sidebar.right.tab.document'>`。owner 提供原始 `resourceAddress`、`content` 与当前 `wrap`:文本内容为 `{ kind: 'text', text, pages: [{ offset, text, lines }], eof }`,其中 `text` 为累积文本;完整字节为 `{ kind: 'bytes', data }`,其中 `data` 为 `Uint8Array`。这些瞬时缓冲区按只读方式借用,不得进入持久布局或 Session JSON。PDF 在转移到 Worker 前复制字节,以保留 owner 的缓冲区。子组件收到同一个框架绑定的 `useTabInfo`,以及全局共享、仅提供元数据的 `useResource`。父组件通过普通 inject 回调调用 `remote.workspaceFiles.read`/`readAll`,拥有追加分页、逐 tab 刷新与加载状态。HTML 自己的 inject 回调使用 `readRelated`;路径由 Host 代码解析。Markdown 和代码在追加期间保留同一个增量渲染器,到 EOF 完成最终解析;HTML 和 PDF 接收完整字节。 +[`DocumentPreviewProps`](../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) 派生自 `PropsRuntime<'sidebar.right.tab.document'>`。owner 提供原始 `resourceAddress`、`content`、当前 `wrap`,以及声明依赖的 `addResource` / `setResources` 回调:文本内容为 `{ kind: 'text', text, pages: [{ offset, text, lines }], eof }`,其中 `text` 为累积文本;完整字节为 `{ kind: 'bytes', data }`,其中 `data` 为 `Uint8Array`。这些瞬时缓冲区按只读方式借用,不得进入持久布局或 Session JSON。PDF 在转移到 Worker 前复制字节,以保留 owner 的缓冲区。子组件收到同一个框架绑定的 `useTabInfo`,以及全局共享、仅提供元数据的 `useResource`。父组件通过普通 inject 回调调用 `remote.workspaceFiles.read`/`readAll`,拥有追加分页、逐 tab 刷新与加载状态。HTML 自己的 inject 回调使用 `readRelated`;路径由 Host 代码解析。Markdown 和代码在追加期间保留同一个增量渲染器,到 EOF 完成最终解析;HTML 和 PDF 接收完整字节。 -Preview 记录已载入版本和读取开始时的观察版本。刷新只重读当前 tab,不改变共享元数据或其他 tab 的内容。读取不具备事务性;版本是不透明的相等性令牌,不是可排序的时间戳([资源观察与 Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md))。 +Preview 记录已载入版本和读取开始时的观察版本。自动刷新默认开启;成员首次元数据只建立基线,后续变化才重新加载 tab。刷新只重读当前 tab,不改变共享元数据或其他 tab 的内容。读取不具备事务性;版本是不透明的相等性令牌,不是可排序的时间戳([资源观察与 Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md))。 -自行加载的渲染器接收 `{ kind: 'renderer', revision, loaded, reload }`,而不是文件字节。正文通过自己的注入回调加载,在 revision 变化和卸载时取消请求,并通过 `loaded(version)` 报告已展示的源版本。父组件忽略过期报告,保留共享的重新加载与源文件变更控件。Office 使用此模式请求 [Host 渲染的 PDF](office-to-pdf.zh.md);自己的 store 和有界缓存保留转换字节,正文渲染嵌套 PDF 视图,`OfficeFontAction` 通过 `sidebar.right.tab.document.action` 在刷新按钮前显示缺失字体警告。[包 README](../../packages/client/ui-sidebar-documentpreview/README.zh.md#what-it-registers)定义加载生命周期。 +自行加载的渲染器接收 `{ kind: 'renderer', revision, loaded, failed, reload }`,而不是文件字节。正文通过自己的注入回调加载,在 revision 变化和卸载时取消请求,并通过 `loaded(version)` 报告已展示的源版本。加载失败时调用 `failed()`,使后续文件变化能够触发重试。父组件忽略过期报告,保留共享的重新加载与源文件变更控件。Office 使用此模式请求 [Host 渲染的 PDF](office-to-pdf.zh.md);自己的 store 和有界缓存保留转换字节,正文渲染嵌套 PDF 视图,`OfficeFontAction` 通过 `sidebar.right.tab.document.action` 在刷新按钮前显示缺失字体警告。[包 README](../../packages/client/ui-sidebar-documentpreview/README.zh.md#what-it-registers)定义加载生命周期。 ## 资源模型 @@ -127,7 +127,7 @@ Preview 记录已载入版本和读取开始时的观察版本。刷新只重读 ## Workspace Files -Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名空间读取 Session 文件系统后端允许的文件:`stat(path)` 返回 `{ absolutePath, version, bytes? }`;`read(path, { offset?, limit? })` 返回一页行(`offset` 1 起,`limit` 受配置页长限制),形如 `{ …stat, offset, text, eof }`;`readBytes(path, { offset?, length? })` 返回一个原始字节窗口(`offset` 0 起,`length` 受配置字节上限限制),形如 base64 的 `{ …stat, offset, data, eof }`、不做文本解码。`list(path)` 仍限定在工作区根内,返回目录的直接子项(`name`、`type: 'file' | 'directory' | 'other'`、`size?`),按配置上限截断并置 `truncated`。`changes()` 同样限定于工作区,订阅就绪后产出 `{ kind: 'ready' }`,随后产出 `{ kind: 'change', change }` 帧,其载荷为 `{ absolutePath, version }` 或 `{ absolutePath, absent: true }`([README](../../packages/api/workspace-files/README.zh.md#use-this-package))。文件操作拒绝末端符号链接并执行传输上限;`read` 还要求 UTF-8 文本。失败使用 `workspace-file/*` 错误码([失败](../../packages/api/workspace-files/README.zh.md))。 +Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名空间读取 Session 文件系统后端允许的文件:`stat(path)` 返回 `{ absolutePath, version, bytes? }`;`read(path, { offset?, limit? })` 返回一页行(`offset` 1 起,`limit` 受配置页长限制),形如 `{ …stat, offset, text, eof }`;`readBytes(path, { offset?, length? })` 返回一个原始字节窗口(`offset` 0 起,`length` 受配置字节上限限制),形如 base64 的 `{ …stat, offset, data, eof }`、不做文本解码。`list(path)` 仍限定在工作区根内,返回目录的直接子项(`name`、`type: 'file' | 'directory' | 'other'`、`size?`),按配置上限截断并置 `truncated`。`changes(path)` 监听一个目标,由 Host 判断目标类型并将目录限制在工作区内;订阅就绪后产出 `{ kind: 'ready' }`,随后产出 `{ kind: 'change', change }` 帧,其载荷为 `{ absolutePath, version }` 或 `{ absolutePath, absent: true }`([README](../../packages/api/workspace-files/README.zh.md#use-this-package))。文件读取拒绝末端符号链接并执行传输上限;`read` 还要求 UTF-8 文本。失败使用 `workspace-file/*` 错误码([失败](../../packages/api/workspace-files/README.zh.md))。 [`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md) 注册 `file` 提供方,`ResourceProtocolMap.file` 直接是 `WorkspaceFileStat`。Session 地址携带授权 Session 与相对或绝对路径,Host 原样接收并解析。提供方在 stat 前等待 Host 的 `ready` 帧,并按 `stat.absolutePath` 过滤变更。裸 `absolute` 地址没有授权 Session,以 `workspace-file/unknown-workspace` 失败,不借用当前或 Tab Session。任何 UI(包括 Global)访问同一完整地址都共享观察。Preview 的普通 Remote 回调使用地址中的 Session;Host `readAll` 和 `readRelated` 保留,字节结果由 Preview 的 `rpc.ts` 解码。 @@ -135,7 +135,7 @@ Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名 - **`guide`**——`builtin`,以 `openTab('guide')` 打开。一枚弱化的罗盘位于各类型按 `order` 贡献的入口胶囊上方;入口较少时显示已注册的描述,未提供图标的入口统一使用内置占位符。点选胶囊即在引导 tab 的位置把贡献它的类型作为页面打开。每个 pane 最多一个引导 tab,tab 条的新增控件只在本 pane 没有引导时出现。新 pane 使用已注册的默认页:只有一个引导入口时直接使用该入口,否则使用引导页([引导](../../packages/client/ui-sidebar-right/README.zh.md#the-guide))。 - **`text`**——`fallback`,`dsh-resource://file/**`,只认领 Session 地址。Document Preview 通过 `useResource<'file'>` 观察元数据,经 Remote 回调加载内容,并拥有渲染器选择、工具栏、逐 tab 刷新、滚动与源码定位;未知扩展名按纯文本渲染([README](../../packages/client/ui-sidebar-documentpreview/README.zh.md))。 -- **`files`**——`builtin`,以 `openTab('files')` 打开。工作区目录树,经 `list` 懒加载,用 `tab.actions.openResource(fileAddressFor(sessionId, root, path))` 在自己所在 pane 打开文件([README](../../packages/client/ui-sidebar-files/README.zh.md))。 +- **`files`**——`builtin`,以 `openTab('files')` 打开。工作区目录树,经 `list` 懒加载,按展开目录建立监听,用 `tab.actions.openResource(fileAddressFor(sessionId, root, path))` 在自己所在 pane 打开文件([README](../../packages/client/ui-sidebar-files/README.zh.md))。 - **`browser`**——可多开的 `builtin`,以 `openTab('browser', { params: { url? } })` 打开。Assistant Markdown 会把 HTTP(S) 链接委托给该页面类型。它在默认 sandbox 下接受公共与 loopback HTTP(S) 目标,本地文件改用 Document Preview,并使用应用已知的 iframe history([README](../../packages/client/ui-sidebar-browser/README.zh.md))。 - **`subagentchat`**——`builtin`,`dsh-resource://subagentchat/session/?parent=&mode=`。资源提供方保留一个显式寻址的 subagent Conversation,并通过共享 Conversation Factory 渲染([README](../../packages/client/ui-subagent/README.zh.md))。 diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index cc8a2cbc75..36c4adcd78 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.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 docs/subsystems/workspace.md -workspace.md: 310b568f6c969919b8a54a32f763bd7bbb5fcf63 -workspace.zh.md: f85c1f5dbda677c581be794f2aeaf26798bb8d33 +workspace.md: 8ecf54a2860ab5d442c4856f6efd03900de1ce9b +workspace.zh.md: dd4aed5f271aff0256115b03c85ec8b50ad0c502 diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index 310b568f6c..8ecf54a286 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -418,12 +418,12 @@ Host Remote file reads and workspace directory observations over the composed fi * 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. * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. - * @param request - target path and file or directory watch intent. + * @param path - target path; the Host determines its type and confines directories to the workspace. * @param signal - 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. */ -@Remote({ mode: 'stream' }) changes( workspaceFileScope: WorkspaceFileScope, request: WorkspaceWatchRequest, signal: AbortSignal, ): AsyncIterable +@Remote({ mode: 'stream' }) changes(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): AsyncIterable ``` Source: [`packages/api/workspace-files/src/index.ts`](../../packages/api/workspace-files/src/index.ts) diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index f85c1f5dbd..dd4aed5f27 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -418,12 +418,12 @@ Host Remote file reads and workspace directory observations over the composed fi * 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. * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. - * @param request - target path and file or directory watch intent. + * @param path - target path; the Host determines its type and confines directories to the workspace. * @param signal - 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. */ -@Remote({ mode: 'stream' }) changes( workspaceFileScope: WorkspaceFileScope, request: WorkspaceWatchRequest, signal: AbortSignal, ): AsyncIterable +@Remote({ mode: 'stream' }) changes(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): AsyncIterable ``` Source: [`packages/api/workspace-files/src/index.ts`](../../packages/api/workspace-files/src/index.ts) diff --git a/packages/api/workspace-files/README.i18n.yaml b/packages/api/workspace-files/README.i18n.yaml index 6e47274021..7611258318 100644 --- a/packages/api/workspace-files/README.i18n.yaml +++ b/packages/api/workspace-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/api/workspace-files/README.md -README.md: d06ffa0de94c54723dd37cda521ca87ed7775657 -README.zh.md: ae1836a93207950b917641bfd7bedb91b0c2c4f5 +README.md: 6e5025b8f5543d810fa951acf5baa32a3c060494 +README.zh.md: c913145966cc982e10c7a533274941c6c52b8b55 diff --git a/packages/api/workspace-files/README.md b/packages/api/workspace-files/README.md index d06ffa0de9..6e5025b8f5 100644 --- a/packages/api/workspace-files/README.md +++ b/packages/api/workspace-files/README.md @@ -25,7 +25,7 @@ Use this package to preview files readable through a Session's filesystem from t ## Use this package -Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, the Session store, and the Typert Gateway; the bundle does so right after the Session Controller. Every method takes the Session identity on the wire, so a Client calls `remote.workspaceFiles.read(sessionId, path, range, signal)`, `stat(sessionId, path, signal)`, `readBytes(sessionId, path, range, signal)`, `list(sessionId, path, signal)`, or `changes(sessionId, request, signal)` and never names a root itself. The Host reads a live Session header or uses persistence `stat` for a cold Session; it does not activate an Agent, read the event body, or borrow a parent Session's root. Session persistence is optional for live reads, but without it a cold Session cannot resolve and the Gateway returns `gateway/lookup-not-found`. +Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, the Session store, and the Typert Gateway; the bundle does so right after the Session Controller. Every method takes the Session identity on the wire, so a Client calls `remote.workspaceFiles.read(sessionId, path, range, signal)`, `stat(sessionId, path, signal)`, `readBytes(sessionId, path, range, signal)`, `list(sessionId, path, signal)`, or `changes(sessionId, path, signal)` and never names a root itself. The Host reads a live Session header or uses persistence `stat` for a cold Session; it does not activate an Agent, read the event body, or borrow a parent Session's root. Session persistence is optional for live reads, but without it a cold Session cannot resolve and the Gateway returns `gateway/lookup-not-found`. | Method | Returns | Purpose | |---|---|---| @@ -35,7 +35,7 @@ Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, the Session store, and | `readAll(path)` | `WorkspaceFileBytes` with `offset: 0`, `eof: true` | Complete raw bytes under `maxFileBytes`; oversized files fail instead of being truncated | | `readRelated(path, relativePath)` | `WorkspaceFileBytes` | Complete bytes of a file resolved from the base file's directory on the Host | | `list(path)` | `WorkspaceDirectoryListing { path, entries, truncated }` | Direct children of one directory | -| `changes({ kind, path })` | stream of `WorkspaceFileWatchFrame` | Subscription readiness, then invalidations of one file or a directory's direct entries | +| `changes(path)` | stream of `WorkspaceFileWatchFrame` | Subscription readiness, then invalidations of one file or a directory's direct entries | ### Addressing and paths @@ -55,7 +55,7 @@ File reads and directory listing first use `lstat` to reject a missing path, a f ### The change feed -`changes` is a `stream` Remote accepting `WorkspaceWatchRequest`: `kind` is `file` or `directory`, and `path` names one target. A generation registers its observation queue, resolves the target, and starts `fs.watch` before yielding `{ kind: 'ready' }`. It then yields `{ kind: 'change', change }`, where `change` is current stat metadata `{ absolutePath, version }` or `{ absolutePath, absent: true }`. Local providers use Chokidar; directories watch only direct entries. Matching `fs/observed` emissions also invalidate the target. The generation queues changes during setup and ends on cancellation or plugin disposal, awaiting watcher closure. Missing targets remain watchable for recreation. +`changes` is a `stream` Remote accepting only a target path. The Host derives the target's type from `stat` and requires workspace containment whenever that type is a directory, including after a type change. A generation registers its observation queue, resolves the target, and starts `fs.watch` before yielding `{ kind: 'ready' }`. It then yields `{ kind: 'change', change }`, where `change` is current stat metadata `{ absolutePath, version }` or `{ absolutePath, absent: true }`. Local providers use Chokidar; directories watch only direct entries. Matching `fs/observed` emissions also invalidate the target. The generation queues changes during setup. Cancellation closes the watcher independently of consumer pulls, and generation or plugin teardown awaits closure. Missing files remain watchable for same-path recreation while their parent directory remains. ### Configuration @@ -139,11 +139,12 @@ None; this package neither assembles nor sends a provider request. - **Provider watch support** — unsupported backends, including SSH, report `watch-unsupported`; ordinary reads and manual refresh remain available, without polling for external changes. +- **Linux parent-directory recreation** — automatic watch recovery after a parent directory is deleted and recreated is deferred; see [fs-local](../../fs/fs-local/README.md). - **Directory scope only** — directory listing and watches stay inside the Session workspace; file reads and watches use the filesystem backend's read authority. - **No total line count** — a page reports `eof`, not how many lines follow; a consumer that needs the total pages to the end or estimates from `bytes`. - **One giant line has no page** — a single line above `maxBytes` fails `too-large` at every window that includes it, because pages are cut by lines, not bytes. - **Reads are not transactional** — result metadata comes from stat before content is read; a concurrent write can make the reported version and returned contents differ. -- **Unbounded generation queue** — a `changes` generation buffers every contained observation until its consumer pulls; a stalled consumer grows Host memory for the life of the stream. +- **Unbounded generation queue** — a `changes` generation buffers operation observations and target invalidations until its consumer pulls; a stalled consumer grows Host memory for the life of the stream. - **`maxEntries` bounds the answer, not the listing** — `list` asks `ctx.fs.listDir` for every child and cuts the array afterwards, so a directory far above the cap still costs the Host the whole listing (on `fs-local`, one stat per child); bounding that work needs a limit on the filesystem seam's `listDir`. - **Dead feeds retain metadata** — after the Host ends `changes` or the stream fails terminally, open values retain their last state until reopened. diff --git a/packages/api/workspace-files/README.zh.md b/packages/api/workspace-files/README.zh.md index ae1836a932..c913145966 100644 --- a/packages/api/workspace-files/README.zh.md +++ b/packages/api/workspace-files/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -把本包与 `dsh-fs`、`dsh-sandbox-policy`、Session store 和 Typert Gateway 一起挂载;bundle 把它紧随 Session Controller 之后挂载。每个方法都在线路上携带 Session 身份,Client 调用 `remote.workspaceFiles.read(sessionId, path, range, signal)`、`stat(sessionId, path, signal)`、`readBytes(sessionId, path, range, signal)`、`list(sessionId, path, signal)` 或 `changes(sessionId, request, signal)`,从不自己指定根。Host 读取 live Session header,cold Session 则使用持久层 `stat`;它不会激活 Agent、读取事件正文或借用父 Session 的根。live 读取不要求挂载 Session persistence;未挂载时 cold Session 无法解析,Gateway 返回 `gateway/lookup-not-found`。 +把本包与 `dsh-fs`、`dsh-sandbox-policy`、Session store 和 Typert Gateway 一起挂载;bundle 把它紧随 Session Controller 之后挂载。每个方法都在线路上携带 Session 身份,Client 调用 `remote.workspaceFiles.read(sessionId, path, range, signal)`、`stat(sessionId, path, signal)`、`readBytes(sessionId, path, range, signal)`、`list(sessionId, path, signal)` 或 `changes(sessionId, path, signal)`,从不自己指定根。Host 读取 live Session header,cold Session 则使用持久层 `stat`;它不会激活 Agent、读取事件正文或借用父 Session 的根。live 读取不要求挂载 Session persistence;未挂载时 cold Session 无法解析,Gateway 返回 `gateway/lookup-not-found`。 | 方法 | 返回 | 用途 | |---|---|---| @@ -35,7 +35,7 @@ kind: "package-reference" | `readAll(path)` | `WorkspaceFileBytes`,其中 `offset: 0`、`eof: true` | `maxFileBytes` 内的完整原始字节;超大文件失败,不截断 | | `readRelated(path, relativePath)` | `WorkspaceFileBytes` | Host 从基文件目录解析出的文件的完整字节 | | `list(path)` | `WorkspaceDirectoryListing { path, entries, truncated }` | 一个目录的直接子项 | -| `changes({ kind, path })` | `WorkspaceFileWatchFrame` 流 | 订阅就绪确认,随后为单个文件或目录直接子项的失效通知 | +| `changes(path)` | `WorkspaceFileWatchFrame` 流 | 订阅就绪确认,随后为单个文件或目录直接子项的失效通知 | ### 寻址与路径 @@ -55,7 +55,7 @@ kind: "package-reference" ### 变更流 -`changes` 是 `stream` 模式的 Remote,接受 `WorkspaceWatchRequest`:`kind` 为 `file` 或 `directory`,`path` 指定一个目标。一代流先注册观察队列、解析目标并建立 `fs.watch`,然后产出 `{ kind: 'ready' }`。随后产出 `{ kind: 'change', change }`,其中 `change` 为当前 stat 元数据 `{ absolutePath, version }` 或 `{ absolutePath, absent: true }`。本地提供方使用 Chokidar,目录仅监听直接子项。匹配目标的 `fs/observed` 也触发失效。流在初始化期间排队变化,在取消或插件释放时结束,并等待 watcher 关闭。目标缺失时仍可监听同一路径的重建。 +`changes` 是 `stream` 模式的 Remote,只接受目标路径。Host 从 `stat` 取得实际目标类型;只要当前类型是目录,就要求位于工作区内,目标类型变化后也执行该检查。一代流先注册观察队列、解析目标并建立 `fs.watch`,然后产出 `{ kind: 'ready' }`。随后产出 `{ kind: 'change', change }`,其中 `change` 为当前 stat 元数据 `{ absolutePath, version }` 或 `{ absolutePath, absent: true }`。本地提供方使用 Chokidar,目录仅监听直接子项。匹配目标的 `fs/observed` 也触发失效。流在初始化期间排队变化。取消会独立于消费方拉取帧的进度关闭 watcher,流或插件拆除等待关闭完成。父目录保持存在时,缺失文件仍可监听同一路径的重建。 ### 配置 @@ -139,11 +139,12 @@ Typert 生成 `./typert` 与 `./remote` 暴露的 Host 与 Client Remote 产物 - **提供方监听支持**——包括 SSH 在内的不支持后端报告 `watch-unsupported`;普通读取与手动刷新仍可用,不轮询外部变化。 +- **Linux 父目录重建**——父目录删除并重建后的自动监听恢复仍延期,见 [fs-local](../../fs/fs-local/README.zh.md)。 - **仅目录受限**——目录列举与监听限定在 Session 工作区内;文件读取与监听沿用文件系统后端的读取权限。 - **没有总行数**——页只报告 `eof`,不报告后面还有多少行;需要总数的消费方要翻到末尾或按 `bytes` 估算。 - **超长单行没有页**——超过 `maxBytes` 的单行在包含它的每个窗口都以 `too-large` 失败,因为页按行而非按字节切。 - **读取不具备事务性**——结果元数据来自内容读取之前的 stat;并发写入可能使报告版本与返回内容不一致。 -- **generation 队列无界**——一个 `changes` generation 会缓冲每一条被包含的观察直到消费方 pull;停滞的消费方会在流的生命期内持续增长 Host 内存。 +- **generation 队列无界**——一个 `changes` generation 会缓冲操作观察与目标失效通知直到消费方 pull;停滞的消费方会在流的生命期内持续增长 Host 内存。 - **`maxEntries` 限制的是答案,不是列举**——`list` 让 `ctx.fs.listDir` 列出全部子项后再截断数组,远超上限的目录仍让 Host 付出整个列举的代价(`fs-local` 上每个子项一次 stat);要限制这份工作,需要文件系统 seam 的 `listDir` 支持上限。 - **失效流保留元数据**——Host 结束 `changes` 或流终态失败后,已打开的值保持最后已知状态,直到重新打开。 diff --git a/packages/api/workspace-files/src/changes.ts b/packages/api/workspace-files/src/changes.ts index cd4ae8f8ac..2d38f031a0 100644 --- a/packages/api/workspace-files/src/changes.ts +++ b/packages/api/workspace-files/src/changes.ts @@ -8,12 +8,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, WorkspaceWatchRequest } from './types.ts' +import type { WorkspaceFileWatchFrame } from './types.ts' -/** One `fs/observed` emission as received, before any generation filters it. */ +/** One target invalidation, optionally carrying its instrumented observation. */ type Observed = readonly [target: FsTarget, observation?: FsObservation] -/** Owns `fs/observed` observation and every open `changes` generation. */ +/** Owns target watches, instrumented observations, and every open `changes` generation. */ export class WorkspaceChangeFeed { private readonly followers = new Set() @@ -24,8 +24,7 @@ export class WorkspaceChangeFeed { }) ctx.effect(() => async () => { const followers = [...this.followers] - for (const follower of followers) follower.close() - await Promise.all(followers.map(follower => follower.done.promise)) + await Promise.all(followers.map(follower => follower.close())) this.followers.clear() }, 'workspace-files.changes') } @@ -33,36 +32,49 @@ export class WorkspaceChangeFeed { /** * Open one target watch; directory targets remain inside `workspaceRoot`. * @param workspaceRoot - the session's workspace root path. - * @param request - file or directory target, resolved relative to the workspace root. + * @param path - target path, resolved relative to the workspace root; Host metadata determines its type. * @param signal - generation cancellation. * @returns `ready` after watching starts, then current metadata for target invalidations. */ - async *follow(workspaceRoot: string, request: WorkspaceWatchRequest, signal: AbortSignal): AsyncIterable { + async *follow(workspaceRoot: string, path: string, 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() + // Instrumented observations remain queued while the target resolves. + const follower = new ChangeFollower(() => { + signal.removeEventListener('abort', cancel) + this.followers.delete(follower) + }) signal = AbortSignal.any([signal, follower.controller.signal]) const aborted = (): boolean => signal.aborted + const cancel = (): void => { + void follower.close().catch((error: unknown) => { this.ctx.logger.error(error) }) + } + signal.addEventListener('abort', cancel, { once: true }) 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; - // a rejection the abort caused is the quiet end every other abort takes here. + // Setup shares cancellation, so a late resolution cannot acquire a watcher. const root = await this.ctx.fs.resolve(workspaceRoot, { signal }).catch((error: unknown) => { if (aborted()) return undefined throw error }) if (root === undefined || aborted() || follower.isClosed) return - const target = await this.ctx.fs.resolve(request.path, { cwd: workspaceRoot, signal }).catch((error: unknown) => { + const target = await this.ctx.fs.resolve(path, { cwd: workspaceRoot, signal }).catch((error: unknown) => { if (aborted()) return undefined throw error }) if (target === undefined || aborted()) return - 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 stat = async () => { + const info = await this.ctx.fs.stat(target, signal).catch((error: unknown) => { + if (aborted()) return undefined + throw error + }) + if (info?.type === 'directory' && !this.ctx.fs.contains(root, target)) { + throw new RemoteError('workspace-file/outside-workspace', 'Directory is outside the workspace', { path }) + } + return info } + await stat() + if (aborted()) return try { unwatch = await this.ctx.fs.watch(target, (error) => { if (error !== undefined) follower.fail(error) @@ -73,16 +85,14 @@ export class WorkspaceChangeFeed { if (aborted() && follower.error === undefined) return const failure = follower.error ?? error throw new RemoteError('workspace-file/watch-unsupported', - failure instanceof Error ? failure.message : String(failure), { path: request.path }) + failure instanceof Error ? failure.message : String(failure), { path }) } + follower.initialized.resolve(unwatch) if (aborted()) return yield { kind: 'ready' } - for await (const [observed] of follower.read(signal)) { + for await (const [observed] of follower.read()) { if (observed.targetKey !== target.targetKey) continue - const info = await this.ctx.fs.stat(target, signal).catch((error: unknown) => { - if (aborted()) return undefined - throw error - }) + const info = await stat() if (aborted()) return const absolutePath = this.ctx.fs.processPath(target) yield { @@ -93,13 +103,8 @@ export class WorkspaceChangeFeed { } } } finally { - follower.close() - try { - await unwatch?.() - } finally { - this.followers.delete(follower) - follower.done.resolve() - } + follower.initialized.resolve(unwatch) + await follower.close() } } } @@ -107,51 +112,51 @@ export class WorkspaceChangeFeed { /** One generation's queue: observations wait here until its consumer pulls them. */ class ChangeFollower { readonly controller = new AbortController() - readonly done = Promise.withResolvers() + readonly initialized = Promise.withResolvers<(() => Promise) | undefined>() error: Error | undefined private readonly queue = new Deque() private wake: (() => void) | undefined private closed = false + private closing: Promise | undefined - /** Whether the generation was closed while its workspace root resolved. */ + constructor(private readonly release: () => void) {} + + /** Whether this generation has stopped accepting invalidations. */ get isClosed(): boolean { return this.closed } push(observed: Observed): void { + if (this.closed) return this.queue.pushBack(observed) this.wake?.() } - close(): void { + close(): Promise { + if (this.closing !== undefined) return this.closing this.closed = true + this.closing = this.initialized.promise.then(unwatch => unwatch?.()).finally(this.release) this.controller.abort() this.wake?.() + return this.closing } fail(error: Error): void { this.error = error - this.close() + this.controller.abort() } /** Drain until closed or aborted; anything still queued then is dropped with the generation. */ - async *read(signal: AbortSignal): AsyncIterable { - const abort = (): void => { this.close() } - signal.addEventListener('abort', abort, { once: true }) - if (signal.aborted) abort() - try { - while (!this.closed) { - const observed = this.queue.popFront() - if (observed !== undefined) { - yield observed - continue - } - await new Promise((resolve) => { this.wake = resolve }) - this.wake = undefined + async *read(): AsyncIterable { + while (!this.closed) { + const observed = this.queue.popFront() + if (observed !== undefined) { + yield observed + continue } - if (this.error !== undefined) throw this.error - } finally { - signal.removeEventListener('abort', abort) + await new Promise((resolve) => { this.wake = resolve }) + this.wake = undefined } + if (this.error !== undefined) throw this.error } } diff --git a/packages/api/workspace-files/src/client/change-feed.ts b/packages/api/workspace-files/src/client/change-feed.ts index 4fc6455146..cf43b68b6c 100644 --- a/packages/api/workspace-files/src/client/change-feed.ts +++ b/packages/api/workspace-files/src/client/change-feed.ts @@ -1,11 +1,11 @@ /** - * One Host `changes` subscription per session, fanned out to the open files of - * that session. + * One Host `changes` subscription per Session and requested path, shared by + * that target's open resources. * - * The Host reports every agent write in a session on one stream; each open file - * wants only its own. The feed opens the session stream when the first follower + * The Host reports invalidations from filesystem watches and instrumented writes. + * The feed opens the target stream when the first follower * arrives, hands each frame to the followers of its path, and disposes the - * stream when the last follower leaves. A follower buffers session changes + * stream when the last follower leaves. A follower buffers target changes * until `stat` supplies its Host absolute path, then filters queued and live * frames by that path, with `\\` normalized to `/`. */ @@ -96,7 +96,7 @@ class Follower implements AsyncIterable { } } -/** The stream and followers of one session. */ +/** The stream and followers of one Session and requested path. */ class SessionFeed { private readonly followers = new Set() private readonly stream: SupervisedStream @@ -106,8 +106,9 @@ class SessionFeed { /** * @param remote - the Remote face carrying `workspaceFiles.changes`. - * @param sessionId - the session whose writes this feed follows. - * @param after - the previous feed of this session still closing, if any; the stream opens once it has settled. + * @param sessionId - the Session whose filesystem this feed observes. + * @param path - target path submitted to the Host. + * @param after - the previous feed of this target still closing, if any; the stream opens once it has settled. * @param onClose - called once when the stream is gone, whatever the cause, with the dispose that is closing it. */ constructor( @@ -119,14 +120,13 @@ class SessionFeed { ) { this.stream = remote.$stream({ name: `workspace file changes of ${sessionId}`, - // A predecessor still closing finishes first, so one session never has + // A predecessor still closing finishes first, so one target never has // two Host streams open at once. open: (signal) => { this.started = false - return openAfter(after, () => remote.workspaceFiles.changes(sessionId, { kind: 'file', path }, signal)) + return openAfter(after, () => remote.workspaceFiles.changes(sessionId, 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. + // A normal end means the Host closed the target's feed. ended: () => new Error(`workspace file changes of ${sessionId} ended`), }) void this.pump() @@ -217,14 +217,14 @@ function assertNever(frame: never): never { } /** - * Per-session fan-out of the Host's workspace file change stream. + * Per-target sharing of the Host's workspace file change streams. * * Owned by the provider; one instance serves every session of the Client. */ export class ChangeFeed { /** Live feeds only: a feed removes itself when its stream closes. */ private readonly sessions = new Map() - /** Streams still closing, by session: the session's next feed opens after its predecessor has settled. */ + /** Streams still closing, by Session and path; a successor waits for its predecessor. */ private readonly closing = new Map>() /** @@ -237,12 +237,12 @@ export class ChangeFeed { * * The follower is registered on call, not on first pull. Changes delivered * to this Client are queued while stat is pending. The first follower starts - * the session's local `changes` call. The iterable ends - * when `signal` aborts or when the session stream is gone; ending it early + * target's local `changes` call. The iterable ends + * when `signal` aborts or when the target stream is gone; ending it early * (`break`, `return`) unregisters the follower as well, and the last follower - * of a session disposes its stream. Await a true `ready` result before stat + * of a target disposes its stream. Await a true `ready` result before stat * so the Host subscription is active, then bind each stat's absolute path. Until binding, - * any session write can trigger a retry; after binding, only matching queued + * any target invalidation can trigger a retry; after binding, only matching queued * and live changes pass. * @param sessionId - the Session providing the file's read authority. * @param path - requested file path; followers share a stream only for the same Session and path. diff --git a/packages/api/workspace-files/src/client/provider.ts b/packages/api/workspace-files/src/client/provider.ts index a6d0a426e9..b31db99877 100644 --- a/packages/api/workspace-files/src/client/provider.ts +++ b/packages/api/workspace-files/src/client/provider.ts @@ -13,9 +13,9 @@ * a string outside the grammar, `workspace-file/unknown-workspace` when the * address carries no Session — and ends. * - * The first frame is the file's `stat`; every Host-reported write yields the - * metadata with its reported version; a reported disappearance, or a write while the - * last stat had failed, runs `stat` again. Failures travel as `ok: false` frames, never as thrown errors: the + * The first frame is the file's `stat`; subsequent invalidations run `stat` + * again unless their version or absence is already known. Reconnection also + * restats the file. Failures travel as `ok: false` frames, never as thrown errors: the * Remote face does not reject, and anything thrown inside the stream is a * programming error the resource model lets surface. A failed stat does not end * the stream: the next write stats again. One {@link ChangeFeed} @@ -40,7 +40,7 @@ interface HostFile { /** * Build the `file` provider over one Remote face and one change feed. * @param remote - the Remote face carrying `workspaceFiles.stat`. - * @param changes - the per-session change fan-out. + * @param changes - target-scoped change streams shared by file resources. * @returns the provider to register into `ctx.resources`. */ export function createFileResourceProvider( diff --git a/packages/api/workspace-files/src/client/types.ts b/packages/api/workspace-files/src/client/types.ts index b17b47e7d4..726862ae1a 100644 --- a/packages/api/workspace-files/src/client/types.ts +++ b/packages/api/workspace-files/src/client/types.ts @@ -41,10 +41,10 @@ declare module '@deepseek-ai/dsh-typert-protocol' { } } -/** One Host-reported write inside the session's workspace. */ +/** One Host-reported target change in the Session's filesystem. */ export type WorkspaceFileEdit = | { readonly kind: 'changed'; readonly version: string } | { readonly kind: 'absent' } -/** What one follower of a path receives: a Host write. */ +/** A target change or a request to restat after reconnection. */ 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 bb01d2bcc0..f9807ea534 100644 --- a/packages/api/workspace-files/src/index.ts +++ b/packages/api/workspace-files/src/index.ts @@ -39,7 +39,6 @@ import type { WorkspaceFileStat, WorkspaceFileText, WorkspaceFileWatchFrame, - WorkspaceWatchRequest, } from './types.ts' export type * from './types.ts' @@ -356,16 +355,14 @@ export class WorkspaceFiles extends TypertRemoteService { * 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. * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. - * @param request - target path and file or directory watch intent. + * @param path - target path; the Host determines its type and confines directories to the workspace. * @param signal - 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. */ @Remote({ mode: 'stream' }) - changes( - workspaceFileScope: WorkspaceFileScope, request: WorkspaceWatchRequest, signal: AbortSignal, - ): AsyncIterable { - return this.feed.follow(workspaceFileScope.workspaceRoot, request, signal) + changes(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): AsyncIterable { + return this.feed.follow(workspaceFileScope.workspaceRoot, path, 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 f165081587..184767f10e 100644 --- a/packages/api/workspace-files/src/types.ts +++ b/packages/api/workspace-files/src/types.ts @@ -115,46 +115,40 @@ export interface WorkspaceDirectoryListing { } /** - * One observation of a workspace file made by an instrumented filesystem - * operation. Frames report observations, not deltas: a consumer already - * holding `version` learns nothing new from the frame and can ignore it. + * Current target metadata after a filesystem invalidation. File consumers may + * ignore an already known version; directory consumers relist on every frame + * because a child's metadata can change without changing the directory version. */ export type WorkspaceFileChange = | { - /** Absolute path of the observed file, in the same form as {@link WorkspaceFileStat.absolutePath}. */ + /** Absolute target path, in the same form as {@link WorkspaceFileStat.absolutePath}. */ readonly absolutePath: string - /** Opaque freshness token after the observed operation; never parsed. */ + /** Opaque freshness token from the current stat; never parsed. */ readonly version: string } | { - /** Absolute path of the observed file, in the same form as {@link WorkspaceFileStat.absolutePath}. */ + /** Absolute target path, in the same form as {@link WorkspaceFileStat.absolutePath}. */ readonly absolutePath: string - /** The file was observed to be gone. */ + /** The target was observed to be gone. */ readonly absent: true } /** * One frame of a workspace file watch generation. `ready` confirms that the - * Host is observing filesystem operations and has resolved the workspace - * root; observations queued during that resolution follow as `change` frames. + * Host has initialized the target watcher; queued and live invalidations + * follow as `change` frames with current target metadata. */ export type WorkspaceFileWatchFrame = | { readonly kind: 'ready' } | { readonly kind: 'change'; readonly change: WorkspaceFileChange } -/** One watch target; directory intent restricts observation to the Workspace and direct entries. */ -export interface WorkspaceWatchRequest { - readonly path: string - readonly kind: 'file' | 'directory' -} - declare module '@deepseek-ai/dsh-typert-protocol' { interface RemoteErrorDetailsMap { /** The filesystem provider cannot initialize a watch for this target. */ '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. */ + /** The directory listing or watch path resolves outside the Session's workspace root. */ 'workspace-file/outside-workspace': { readonly path: string } /** The requested page exceeds the configured byte cap; nothing is returned. */ 'workspace-file/too-large': { readonly path: string; readonly limit: number } 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 bc8ab4734b..f1ffb141c9 100644 --- a/packages/api/workspace-files/tests/change-feed.client.spec.ts +++ b/packages/api/workspace-files/tests/change-feed.client.spec.ts @@ -40,7 +40,7 @@ describe('ChangeFeed — one Host stream per Session and target', () => { const second = follow(S1, PATH) await expect(second.ready).resolves.toBe(true) expect(remote.calls).toEqual(['changes', 'accept']) - expect(remote.opened).toMatchObject([{ sessionId: S1, kind: 'file', path: PATH }]) + expect(remote.opened).toMatchObject([{ sessionId: S1, path: PATH }]) await remote.opened[0]!.source.deliver({ kind: 'change', change: { absolutePath: PATH, version: 'v1' } }) await expect(second.it.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 'v1' } }) @@ -59,10 +59,10 @@ describe('ChangeFeed — one Host stream per Session and target', () => { const otherSession = follow(S2, PATH) await Promise.all([remote.ready(0), remote.ready(1), remote.ready(2)]) await expect(Promise.all([one.ready, twin.ready, otherPath.ready, otherSession.ready])).resolves.toEqual([true, true, true, true]) - expect(remote.opened.map(({ sessionId, path, kind }) => ({ sessionId, path, kind }))).toEqual([ - { sessionId: S1, path: PATH, kind: 'file' }, - { sessionId: S1, path: '/w/b.txt', kind: 'file' }, - { sessionId: S2, path: PATH, kind: 'file' }, + expect(remote.opened.map(({ sessionId, path }) => ({ sessionId, path }))).toEqual([ + { sessionId: S1, path: PATH }, + { sessionId: S1, path: '/w/b.txt' }, + { sessionId: S2, path: PATH }, ]) }) diff --git a/packages/api/workspace-files/tests/changes.spec.ts b/packages/api/workspace-files/tests/changes.spec.ts index 1fb613b479..5d28b78774 100644 --- a/packages/api/workspace-files/tests/changes.spec.ts +++ b/packages/api/workspace-files/tests/changes.spec.ts @@ -7,7 +7,6 @@ import type { Context } from '@deepseek-ai/cordis' import type { FileSystem, FsObservation, FsTarget } from '@deepseek-ai/dsh-fs' import { FsVersion } from '@deepseek-ai/dsh-fs' import { WorkspaceFiles } from '../src/index.ts' -import type { WorkspaceWatchRequest } from '../src/types.ts' import { failureOf, openWorkspace, type Harness } from './harness.ts' let harness: Harness @@ -52,10 +51,10 @@ const present = (version: string): FsObservation => ({ kind: 'present', version: /** Own the iterator until its asynchronous watcher close has settled, including failed assertions. */ function open( service: WorkspaceFiles, - request: WorkspaceWatchRequest, + path: string, controller = new AbortController(), ) { - const iterator = service.changes(harness.scope, request, controller.signal)[Symbol.asyncIterator]() + const iterator = service.changes(harness.scope, path, controller.signal)[Symbol.asyncIterator]() const close = async (): Promise => { controller.abort() await iterator.return?.() @@ -97,7 +96,7 @@ describe('workspaceFiles.changes — target frames', () => { options?.signal?.throwIfAborted() return root }) - const stream = open(service, { kind: 'file', path }) + const stream = open(service, path) const acknowledged = vi.fn() const first = stream.next().then((result) => { acknowledged(); return result }) try { @@ -133,7 +132,7 @@ describe('workspaceFiles.changes — target frames', () => { await release.promise return unwatch }) - const stream = open(service, { kind: 'file', path }) + const stream = open(service, path) const acknowledged = vi.fn() const first = stream.next().then((result) => { acknowledged(); return result }) try { @@ -158,7 +157,7 @@ describe('workspaceFiles.changes — target frames', () => { const current = await service.stat(harness.scope, path, new AbortController().signal) const fs = harness.ctx.fs const target = await fs.resolve(path) - const stream = open(service, { kind: 'file', path }) + const stream = open(service, path) await ready(stream) const stat = vi.spyOn(fs, 'stat') const pending = stream.next() @@ -189,7 +188,7 @@ describe('workspaceFiles.changes — target frames', () => { await writeFile(path, 'fresh bytes') const service = harness.endpoint() const current = await service.stat(harness.scope, path, new AbortController().signal) - const stream = open(service, { kind: 'file', path: 'fresh.txt' }) + const stream = open(service, 'fresh.txt') await ready(stream) const pending = stream.next() await observe(path, observation) @@ -203,7 +202,7 @@ describe('workspaceFiles.changes — target frames', () => { it('reports absence when stat finds no file despite a present observation', async () => { const path = join(harness.workspace, 'gone.txt') const target = await harness.ctx.fs.resolve(path) - const stream = open(harness.endpoint(), { kind: 'file', path }) + const stream = open(harness.endpoint(), path) await ready(stream) const pending = stream.next() await observe(path, present('formerly-present')) @@ -217,7 +216,7 @@ describe('workspaceFiles.changes — target frames', () => { const path = join(harness.workspace, 'queued.txt') await writeFile(path, 'first') const service = harness.endpoint() - const stream = open(service, { kind: 'file', path }) + const stream = open(service, path) await ready(stream) await observe(path, present('queued-one')) await observe(path, present('queued-two')) @@ -240,8 +239,8 @@ describe('workspaceFiles.changes — target frames', () => { await writeFile(path, 'shared') const service = harness.endpoint() const current = await service.stat(harness.scope, path, new AbortController().signal) - const one = open(service, { kind: 'file', path }) - const two = open(service, { kind: 'file', path }) + const one = open(service, path) + const two = open(service, path) await ready(one) await ready(two) const first = one.next() @@ -269,7 +268,7 @@ describe('workspaceFiles.changes — backends and access', () => { const path = join(harness.workspace, 'remote.txt') await writeFile(path, 'still readable') const service = harness.endpoint() - const stream = open(service, { kind: 'file', path }) + const stream = open(service, path) await expect(failureOf(stream.next())).resolves.toEqual({ code: 'workspace-file/watch-unsupported', details: { path }, }) @@ -292,7 +291,7 @@ describe('workspaceFiles.changes — backends and access', () => { 'Provider rejected watch initialization', ])('reports watcher initialization failure as unavailable: %s', async (failure) => { watch.mockRejectedValueOnce(failure) - const stream = open(harness.endpoint(), { kind: 'directory', path: harness.workspace }) + const stream = open(harness.endpoint(), harness.workspace) await expect(stream.next()).rejects.toMatchObject({ code: 'workspace-file/watch-unsupported', message: typeof failure === 'string' ? failure : failure.message, @@ -303,7 +302,7 @@ describe('workspaceFiles.changes — backends and access', () => { }) it('reports a watcher callback failure and awaits its close', async () => { - const stream = open(harness.endpoint(), { kind: 'directory', path: harness.workspace }) + const stream = open(harness.endpoint(), harness.workspace) await ready(stream) const pending = stream.next() const failure = new Error('Watch failed after readiness') @@ -322,7 +321,7 @@ describe('workspaceFiles.changes — backends and access', () => { signal.throwIfAborted() return unwatch }) - const stream = open(harness.endpoint(), { kind: 'directory', path: harness.workspace }) + const stream = open(harness.endpoint(), harness.workspace) const first = stream.next() const failure = new Error('Watch failed before readiness') try { @@ -352,7 +351,7 @@ describe('workspaceFiles.changes — backends and access', () => { await release.promise }) const stat = vi.spyOn(harness.ctx.fs, 'stat') - const stream = open(harness.endpoint(), { kind: 'directory', path: harness.workspace }) + const stream = open(harness.endpoint(), harness.workspace) const settled = vi.fn() const first = stream.next() const settlement = first.then(settled, settled) @@ -361,7 +360,7 @@ describe('workspaceFiles.changes — backends and access', () => { await Promise.resolve() expect(settled).not.toHaveBeenCalled() expect(stream.controller.signal.aborted).toBe(false) - expect(stat).not.toHaveBeenCalled() + expect(stat).toHaveBeenCalledExactlyOnceWith(watch.mock.calls[0]![0], watch.mock.calls[0]![2]) release.resolve(undefined) await expect(first).rejects.toMatchObject({ code: 'workspace-file/watch-unsupported', message: failure.message, details: { path: harness.workspace }, @@ -376,7 +375,7 @@ describe('workspaceFiles.changes — backends and access', () => { }) it('rejects an outside directory before opening a watcher', async () => { - const stream = open(harness.endpoint(), { kind: 'directory', path: harness.outside }) + const stream = open(harness.endpoint(), harness.outside) await expect(failureOf(stream.next())).resolves.toEqual({ code: 'workspace-file/outside-workspace', details: { path: harness.outside }, }) @@ -388,7 +387,7 @@ describe('workspaceFiles.changes — backends and access', () => { await writeFile(path, 'outside contents') const service = harness.endpoint() const current = await service.stat(harness.scope, path, new AbortController().signal) - const stream = open(service, { kind: 'file', path }) + const stream = open(service, path) await ready(stream) expect(watch.mock.calls[0]![0]).toEqual(await harness.ctx.fs.resolve(path)) const pending = stream.next() @@ -402,6 +401,26 @@ describe('workspaceFiles.changes — backends and access', () => { }) }) + it('rejects and closes an outside target when a later stat finds a directory', async () => { + const path = join(harness.outside, 'changed-kind') + await writeFile(path, 'initial file') + const stat = vi.spyOn(harness.ctx.fs, 'stat') + const stream = open(harness.endpoint(), path) + await ready(stream) + const [target, changed, signal] = watch.mock.calls[0]! + expect(stat).toHaveBeenCalledExactlyOnceWith(target, signal) + await rm(path) + await mkdir(path) + const pending = stream.next() + changed() + await expect(failureOf(pending)).resolves.toEqual({ + code: 'workspace-file/outside-workspace', details: { path }, + }) + expect(stat.mock.calls).toEqual([[target, signal], [target, signal]]) + expect(unwatch).toHaveBeenCalledTimes(1) + 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) => { @@ -414,7 +433,7 @@ describe('workspaceFiles.changes — backends and access', () => { const observed = vi.fn() const detach = harness.ctx.on('fs/observed', observed) cleanups.push(async () => { detach() }) - const stream = open(service, { kind: 'directory', path: harness.workspace }) + const stream = open(service, harness.workspace) await ready(stream) const pending = stream.next() if (operation === 'remove') await rm(path) @@ -459,7 +478,7 @@ describe('workspaceFiles.changes — cancellation and disposal', () => { return root }) const owner = await ownedEndpoint() - const stream = open(owner.service, { kind: 'directory', path: harness.workspace }) + const stream = open(owner.service, harness.workspace) const first = stream.next() try { const lifetime = await entered.promise @@ -484,13 +503,13 @@ describe('workspaceFiles.changes — cancellation and disposal', () => { it('refuses an already-aborted signal without acquiring a watcher', async () => { const controller = new AbortController() controller.abort() - const stream = open(harness.endpoint(), { kind: 'directory', path: harness.workspace }, controller) + const stream = open(harness.endpoint(), harness.workspace, controller) await expect(stream.next()).rejects.toMatchObject({ name: 'AbortError' }) expect(watch).not.toHaveBeenCalled() }) it('ends an idle generation and closes its watcher on cancellation', async () => { - const stream = open(harness.endpoint(), { kind: 'directory', path: harness.workspace }) + const stream = open(harness.endpoint(), harness.workspace) await ready(stream) const pending = stream.next() stream.controller.abort() @@ -498,8 +517,58 @@ describe('workspaceFiles.changes — cancellation and disposal', () => { expect(unwatch).toHaveBeenCalledTimes(1) }) + it.each(['cancel', 'dispose'] as const)('closes a generation paused at ready on %s and awaits watcher closure', async (ending) => { + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + unwatch.mockImplementationOnce(async () => { + entered.resolve(undefined) + await release.promise + }) + const owner = await ownedEndpoint() + const stream = open(owner.service, harness.workspace) + cleanups.push(async () => { release.resolve(undefined) }) + await ready(stream) + const finished = vi.fn() + let disposal: Promise | undefined + try { + if (ending === 'cancel') { + stream.controller.abort() + await entered.promise + await observe(harness.workspace, present('closing')) + } + disposal = owner.dispose().then(finished) + await entered.promise + expect(stream.controller.signal.aborted).toBe(ending === 'cancel') + expect(unwatch).toHaveBeenCalledTimes(1) + expect(finished).not.toHaveBeenCalled() + release.resolve(undefined) + await disposal + expect(finished).toHaveBeenCalledTimes(1) + await expect(stream.next()).resolves.toEqual({ done: true, value: undefined }) + expect(unwatch).toHaveBeenCalledTimes(1) + } finally { + release.resolve(undefined) + await stream.close() + if (disposal !== undefined) await disposal + } + }) + + it('reports watcher-close failures without requiring another pull', async () => { + const failure = new Error('Watcher close failed') + const reported = Promise.withResolvers() + const error = vi.spyOn(harness.ctx.logger, 'error').mockImplementation((value) => { reported.resolve(value) }) + cleanups.push(async () => { error.mockRestore() }) + unwatch.mockRejectedValueOnce(failure) + const stream = open(harness.endpoint(), harness.workspace) + await ready(stream) + stream.controller.abort() + await expect(reported.promise).resolves.toBe(failure) + await expect(stream.next()).rejects.toBe(failure) + expect(unwatch).toHaveBeenCalledOnce() + }) + it('drops queued invalidations when cancelled after ready and before another pull', async () => { - const stream = open(harness.endpoint(), { kind: 'directory', path: harness.workspace }) + const stream = open(harness.endpoint(), harness.workspace) await ready(stream) await observe(harness.workspace, present('queued')) const stat = vi.spyOn(harness.ctx.fs, 'stat') @@ -525,7 +594,7 @@ describe('workspaceFiles.changes — cancellation and disposal', () => { options?.signal?.throwIfAborted() return resolve(requested, options) }) - const stream = open(harness.endpoint(), { kind: 'file', path }) + const stream = open(harness.endpoint(), path) const pending = stream.next() try { const lifetime = await entered.promise @@ -554,7 +623,7 @@ describe('workspaceFiles.changes — cancellation and disposal', () => { await release.promise return target }) - const stream = open(harness.endpoint(), { kind: 'file', path }) + const stream = open(harness.endpoint(), path) const pending = stream.next() try { await entered.promise @@ -578,7 +647,7 @@ describe('workspaceFiles.changes — cancellation and disposal', () => { if (settlement === 'reject') signal.throwIfAborted() return unwatch }) - const stream = open(harness.endpoint(), { kind: 'directory', path: harness.workspace }) + const stream = open(harness.endpoint(), harness.workspace) const pending = stream.next() try { const lifetime = await entered.promise @@ -593,9 +662,14 @@ describe('workspaceFiles.changes — cancellation and disposal', () => { }, ) - it.each(['reject', 'return'] as const)( - 'does not publish a change when stat settles by %s after cancellation', - async (settlement) => { + it.each([ + { phase: 'initial', settlement: 'reject' }, + { phase: 'initial', settlement: 'return' }, + { phase: 'change', settlement: 'reject' }, + { phase: 'change', settlement: 'return' }, + ] as const)( + 'ends quietly when $phase stat settles by $settlement after cancellation', + async ({ phase, settlement }) => { const path = join(harness.workspace, 'late-stat.txt') await writeFile(path, 'contents') const fs = harness.ctx.fs @@ -603,23 +677,23 @@ describe('workspaceFiles.changes — cancellation and disposal', () => { const info = await fs.stat(target) const entered = Promise.withResolvers() const release = Promise.withResolvers() + const stream = open(harness.endpoint(), path) + if (phase === 'change') await ready(stream) vi.spyOn(fs, 'stat').mockImplementationOnce(async (_target, signal) => { entered.resolve(signal) await release.promise if (settlement === 'reject') signal?.throwIfAborted() return info }) - const stream = open(harness.endpoint(), { kind: 'file', path }) - await ready(stream) const pending = stream.next() try { - await observe(path, present('trigger')) + if (phase === 'change') await observe(path, present('trigger')) const lifetime = await entered.promise stream.controller.abort() expect(lifetime?.aborted).toBe(true) release.resolve(undefined) await expect(pending).resolves.toEqual({ done: true, value: undefined }) - expect(unwatch).toHaveBeenCalledTimes(1) + expect(unwatch).toHaveBeenCalledTimes(phase === 'change' ? 1 : 0) } finally { release.resolve(undefined) } @@ -636,7 +710,7 @@ describe('workspaceFiles.changes — cancellation and disposal', () => { return resolve(requested, options) }) const stat = vi.spyOn(harness.ctx.fs, 'stat') - const stream = open(harness.endpoint(), { kind: 'file', path }) + const stream = open(harness.endpoint(), path) await expect(stream.next()).rejects.toBe(failure) expect(stream.controller.signal.aborted).toBe(false) expect(watch).not.toHaveBeenCalled() @@ -646,9 +720,9 @@ describe('workspaceFiles.changes — cancellation and disposal', () => { it('propagates a non-cancellation stat failure without publishing absence and closes the watcher', async () => { const failure = new Error('Metadata read failed') - const stat = vi.spyOn(harness.ctx.fs, 'stat').mockRejectedValueOnce(failure) - const stream = open(harness.endpoint(), { kind: 'directory', path: harness.workspace }) + const stream = open(harness.endpoint(), harness.workspace) await ready(stream) + const stat = vi.spyOn(harness.ctx.fs, 'stat').mockRejectedValueOnce(failure) const pending = stream.next() const [target, changed, signal] = watch.mock.calls[0]! changed() @@ -666,7 +740,7 @@ describe('workspaceFiles.changes — cancellation and disposal', () => { entered.resolve(undefined) await release.promise }) - const stream = open(harness.endpoint(), { kind: 'directory', path: harness.workspace }) + const stream = open(harness.endpoint(), harness.workspace) await ready(stream) const finished = vi.fn() const returned = Promise.resolve(stream.return()).then((result) => { finished(); return result }) @@ -698,8 +772,8 @@ describe('workspaceFiles.changes — cancellation and disposal', () => { await release.promise }) const owner = await ownedEndpoint() - const one = open(owner.service, { kind: 'file', path }) - const two = open(owner.service, { kind: 'file', path }) + const one = open(owner.service, path) + const two = open(owner.service, path) await ready(one) await ready(two) const first = one.next() @@ -715,9 +789,9 @@ describe('workspaceFiles.changes — cancellation and disposal', () => { await disposal expect(finished).toHaveBeenCalledTimes(1) expect(unwatch).toHaveBeenCalledTimes(2) - const stat = vi.spyOn(fs, 'stat') - const live = open(harness.endpoint(), { kind: 'file', path }) + const live = open(harness.endpoint(), path) await ready(live) + const stat = vi.spyOn(fs, 'stat') const next = live.next() watch.mock.calls[0]![1]() watch.mock.calls[1]![1]() diff --git a/packages/api/workspace-files/tests/fake-remote.client.ts b/packages/api/workspace-files/tests/fake-remote.client.ts index 5d0c3d04b9..c310dc433f 100644 --- a/packages/api/workspace-files/tests/fake-remote.client.ts +++ b/packages/api/workspace-files/tests/fake-remote.client.ts @@ -6,7 +6,7 @@ import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { WorkspaceFileWatchFrame, WorkspaceFileStat, WorkspaceWatchRequest } from '../src/types.ts' +import type { WorkspaceFileWatchFrame, WorkspaceFileStat } 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. */ @@ -87,7 +87,6 @@ export interface PendingStat { interface OpenedWatch { readonly sessionId: SessionId readonly path: string - readonly kind: WorkspaceWatchRequest['kind'] readonly source: Source } @@ -226,11 +225,11 @@ export class FakeRemote implements WorkspaceFilesRemote { this.statWaiters.delete(index) if (this.closed) resolve(this.closedStat()) }), - changes: (sessionId: SessionId, request: WorkspaceWatchRequest, signal?: AbortSignal): AsyncIterable => { + changes: (sessionId: SessionId, path: string, 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) - const watch = { sessionId, path: request.path, kind: request.kind, source } + const watch = { sessionId, path, source } const index = this.opened.length this.opened.push(watch) for (const waiter of this.watchWaiters.get(index) ?? []) waiter(watch) diff --git a/packages/api/workspace-files/tests/host-path.client.spec.ts b/packages/api/workspace-files/tests/host-path.client.spec.ts index e6049717b4..8626dddb71 100644 --- a/packages/api/workspace-files/tests/host-path.client.spec.ts +++ b/packages/api/workspace-files/tests/host-path.client.spec.ts @@ -43,7 +43,7 @@ describe('Host-resolved file paths', () => { const { iterator } = open() const first = iterator.next() const watch = await remote.ready(0) - expect(watch).toMatchObject({ sessionId: SESSION, path: RELATIVE, kind: 'file' }) + expect(watch).toMatchObject({ sessionId: SESSION, path: RELATIVE }) const request = await remote.waitForStat(0) expect(remote.calls).toEqual(['changes', 'accept', 'stat']) expect(request).toMatchObject({ sessionId: SESSION, path: RELATIVE }) @@ -166,7 +166,7 @@ describe('Host-resolved file paths', () => { const { iterator } = open(address) const first = iterator.next() const watch = await remote.ready(0) - expect(watch).toMatchObject({ sessionId: SESSION, path, kind: 'file' }) + expect(watch).toMatchObject({ sessionId: SESSION, path }) const request = await remote.waitForStat(0) expect(request).toMatchObject({ sessionId: SESSION, path }) const source = watch.source diff --git a/packages/api/workspace-files/tests/provider.client.spec.ts b/packages/api/workspace-files/tests/provider.client.spec.ts index 04ce185f41..21c00d8cc5 100644 --- a/packages/api/workspace-files/tests/provider.client.spec.ts +++ b/packages/api/workspace-files/tests/provider.client.spec.ts @@ -92,7 +92,7 @@ describe('file provider — the address', () => { const { remote, it } = opened() const first = it.next() const watch = await remote.ready(0) - expect(watch).toMatchObject({ sessionId: S1, path: REL_PATH, kind: 'file' }) + expect(watch).toMatchObject({ sessionId: S1, path: REL_PATH }) const initial = await remote.waitForStat(0) expect(initial).toMatchObject({ sessionId: S1, path: REL_PATH }) initial.resolve({ ok: true, value: stat('v0', 3) }) @@ -109,7 +109,7 @@ describe('file provider — the address', () => { const { remote, it } = opened(ABS_ADDRESS) const first = it.next() const watch = await remote.ready(0) - expect(watch).toMatchObject({ sessionId: S1, path: ABS_PATH, kind: 'file' }) + expect(watch).toMatchObject({ sessionId: S1, path: ABS_PATH }) const initial = await remote.waitForStat(0) expect(initial).toMatchObject({ sessionId: S1, path: ABS_PATH }) initial.resolve({ ok: true, value: { absolutePath: ABS_PATH, version: 'v0', bytes: 3 } }) diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 4f5a29cd67..6227c0e111 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/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-documentpreview/README.md -README.md: 4127b45bdfaea6c866efe457b7be0a3942cb5e86 -README.zh.md: 05eefb854ba3da54559f5bc24290cd795fac58d1 +README.md: 2a42b50684239da216c65cd2378256fcf7760299 +README.zh.md: 6e40ceca2f96d1fc2ab1989dfb4985d04584c2aa diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 4127b45bdf..2a42b50684 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Preview files in the right Sidebar and choose among registered renderers. Markdown and code support paged text; PDF, HTML, and common images receive complete bytes; unknown extensions use plain text. Office documents convert locally to PDF. The tab provides file status, renderer selection, wrap, and reload. Plugins can add local opening controls to the header and unsupported-preview empty state. +Preview files in the right Sidebar and choose among registered renderers. Markdown and code support paged text; PDF, HTML, and common images receive complete bytes; unknown extensions use plain text. Office documents convert locally to PDF. The tab provides file status, renderer selection, wrap, and automatic or manual reload. Plugins can add local opening controls to the header and unsupported-preview empty state. ## Table of Contents @@ -28,12 +28,12 @@ Preview files in the right Sidebar and choose among registered renderers. Markdo ## What it registers - **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, pattern `dsh-resource://file/**`, band `fallback`. `canOpen` accepts only Session addresses, whose paths may be relative or absolute; bare `absolute` addresses are not claimed. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; other supported files land here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title, and the keyed `sidebar.right.pane.tab.title` seat places its extension-specific `FileTypeIcon` before that title. -- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; directories use tertiary label colour, the name uses primary label colour, and a clipped path retains and fades toward its final segment while its tooltip exposes the full value. A dropdown appears when multiple supported renderers are available. Plain text is offered only for text-compatible sources; a single renderer shows no viewer control. A known binary container suffix without a registered renderer shows the file-type icon and an unsupported-preview message under the path header, without issuing a read. Two Session-scoped list child slots hand the previewed file to other plugins once its metadata reports a Host path, both with `absolutePath` as the owner prop: `sidebar.right.tab.document.actions` renders after the header's own controls in every state that shows the header, and `sidebar.right.tab.document.unpreviewable` renders in the unsupported empty state and in the empty state of a readable file the preview cannot render (`not-text`, `too-large`), where Retry would otherwise stand; a carrier or unclassified read failure keeps Retry, and a missing or non-regular path offers only its explanation. The shipped occupant of both is [`ui-open-in-app`](../ui-open-in-app/README.md). A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the Files tab's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge. -- **Shared loading and view state**, session-scoped and bucketed by tab id. The store holds accumulated pages or complete bytes, read and observed versions, loading/failure state, renderer choice, scroll offset, wrap, and the answered navigation revision. The ordinary inject face calls Remote readers and writes through declared store actions. Reloads and loading-mode changes retire older requests; the tab's abort signal forgets its state. +- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; directories use tertiary label colour, the name uses primary label colour, and a clipped path retains and fades toward its final segment while its tooltip exposes the full value. A dropdown appears when multiple supported renderers are available. Plain text is offered only for text-compatible sources; a single renderer shows no viewer control. A known binary container suffix without a registered renderer shows the file-type icon and an unsupported-preview message under the path header, without issuing a read. Two Session-scoped list child slots hand the previewed file to other plugins once its metadata reports a Host path, both with `absolutePath` as the owner prop: `sidebar.right.tab.document.actions` renders after the header's own controls in every state that shows the header, and `sidebar.right.tab.document.unpreviewable` renders in the unsupported empty state and in the empty state of a readable file the preview cannot render (`not-text`, `too-large`), where Retry would otherwise stand; a carrier or unclassified read failure keeps Retry, and a missing or non-regular path offers only its explanation. The shipped occupant of both is [`ui-open-in-app`](../ui-open-in-app/README.md). A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. Automatic refresh defaults to enabled; its separate toggle is hidden, with state, labels, styles, and toggle logic retained. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the Files tab's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge. +- **Shared loading and view state**, session-scoped and bucketed by tab id. The store holds accumulated pages or complete bytes, read and observed versions, pending Resource changes, automatic-refresh state, loading/failure state, renderer choice, scroll offset, wrap, and the answered navigation revision. The ordinary inject face calls Remote readers and writes through declared store actions. Reloads and loading-mode changes retire older requests; the tab's abort signal forgets its state. -Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. `binaryExtensions` lists suffixes in `extensions` that cannot be read as text and omit the plain-text option. Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, `content`, `wrap`, `scrollportRef`, and the standard `useTabInfo`/`useResource` hooks. An inner scrolling element attaches `scrollportRef`; unmounting restores the shared body as scroll owner. The registry retains all matching alternatives: `extension` (the default) ranks above `builtin`, then longer suffixes rank first, then registration order. The dropdown preserves a selected implementation while it remains available. HTML, SVG, and unmatched extensions retain the plain-text fallback independently of loading mode. +Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. `binaryExtensions` lists suffixes in `extensions` that cannot be read as text and omit the plain-text option. Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, `content`, `wrap`, `scrollportRef`, `addResource`, `setResources`, and the standard `useTabInfo`/`useResource` hooks. Dependency membership belongs to a tab-owned `ResourceGroup`; replacing dependencies always retains the root Resource. An inner scrolling element attaches `scrollportRef`; unmounting restores the shared body as scroll owner. The registry retains all matching alternatives: `extension` (the default) ranks above `builtin`, then longer suffixes rank first, then registration order. The dropdown preserves a selected implementation while it remains available. HTML, SVG, and unmatched extensions retain the plain-text fallback independently of loading mode. -`loading: 'text-pages'` and `'bytes-complete'` use the shared file reader. With `'renderer'`, the selected body mounts before any bytes are read and receives `content: { kind: 'renderer', revision, loaded, reload }`. Its injected callbacks own content loading, errors, and cancellation. `loaded(version)` reports the displayed source version for the shared change notice; reports from replaced revisions are ignored. `reload()` increments the revision, which the body observes to cancel and replace its request. The body also cancels on unmount and tab closure, retains settled content in its declared tab store, and releases that state when the tab ends. [Office previews](#office-preview) use this mode without putting converted bytes or font metadata in the shared file store. +`loading: 'text-pages'` and `'bytes-complete'` use the shared file reader. With `'renderer'`, the selected body mounts before any bytes are read and receives `content: { kind: 'renderer', revision, loaded, failed, reload }`. Its injected callbacks own content loading, errors, and cancellation. `loaded(version)` records the displayed source version and ends loading. `failed()` ends loading without recording a successful version, allowing a later file change to retry; reports from replaced revisions are ignored. `reload()` increments the revision, which the body observes to cancel and replace its request. The body also cancels on unmount and tab closure, retains settled content in its declared tab store, and releases that state when the tab ends. [Office previews](#office-preview) use this mode without putting converted bytes or font metadata in the shared file store. ## Addresses @@ -43,16 +43,16 @@ A tab uses the Session address built by `fileAddressFor`, carrying a relative or ## How it reads -Developer tools selects the HTML preview policy in both Web and desktop. The renderer receives `interactivePreview` from plugin assembly. Off uses a DOMPurify-sanitized complete static document in an iframe with no sandbox permissions: CSP blocks scripts, external resources, connections, forms, and nested frames; all `href` and `xlink:href` attributes, refresh directives, and declarative shadow roots are removed before reparsing. Inline styles and data images remain visible, and related files are not read. On uses the scripted Blob preview described below. A mode change unmounts the previous frame and aborts its pending related-file reads. Other document formats retain their own policies. +Developer tools selects the HTML preview policy in both Web and desktop. The renderer receives `interactivePreview` from plugin assembly. Off uses a DOMPurify-sanitized complete static document in an iframe with no sandbox permissions: CSP blocks scripts, external resources, connections, forms, and nested frames; all `href` and `xlink:href` attributes, refresh directives, and declarative shadow roots are removed before reparsing. Inline styles and data images remain visible, and related files are not read. On uses the scripted Blob preview described below. A mode change unmounts the previous frame and aborts its pending related-file reads. Static preview releases CSS/JS resource subscriptions and keeps the root file watched. Other document formats retain their own policies. The body reads its record, navigation and lifetime through `useTabInfo().tab`. `useResource<'file'>(tab.contentId)` supplies metadata; ordinary inject callbacks supply content reads: -- The resource snapshot contains only `status`, `value`, and `failure`; `value` is `WorkspaceFileStat` metadata. Content reads do not wait for the first metadata frame once the provider is available. Observation failures take precedence over Preview's change notice; neither automatically replaces loaded content. +- The resource snapshot contains only `status`, `value`, and `failure`; `value` is `WorkspaceFileStat` metadata. Content reads do not wait for the first metadata frame once the provider is available. Observation failures take precedence over Preview's change notice; loaded content remains while metadata is unavailable. - **Text pages** — plain text, Markdown, and code read through an inject callback to `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`. The first mount reads page one; scrolling to the body end or **Load more** requests the next page until `eof`. The owner delivers the accumulated prefix as `{ kind: 'text', text, pages, eof }`, including source offsets and line counts. Markdown and code render that prefix incrementally; they do not render each page as a separate document. A newer-version page past page one restarts from the beginning rather than mixing versions. A failure before any content fills the body with the file-type icon, the explanation, and the recourse the failure names (Retry, the unpreviewable child slot, or nothing); a later failure retains loaded content and adds the retry below it. - **Complete bytes** — PDF, HTML, and common images use an inject callback to `remote.workspaceFiles.readAll(sessionId, path, signal)`. `rpc.ts` decodes the wire base64 into `data: Uint8Array` for `{ kind: 'bytes', data }`. The Host's `maxFileBytes` cap rejects oversized files rather than truncating them. PDF copies retained bytes before worker transfer, keeping the Preview buffer usable. Bytes stay in transient view state, never persisted layouts or Session JSONL. Loading-mode changes retire previous results. -- **Reload** — only the current Preview tab rereads through its Remote callbacks, preserving its scroll preference and retiring older requests. Its change notice compares the read version and the observation captured at read start with later `resource.value.version`; an already observed version does not become a new change after refresh. Reads neither refresh shared metadata nor clear another tab's notice. +- **Reload** — manual reload rereads only the current Preview tab through its Remote callbacks, preserving its scroll preference and retiring older requests. Automatic refresh uses the same callbacks after a ResourceGroup member changes. Each member's first metadata establishes a baseline without triggering reload or first-read version reconciliation; later changes received during a read remain pending for another refresh. Reads neither refresh shared metadata nor clear another tab's notice. -When Developer tools is enabled, HTML fills the body edge to edge in a Blob iframe with exactly `sandbox="allow-scripts"`, without `allow-same-origin`; scripts cannot access the parent application's origin or file reader. The renderer loads directly declared relative `.js` classic scripts and `.css` stylesheets through its ordinary inject callback to `remote.workspaceFiles.readRelated`, with fixed safety limits of 4 MiB per asset, 32 MiB total, and 64 distinct assets. Host code resolves the related path; `rpc.ts` decodes the returned bytes. Inside the renderer, base64 is used only to embed the iframe bootstrap payload in script text. A `` leaves dependency resolution to the browser, as do HTTPS resources. Local module imports, CSS `url()`/`@import`, and dynamic `fetch` do not use Host file access. Read failures, invalid UTF-8, or exceeded limits fail the preview rather than publishing a partial asset package. Replacing or unmounting the document releases its Blob URL. +When Developer tools is enabled, HTML fills the body edge to edge in a Blob iframe with exactly `sandbox="allow-scripts"`, without `allow-same-origin`; scripts cannot access the parent application's origin or file reader. The renderer loads directly declared relative `.js` classic scripts and `.css` stylesheets through its ordinary inject callback to `remote.workspaceFiles.readRelated`, with fixed safety limits of 4 MiB per asset, 32 MiB total, and 64 distinct assets. Host code resolves the related path; `rpc.ts` decodes the returned bytes. The dependency Resource is added after `readRelated` returns, using its `absolutePath` or a string `error.details.path` on failure; without a Host path, the Client does not guess one. Inside the renderer, base64 is used only to embed the iframe bootstrap payload in script text. A `` leaves dependency resolution to the browser, as do HTTPS resources. Local module imports, CSS `url()`/`@import`, and dynamic `fetch` do not use Host file access. Read failures, invalid UTF-8, or exceeded limits fail the preview rather than publishing a partial asset package. Replacing or unmounting the document releases its Blob URL. PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `` static-image context, rounded inside a 12px inset. A wider image scales down to the pane's width at its intrinsic aspect ratio, a smaller image centres at its intrinsic CSS-pixel dimensions, and a taller image scrolls vertically. The renderer provides neither zoom nor drag-to-pan. SVG markup never enters the application DOM or an iframe, so its scripts cannot execute or reach the parent page. Replacing or unmounting the image revokes its Blob URL. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index 05eefb854b..6e40ceca2f 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -在右侧 Sidebar 预览文件,并切换已注册的渲染器。Markdown 和代码支持分页文本;PDF、HTML 和常见图片接收完整字节;未知扩展名使用纯文本。Office 文档在本地转换为 PDF。tab 提供文件状态、渲染器选择、换行和重新载入。插件可在头部和不支持预览的空态中添加本地打开控件。 +在右侧 Sidebar 预览文件,并切换已注册的渲染器。Markdown 和代码支持分页文本;PDF、HTML 和常见图片接收完整字节;未知扩展名使用纯文本。Office 文档在本地转换为 PDF。tab 提供文件状态、渲染器选择、换行以及自动或手动重新载入。插件可在头部和不支持预览的空态中添加本地打开控件。 ## 目录 @@ -28,12 +28,12 @@ kind: "package-reference" ## 注册了什么 - **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`(这个实现在 tab 系统中的身份,也是其正文注册所用的键),kind `text`,pattern `dsh-resource://file/**`,档位 `fallback`。`canOpen` 只接受 Session 地址,其中路径可为相对或绝对路径;不认领裸 `absolute` 地址。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其他受支持文件落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题,keyed slot `sidebar.right.pane.tab.title` 会在标题前放置按扩展名选择的 `FileTypeIcon`。 -- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;目录使用三级标签色,文件名使用一级标签色,路径过长时保留末段并向开头淡出,提示中仍提供完整值。有多个受支持的渲染器时才显示下拉菜单。仅文本兼容的源文件提供纯文本选项;只有一个渲染器时不显示查看器控件。已知的二进制容器后缀没有注册渲染器时,在路径头部下方显示文件类型图标和不支持预览的说明,并且不会发起读取。两个 Session 作用域的 list 子 slot 在文件元数据报出 Host 路径后把当前文件交给其他插件,owner prop 为 `absolutePath`:`sidebar.right.tab.document.actions` 在每个显示头部的状态里渲染在头部自有控件之后;`sidebar.right.tab.document.unpreviewable` 渲染在不支持预览的空态里,以及可读但本预览无法渲染的文件(`not-text`、`too-large`)的空态里,占据原本放重试的位置;载体或未分类的读取失败仍保留重试,不存在或非普通文件的路径只给说明。两者已发布的占位者都是 [`ui-open-in-app`](../ui-open-in-app/README.zh.md)。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与 Files tab 右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。 -- **共享加载与视图状态**,会话作用域、按 tab id 分桶。store 持有累计页或完整字节、读取与观察版本、加载/失败状态、渲染器选择、滚动位置、换行和已响应的导航 revision。普通 inject face 调用 Remote 读取,并经声明的 store action 写入。重新载入和加载模式变化会淘汰旧请求;tab 的中止信号清理其状态。 +- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;目录使用三级标签色,文件名使用一级标签色,路径过长时保留末段并向开头淡出,提示中仍提供完整值。有多个受支持的渲染器时才显示下拉菜单。仅文本兼容的源文件提供纯文本选项;只有一个渲染器时不显示查看器控件。已知的二进制容器后缀没有注册渲染器时,在路径头部下方显示文件类型图标和不支持预览的说明,并且不会发起读取。两个 Session 作用域的 list 子 slot 在文件元数据报出 Host 路径后把当前文件交给其他插件,owner prop 为 `absolutePath`:`sidebar.right.tab.document.actions` 在每个显示头部的状态里渲染在头部自有控件之后;`sidebar.right.tab.document.unpreviewable` 渲染在不支持预览的空态里,以及可读但本预览无法渲染的文件(`not-text`、`too-large`)的空态里,占据原本放重试的位置;载体或未分类的读取失败仍保留重试,不存在或非普通文件的路径只给说明。两者已发布的占位者都是 [`ui-open-in-app`](../ui-open-in-app/README.zh.md)。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。自动刷新默认开启;独立开关暂时隐藏,状态、文案、样式与切换逻辑保留。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与 Files tab 右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。 +- **共享加载与视图状态**,会话作用域、按 tab id 分桶。store 持有累计页或完整字节、读取与观察版本、待处理 Resource 变化、自动刷新状态、加载/失败状态、渲染器选择、滚动位置、换行和已响应的导航 revision。普通 inject face 调用 Remote 读取,并经声明的 store action 写入。重新载入和加载模式变化会淘汰旧请求;tab 的中止信号清理其状态。 -文档实现在 `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。`binaryExtensions` 列出 `extensions` 中不可按文本阅读的后缀,这些后缀不提供纯文本选项。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、`content`、`wrap`、`scrollportRef` 和标准 `useTabInfo`/`useResource` 钩子。内部滚动元素挂载 `scrollportRef`;卸载时恢复共享正文的滚动职责。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变。HTML、SVG 和未匹配的扩展名保留纯文本回退,与加载方式无关。 +文档实现在 `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。`binaryExtensions` 列出 `extensions` 中不可按文本阅读的后缀,这些后缀不提供纯文本选项。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、`content`、`wrap`、`scrollportRef`、`addResource`、`setResources` 和标准 `useTabInfo`/`useResource` 钩子。依赖成员由 tab 持有的 `ResourceGroup` 管理;替换依赖始终保留根 Resource。内部滚动元素挂载 `scrollportRef`;卸载时恢复共享正文的滚动职责。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变。HTML、SVG 和未匹配的扩展名保留纯文本回退,与加载方式无关。 -`loading: 'text-pages'` 和 `'bytes-complete'` 使用共享文件读取器。选择 `'renderer'` 时,所选正文在读取任何字节前挂载,并接收 `content: { kind: 'renderer', revision, loaded, reload }`。其注入回调负责内容加载、错误和取消。`loaded(version)` 为共享变更提示报告已展示的源版本;已被替换的 revision 所发出的报告会被忽略。`reload()` 增加 revision,正文据此取消并替换当前请求。正文也在卸载和 tab 关闭时取消请求,将已完成内容保留在自己声明的 tab store 中,并在 tab 结束时释放。[Office 预览](#office-preview) 使用此模式,转换后的字节和字体元数据不会进入共享文件 store。 +`loading: 'text-pages'` 和 `'bytes-complete'` 使用共享文件读取器。选择 `'renderer'` 时,所选正文在读取任何字节前挂载,并接收 `content: { kind: 'renderer', revision, loaded, failed, reload }`。其注入回调负责内容加载、错误和取消。`loaded(version)` 记录已展示的源版本并结束加载。`failed()` 结束加载但不记录成功版本,使后续文件变化可以重试;已被替换的 revision 所发出的报告会被忽略。`reload()` 增加 revision,正文据此取消并替换当前请求。正文也在卸载和 tab 关闭时取消请求,将已完成内容保留在自己声明的 tab store 中,并在 tab 结束时释放。[Office 预览](#office-preview) 使用此模式,转换后的字节和字体元数据不会进入共享文件 store。 ## 地址 @@ -43,16 +43,16 @@ tab 使用 `fileAddressFor` 构造的 Session 地址,携带相对或绝对路 ## 怎么读 -Web 和桌面端均通过开发者工具选择 HTML 预览策略。渲染器从插件组装层接收 `interactivePreview`。关闭时,将经 DOMPurify 清理的完整静态文档放入不授予沙箱权限的 iframe:CSP 禁止脚本、外部资源、连接、表单和嵌套框架;所有 `href` 和 `xlink:href` 属性、刷新指令及声明式 Shadow DOM 均在重新解析前被移除。行内样式和 data 图片仍可显示,不读取关联文件。开启时使用下述支持脚本的 Blob 预览。切换模式会卸载之前的框架并中止其待处理关联文件读取。其他文档格式保持各自策略。 +Web 和桌面端均通过开发者工具选择 HTML 预览策略。渲染器从插件组装层接收 `interactivePreview`。关闭时,将经 DOMPurify 清理的完整静态文档放入不授予沙箱权限的 iframe:CSP 禁止脚本、外部资源、连接、表单和嵌套框架;所有 `href` 和 `xlink:href` 属性、刷新指令及声明式 Shadow DOM 均在重新解析前被移除。行内样式和 data 图片仍可显示,不读取关联文件。开启时使用下述支持脚本的 Blob 预览。切换模式会卸载之前的框架并中止其待处理关联文件读取。静态预览释放 CSS/JS 的 Resource 订阅,根文件继续监听。其他文档格式保持各自策略。 正文通过 `useTabInfo().tab` 读取记录、导航和生命周期。`useResource<'file'>(tab.contentId)` 提供元数据,普通 inject 回调提供内容读取: -- 资源快照仅包含 `status`、`value` 和 `failure`;`value` 是 `WorkspaceFileStat` 元数据。提供方可用后,内容读取无需等待首个元数据帧。观察失败优先于 Preview 的变更提示显示;两者都不会自动替换已加载内容。 +- 资源快照仅包含 `status`、`value` 和 `failure`;`value` 是 `WorkspaceFileStat` 元数据。提供方可用后,内容读取无需等待首个元数据帧。观察失败优先于 Preview 的变更提示显示;元数据不可用时保留已加载内容。 - **文本页** —— 纯文本、Markdown 和代码通过 inject 回调调用 `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`。首次挂载读取第一页;滚动到正文末尾或点击 **加载更多** 会读取下一页,直到 `eof`。owner 以 `{ kind: 'text', text, pages, eof }` 提供累计前缀,包含源码偏移和行数。Markdown 和代码增量渲染此前缀,不把每页当成独立文档。第一页之后到达的更新版本页会使读取从头开始,避免混合版本。尚无内容时,失败会以文件类型图标、说明和该失败对应的出路(重试、unpreviewable 子 slot,或什么都不给)填满正文;较晚的失败保留已有内容并在其下提供重试。 - **完整字节** —— PDF、HTML 和常见图片通过 inject 回调调用 `remote.workspaceFiles.readAll(sessionId, path, signal)`。`rpc.ts` 将线路上的 base64 解码为 `data: Uint8Array`,供 `{ kind: 'bytes', data }` 使用。Host 的 `maxFileBytes` 上限拒绝超大文件,不截断。PDF 在传给 worker 前复制保留的字节,使 Preview 缓冲区仍可使用。字节仅保存在临时视图状态中,绝不进入持久布局或 Session JSONL。加载模式变化会淘汰先前结果。 -- **重新载入** —— 仅当前 Preview tab 通过自己的 Remote 回调重读,保留滚动偏好并淘汰旧请求。变更提示将读取版本及起读时的观察版本与后续 `resource.value.version` 比较;刷新前已观察到的版本不会被当成新变化。读取既不刷新共享元数据,也不清除其它 tab 的提示。 +- **重新载入** —— 手动重新载入仅让当前 Preview tab 通过自己的 Remote 回调重读,保留滚动偏好并淘汰旧请求。ResourceGroup 成员变化后,自动刷新使用相同回调。各成员首次元数据仅建立基线,不触发重新载入或首读版本对账;后续在读取期间收到的变化仍会留待下一次刷新。读取既不刷新共享元数据,也不清除其它 tab 的提示。 -开启开发者工具时,HTML 以贴合正文四边的 Blob iframe 运行,沙箱属性严格为 `sandbox="allow-scripts"`,不含 `allow-same-origin`;脚本无法访问父应用的源或文件读取接口。渲染器通过普通 inject 回调调用 `remote.workspaceFiles.readRelated`,加载直接声明的相对 `.js` 经典脚本和 `.css` 样式表;固定安全上限为单个资源 4 MiB、总计 32 MiB、64 个不同资源。Host 代码解析关联路径,`rpc.ts` 解码返回的字节。在渲染器内部,base64 仅用于把 iframe 引导载荷嵌入脚本文本。`` 将依赖解析交给浏览器,HTTPS 资源也由浏览器处理。本地模块 import、CSS `url()`/`@import` 和动态 `fetch` 不使用 Host 文件访问。读取失败、无效 UTF-8 或超出上限都使预览失败,不发布部分资源包。替换或卸载文档会释放其 Blob URL。 +开启开发者工具时,HTML 以贴合正文四边的 Blob iframe 运行,沙箱属性严格为 `sandbox="allow-scripts"`,不含 `allow-same-origin`;脚本无法访问父应用的源或文件读取接口。渲染器通过普通 inject 回调调用 `remote.workspaceFiles.readRelated`,加载直接声明的相对 `.js` 经典脚本和 `.css` 样式表;固定安全上限为单个资源 4 MiB、总计 32 MiB、64 个不同资源。Host 代码解析关联路径,`rpc.ts` 解码返回的字节。依赖 Resource 在 `readRelated` 返回后加入,使用返回的 `absolutePath`,失败时则使用字符串类型的 `error.details.path`;没有 Host 路径时,Client 不自行猜测。在渲染器内部,base64 仅用于把 iframe 引导载荷嵌入脚本文本。`` 将依赖解析交给浏览器,HTTPS 资源也由浏览器处理。本地模块 import、CSS `url()`/`@import` 和动态 `fetch` 不使用 Host 文件访问。读取失败、无效 UTF-8 或超出上限都使预览失败,不发布部分资源包。替换或卸载文档会释放其 Blob URL。 PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态图片上下文中渲染,带 12px 内边距和圆角。宽图按固有纵横比缩小到面板宽度,小图按固有 CSS 像素尺寸居中,超高图纵向滚动。渲染器既不提供缩放,也不提供拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 Blob URL。 diff --git a/packages/client/ui-sidebar-documentpreview/package.json b/packages/client/ui-sidebar-documentpreview/package.json index 5d5f18604a..2a30d10fac 100644 --- a/packages/client/ui-sidebar-documentpreview/package.json +++ b/packages/client/ui-sidebar-documentpreview/package.json @@ -30,6 +30,7 @@ "inject": [ "@deepseek-ai/dsh-api-gateway", "@deepseek-ai/dsh-api-workspace-files", + "@deepseek-ai/dsh-client-resources", "@deepseek-ai/dsh-client-ui-sidebar-right", "@deepseek-ai/dsh-client-ui-session", "@deepseek-ai/dsh-api-remotes", diff --git a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx index b6489e1570..34efd79edb 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx @@ -3,9 +3,8 @@ * * Two sources meet here. The standard `useResource` hook gives the file's * metadata — its version — and this type's - * own store holds the content it read through its face. A Host-reported change is - * announced, not applied: reloading under a reader would lose their place, so - * the bar waits for a click. A failed metadata frame — the file gone, its + * own store holds the content it read through its face. Metadata changes reload + * the current preview while automatic refresh is enabled. A failed metadata frame — the file gone, its * workspace unknown — takes the same bar's place over the pages already loaded, * with the same reload. The type's controls, viewer choice, wrap and reload, sit at the end of * the path row; the Sidebar's strip carries none of them. @@ -224,6 +223,7 @@ export function TextPreview({ if (current === undefined) return undefined const revision = current.loadRevision return { kind: 'renderer', revision, reload: rendererReload, + failed: () => { actions.rendererFailed(tab.id, revision) }, loaded: (version) => { actions.rendered(tab.id, revision, version) } } } if (mode === 'bytes-complete') { diff --git a/packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts b/packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts index a9a55f143a..a323ab30af 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts @@ -23,13 +23,17 @@ export type DocumentContent = readonly revision: number /** Report the displayed source version; stale revisions cannot update the owner. @param version - loaded source version. */ readonly loaded: (version: string) => void + /** End a failed load; a later file change can start another revision. */ + readonly failed: () => void /** Cancel the current load and start a new revision. */ readonly reload: () => void } /** Content and viewing inputs shared by document bodies and nested PDF presentation. */ export interface DocumentBodyOwner { + /** Observe a file read by this renderer. @param address - complete file resource address. */ readonly addResource: (address: string) => void + /** Replace this renderer's dependencies. @param addresses - complete file resource addresses. */ readonly setResources: (addresses: readonly string[]) => void /** Original file address, also readable through the standard useResource hook. */ readonly resourceAddress: string diff --git a/packages/client/ui-sidebar-documentpreview/src/client/document/resource-group.ts b/packages/client/ui-sidebar-documentpreview/src/client/document/resource-group.ts index 8cd7fce5fd..4f3eb70daf 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/document/resource-group.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/document/resource-group.ts @@ -10,7 +10,7 @@ export class ResourceGroup { constructor(private readonly resources: Resources, private readonly changed: () => void) {} /** - * Subscribe once; initial metadata and later changes invalidate the preview. + * Subscribe once; initial metadata establishes the baseline for later changes. * @param address - complete resource address. */ add(address: string): void { @@ -21,7 +21,7 @@ export class ResourceGroup { const next = source.getSnapshot() as ResourceSnapshot if (next.status !== 'live' && next.status !== 'failed') return const key = next.status === 'live' ? `live:${next.value?.version}` : `failed:${next.failure?.code}` - const changed = key !== previous + const changed = previous !== undefined && key !== previous previous = key if (changed) this.changed() } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/face.ts b/packages/client/ui-sidebar-documentpreview/src/client/face.ts index a95ae1c89c..17dda43da3 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/face.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/face.ts @@ -27,7 +27,9 @@ import { ResourceGroup } from './document/resource-group.ts' /** The preview's injected business face, as the body receives it. */ export interface TextInjected { + /** Observe one renderer dependency. @param tabId - owning tab. @param address - resource address. @param signal - tab lifetime. */ readonly addResource: (tabId: TabId, address: string, signal: AbortSignal) => void + /** Replace dependency membership. @param tabId - owning tab. @param addresses - resource addresses. @param signal - tab lifetime. */ readonly setResources: (tabId: TabId, addresses: readonly string[], signal: AbortSignal) => void /** * Read one page into the store. A page of a newer file version than the pages diff --git a/packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx b/packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx index 83ebc9dbe9..85a5dca572 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx @@ -78,6 +78,9 @@ export function HtmlBody({ }: HtmlBodyProps): ReactNode { const interactivePreview = useInteractivePreview(value => value) const { tab } = useTabInfo() + useEffect(() => { + if (!interactivePreview) setResources([]) + }, [interactivePreview, setResources]) if (content.kind !== 'bytes') return null if (!interactivePreview) return return { controller.abort() } }, [revision, resourceAddress, tab.id, tab.signal, read, actions, describeFailure, settled]) const file = view?.file - useEffect(() => { if (file !== undefined) request?.loaded(file.version) }, [file, request?.loaded]) + useEffect(() => { + if (request === undefined) return + if (file !== undefined) request.loaded(file.version) + else if (view?.failure !== undefined) request.failed() + }, [file, view?.failure, request]) if (request === undefined) return null if (view?.failure !== undefined) { const { name } = pathPartsOf(resourceAddress) diff --git a/packages/client/ui-sidebar-documentpreview/src/client/store.ts b/packages/client/ui-sidebar-documentpreview/src/client/store.ts index 755b523867..9668b4ea86 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/store.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/store.ts @@ -101,6 +101,7 @@ type TextActions = { 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 + rendererFailed: (draft: TextState, tabId: TabId, revision: number) => void complete: (draft: TextState, tabId: TabId, file: DocumentFileBytes) => void page: (draft: TextState, tabId: TabId, page: WorkspaceFileText) => void failed: (draft: TextState, tabId: TabId, failure: RemoteFailure) => void @@ -159,6 +160,12 @@ export function createTextStore(): EngineStoreHandle { state.version = version state.loading = false }, + /** @param d - draft. @param tabId - owning tab. @param revision - failed content revision. */ + rendererFailed: (d, tabId: TabId, revision: number) => { + const state = d.byTab[tabId] + if (state?.mode !== 'renderer' || state.loadRevision !== revision) return + state.loading = false + }, /** @param d - draft. @param tabId - owning tab. @param file - complete byte result for this view. */ complete: (d, tabId: TabId, file: DocumentFileBytes) => { const state = bucket(d, tabId) diff --git a/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx index 20c3329a0d..f938754af5 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx @@ -104,7 +104,6 @@ describe('document extension seat', () => { await waitFor(() => { expect(h.view.container.querySelectorAll('[data-textpreview-line]')).toHaveLength(2) }) expect(h.read.mock.calls).toEqual([ ['address-session', path, { offset: 1 }, expect.any(AbortSignal)], - ['address-session', path, { offset: 1 }, expect.any(AbortSignal)], ]) expect(h.bytes).not.toHaveBeenCalled() }, @@ -134,7 +133,7 @@ describe('document extension seat', () => { Object.defineProperties(body, { clientHeight: { configurable: true, value: 100 }, scrollHeight: { configurable: true, value: 200 } }) fireEvent.scroll(body, { target: { scrollTop: 100 } }) await waitFor(() => { expect(h.view.container.querySelectorAll('[data-textpreview-line]')).toHaveLength(3) }) - expect(h.read.mock.calls.map(([_sessionId, _path, range]) => range.offset)).toEqual([1, 1, 3]) + expect(h.read.mock.calls.map(([_sessionId, _path, range]) => range.offset)).toEqual([1, 3]) }) it('defaults to an extension, switches implementations without changing tabs, and restores a builtin on removal', async () => { @@ -152,7 +151,7 @@ describe('document extension seat', () => { }) const tab = h.view.container.querySelector('[data-renderer-tab]')?.getAttribute('data-renderer-tab') expect(h.read).not.toHaveBeenCalled() - expect(h.bytes).toHaveBeenCalledTimes(2) + expect(h.bytes).toHaveBeenCalledOnce() fireEvent.click(h.view.container.querySelector('[data-document-viewer-menu]')!) fireEvent.click(screen.getByRole('menuitem', { name: 'builtin-reader' })) await waitFor(() => { expect(h.view.container.querySelector('[data-renderer="builtin-reader"]')?.textContent).toBe('first\nsecond') }) @@ -174,6 +173,6 @@ describe('document extension seat', () => { }) await act(async () => { await remove!() }) await waitFor(() => { expect(h.view.container.querySelector('[data-document-markdown]')).not.toBeNull() }) - expect(h.read).toHaveBeenCalledTimes(2) + expect(h.read).toHaveBeenCalledOnce() }) }) diff --git a/packages/client/ui-sidebar-documentpreview/tests/html-body.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/html-body.client.spec.tsx index c0f2534125..be3a1a7869 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/html-body.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/html-body.client.spec.tsx @@ -68,8 +68,16 @@ describe('HtmlBody', () => { }) const root = createSnapshotStore(metadata('root-v1')) const dependency = createSnapshotStore(metadata('asset-v1')) + const release = vi.fn() + const dependencySource = { + getSnapshot: () => dependency.getSnapshot(), + subscribe: (listener: () => void) => { + const off = dependency.subscribe(listener) + return () => { off(); release() } + }, + } const resources: Resources = { - source: address => address === ADDRESS ? root : dependency, + source: address => address === ADDRESS ? root : dependencySource, register: () => () => {}, pin: () => {}, } const face = textFace(h.read, h.bytes, resources)(SESSION, h.instance.actions) @@ -82,10 +90,12 @@ describe('HtmlBody', () => { data: btoa(extension === 'css' ? 'body { color: red }' : 'window.loaded = true'), } }) const definition = { ...htmlBodyDefinition(() => 'HTML'), extensions: ['md'] } - const view = render( select([definition])} renderSlot={documentSlots((_key, owner) => )} />) + useTabInfo={previewProps.useTabInfo} useInteractivePreview={select => select(interactive)} readRelated={readRelated} />)} /> + const view = render(preview()) await waitFor(() => { expect(screen.getByTitle(en.frame)).toBeDefined() expect(h.instance.getSnapshot().byTab[TAB_ID]).toMatchObject({ loading: false, resourcesDirty: false }) @@ -93,6 +103,9 @@ describe('HtmlBody', () => { const previous = screen.getByTitle(en.frame) const reads = h.bytes.mock.calls.length const relatedReads = readRelated.mock.calls.length + expect(reads).toBe(1) + expect(relatedReads).toBe(1) + expect(create).toHaveBeenCalledOnce() readRelated.mockResolvedValueOnce({ ok: true, value: { absolutePath: `/workspace/asset.${extension}`, version: 'asset-v2', offset: 0, eof: true, data: btoa(extension === 'css' ? 'body { color: blue }' : 'window.loaded = false'), @@ -103,6 +116,18 @@ describe('HtmlBody', () => { expect(readRelated).toHaveBeenCalledTimes(relatedReads + 1) expect(h.instance.getSnapshot().byTab[TAB_ID]?.version).toBe('root-v1') expect(h.read).not.toHaveBeenCalled() + interactive = false + view.rerender(preview()) + const staticFrame = screen.getByTitle(en.frame) + expect(staticFrame.getAttribute('sandbox')).toBe('') + expect(release).toHaveBeenCalledOnce() + act(() => { dependency.set(metadata('asset-v3')) }) + expect(screen.getByTitle(en.frame)).toBe(staticFrame) + expect(h.bytes).toHaveBeenCalledTimes(reads + 1) + expect(readRelated).toHaveBeenCalledTimes(relatedReads + 1) + act(() => { root.set(metadata('root-v2')) }) + await waitFor(() => { expect(h.bytes).toHaveBeenCalledTimes(reads + 2) }) + expect(readRelated).toHaveBeenCalledTimes(relatedReads + 1) view.unmount() }) 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 e4c7656808..d043a1a579 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 @@ -1,6 +1,7 @@ /** HTML URL decoding stays local; ordinary Remote reads leave path resolution and authorization to the Host. */ import { describe, expect, it, vi } from 'vitest' import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' import { createReadHtmlRelative } from '../src/client/html/read-relative.ts' import type { ReadHtmlRelated } from '../src/client/html/read-relative.ts' @@ -12,15 +13,48 @@ 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, vi.fn()) + const addResource = vi.fn() + const read = createReadHtmlRelative(readRelated, ADDRESS, tab.signal, addResource) 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) + expect(addResource).toHaveBeenCalledExactlyOnceWith(sessionFileAddress('html', value.absolutePath)) expect(signal?.aborted).toBe(false) tab.abort() expect(signal?.aborted).toBe(true) }) + it('observes the Host-resolved dependency instead of a symlink and parent-segment spelling', async () => { + const value = { absolutePath: '/workspace/style.css', data: btoa('x'), version: 'v1', offset: 0, eof: true } + const readRelated = vi.fn().mockResolvedValue({ ok: true, value }) + const addResource = vi.fn() + const signal = new AbortController().signal + await createReadHtmlRelative(readRelated, ADDRESS, signal, addResource)('linked/../style.css', signal) + expect(addResource).toHaveBeenCalledExactlyOnceWith(sessionFileAddress('html', value.absolutePath)) + }) + + it('observes the Host-reported missing path so creation can invalidate the preview', async () => { + const path = '/workspace/missing.css' + const readRelated = vi.fn().mockResolvedValue({ + ok: false, error: new RemoteError('workspace-file/not-found', 'Missing dependency', { path }), + }) + const addResource = vi.fn() + const signal = new AbortController().signal + await expect(createReadHtmlRelative(readRelated, ADDRESS, signal, addResource)('linked/../missing.css', signal)) + .rejects.toThrow('Missing dependency') + expect(addResource).toHaveBeenCalledExactlyOnceWith(sessionFileAddress('html', path)) + }) + + it('does not guess a dependency path when the Host failure contains none', async () => { + const readRelated = vi.fn().mockResolvedValue({ + ok: false, error: new RemoteError('gateway/internal', 'Read failed', {}), + }) + const addResource = vi.fn() + const signal = new AbortController().signal + await expect(createReadHtmlRelative(readRelated, ADDRESS, signal, addResource)('asset.css', signal)).rejects.toThrow('Read failed') + expect(addResource).not.toHaveBeenCalled() + }) + it('refuses non-relative references and preserves Host permission failures', async () => { const readRelated = vi.fn().mockResolvedValue({ ok: false, error: new RemoteError('workspace-file/outside-workspace', 'outside workspace', { path: '../x.js' }), diff --git a/packages/client/ui-sidebar-documentpreview/tests/renderer-loading.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/renderer-loading.client.spec.tsx index 8d69cb1b4c..472a8192f2 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/renderer-loading.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/renderer-loading.client.spec.tsx @@ -163,19 +163,45 @@ it('aborts a superseded load and rejects its late bytes and version report', asy const previous = h.request() fireEvent.click(screen.getByRole('button', { name: 'reload' })) expect(h.pending[0]!.signal.aborted).toBe(true) + act(() => { previous.failed() }) + expect(h.h.instance.getSnapshot().byTab[TAB_ID]?.loading).toBe(true) await act(async () => { h.pending[1]!.deferred.resolve(result('v2')) }) await act(async () => { h.pending[0]!.deferred.resolve(result('v1')); previous.loaded('v1') }) expect(screen.getByText('PDF v2')).toBeTruthy() expect(h.h.instance.getSnapshot().byTab[TAB_ID]?.version).toBe('v2') }) +it.each(['declared', 'exception'] as const)('automatically retries a %s conversion failure only after another file change', async (kind) => { + const h = setup() + render() + await act(async () => { + if (kind === 'declared') h.pending[0]!.deferred.resolve({ + ok: false, error: new RemoteError('document-render/failed', 'Conversion failed', { reason: 'failed' }), + }) + else h.pending[0]!.deferred.reject(new Error('Conversion failed')) + }) + expect(screen.getByText('Conversion failed')).toBeTruthy() + expect(h.h.instance.getSnapshot().byTab[TAB_ID]?.loading).toBe(false) + expect(h.read).toHaveBeenCalledTimes(1) + act(() => { + h.h.setVersion('v2') + h.h.instance.actions.resourceChanged(TAB_ID) + }) + expect(h.read).toHaveBeenCalledTimes(2) + await act(async () => { h.pending[1]!.deferred.resolve(result('v2')) }) + expect(screen.getByText('PDF v2')).toBeTruthy() + expect(h.h.instance.getSnapshot().byTab[TAB_ID]?.loading).toBe(false) +}) + it.each(['replace', 'close', 'hide'] as const)('retires pending conversion on %s and ignores a late rejection', async (transition) => { const h = setup() h.h.bytes.mockResolvedValue({ ok: true, value: { absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0, eof: true, data: new Uint8Array() } }) const mounted = render() + const previous = h.request() if (transition === 'replace') mounted.rerender() else if (transition === 'close') act(() => { h.h.controller.abort() }) else mounted.unmount() + if (transition !== 'hide') act(() => { previous.failed() }) expect(h.pending[0]!.signal.aborted).toBe(true) await act(async () => { h.pending[0]!.deferred.reject(new Error('late error')) }) if (transition === 'close') { diff --git a/packages/client/ui-sidebar-documentpreview/tests/resource-group.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/resource-group.client.spec.ts index c1697d0578..3254e0d3f2 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/resource-group.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/resource-group.client.spec.ts @@ -26,10 +26,12 @@ function harness() { } describe('ResourceGroup', () => { - it('invalidates when initial metadata arrives after subscription', () => { + it('uses initial metadata as a baseline and invalidates only on later changes', () => { const h = harness() h.group.add('style.css') h.version('v2') + expect(h.changed).not.toHaveBeenCalled() + h.version('v3') expect(h.changed).toHaveBeenCalledOnce() h.group.close() }) @@ -39,6 +41,8 @@ describe('ResourceGroup', () => { h.group.add('style.css') h.version('v2') h.group.add('style.css') + expect(h.changed).not.toHaveBeenCalled() + h.version('v3') expect(h.changed).toHaveBeenCalledOnce() h.group.close() }) @@ -48,12 +52,12 @@ describe('ResourceGroup', () => { h.version('v1') h.group.add('style.css') h.version('v1') - expect(h.changed).toHaveBeenCalledOnce() + expect(h.changed).not.toHaveBeenCalled() h.version('v2') - expect(h.changed).toHaveBeenCalledTimes(2) + expect(h.changed).toHaveBeenCalledOnce() h.group.close() h.version('v3') - expect(h.changed).toHaveBeenCalledTimes(2) + expect(h.changed).toHaveBeenCalledOnce() }) it('shares membership and releases members absent from the next document', () => { @@ -77,10 +81,10 @@ describe('ResourceGroup', () => { failure: new RemoteError('workspace-file/not-found', 'File missing', { path: 'style.css' }), } h.state.set(failed) - expect(h.changed).toHaveBeenCalledTimes(2) + expect(h.changed).toHaveBeenCalledOnce() h.state.set({ ...failed }) - expect(h.changed).toHaveBeenCalledTimes(2) + expect(h.changed).toHaveBeenCalledOnce() h.version('v1') - expect(h.changed).toHaveBeenCalledTimes(3) + expect(h.changed).toHaveBeenCalledTimes(2) }) }) diff --git a/packages/client/ui-sidebar-files/README.i18n.yaml b/packages/client/ui-sidebar-files/README.i18n.yaml index fefd4561f0..07c7e770b5 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: efaf0a584e0e8617d0cb97442f9ebb5fa1fe7b59 -README.zh.md: a0d0abffcd6e9f4e979d9a6548537745d0a74ba6 +README.md: e52b298814479c5e45f28d6a1f3b6158534ccdf9 +README.zh.md: b6824c4b47dd75ae540691bd286e0c15ecc4ccca diff --git a/packages/client/ui-sidebar-files/README.md b/packages/client/ui-sidebar-files/README.md index efaf0a584e..e52b298814 100644 --- a/packages/client/ui-sidebar-files/README.md +++ b/packages/client/ui-sidebar-files/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The right Sidebar's navigator tab type: the session's workspace root as a tree, listed one level at a time over the wire, opening files into the Sidebar. It is a page type reached from the guide and claims no address; it opens files by address for the `dsh-resource://file` viewers to claim — nothing in `ui-sidebar-right` knows this package. +Browse a Session's workspace tree and open files in Sidebar previews. The root and expanded directories refresh automatically from direct-entry watches; manual reload remains available. The tab is reached from the guide and claims no resource address. ## Table of Contents @@ -25,25 +25,27 @@ The right Sidebar's navigator tab type: the session's workspace root as a tree, ## What it registers - **The type** — `ctx.sidebarRightTabs.register(...)` with kind `files`, id `@deepseek-ai/dsh-client-ui-sidebar-files`, band `builtin`, no patterns, and one guide entry (order 10, its title and description from the `sidebarFiles` namespace, its glyph the shared folder icon) that opens the type. -- **The body** — the keyed `sidebar.right.pane.tab` seat under that id: a header row under the strip, then the tree. The header row is the document preview's (`ui-sidebar-documentpreview`): the root path, its directories greyed and its last segment in full ink, never ellipsized (a path wider than the row keeps its end and fades its start), with the one control, reload, at its right. The row is copied rather than shared because a plugin bundle shares runtime code only through the platform modules; once the artifact and slot surfaces settle, one copy in `ui-primitives` could serve every pane header. +- **The body** — the keyed `sidebar.right.pane.tab` seat under that id: a header row under the strip, then the tree. The header row is the document preview's (`ui-sidebar-documentpreview`): the root path, its directories greyed and its last segment in full ink, never ellipsized (a path wider than the row keeps its end and fades its start), with the visible reload control at its right. The row is copied rather than shared because a plugin bundle shares runtime code only through the platform modules; once the artifact and slot surfaces settle, one copy in `ui-primitives` could serve every pane header. - **The chip title** — the keyed `sidebar.right.pane.tab.title` seat under that id: a shared `FileTypeIcon` folder glyph at 16px before the type's label. The tree's own rows never draw this sheet. -Seven source files under `src/client/`: `definition.tsx` (the type), `store.ts` (what it keeps), `face.ts` (how it lists, Remote binding included), `FilesBody.tsx` (what it draws, with its ordering and failure-line helpers), `FilesTitle.tsx` (the chip title), `locales.ts` (what it says), and `index.ts` (the wiring). +Source files under `src/client/`: `definition.tsx` (the type), `store.ts` (what it keeps), `face.ts` (Remote reads and watches), `directory-node.ts` (open directories and their lifetimes), `FilesBody.tsx` (what it draws, with its ordering and failure-line helpers), `FilesTitle.tsx` (the chip title), `locales.ts` (what it says), and `index.ts` (the wiring). ## The tree -The root is the session's working directory, read from `useSessions().byId[sessionId].cwd`, and split for the header row by `pathPartsOf` from `@deepseek-ai/dsh-util-workspace-path`. Every level is keyed by absolute path; a child's path is its parent's joined with the entry name by `/`. A level is listed when it is first expanded, through `remote.workspaceFiles.list(sessionId, absolutePath)` on the `@deepseek-ai/dsh-api-workspace-files` namespace; the adapter keeps the listing's entries and truncation flag and drops its workspace-relative path. Rows are ordered directories first, then by natural, case-insensitive name; dotfiles are shown like any other entry. +The root is the session's working directory, read from `useSessions().byId[sessionId].cwd`, and split for the header row by `pathPartsOf` from `@deepseek-ai/dsh-util-workspace-path`. Filesystem roots such as `/` and Windows drive roots are valid tree roots. Every level is keyed by absolute path; a child's path is its parent's joined with the entry name by `/`. A level is listed when it is first expanded, through `remote.workspaceFiles.list(sessionId, absolutePath)` on the `@deepseek-ai/dsh-api-workspace-files` namespace; the adapter keeps the listing's entries and truncation flag and drops its workspace-relative path. Rows are ordered directories first, then by natural, case-insensitive name; dotfiles are shown like any other entry. | Entry type | Row | |---|---| -| `directory` | Toggles; the level is fetched the first time it opens and kept while collapsed. | +| `directory` | Toggles; reopening lists again and restores still-present expanded descendants. Displayed entries remain cached while collapsed. | | `file` | Opens `dsh-resource://file/session//`, built by `fileAddressFor` from `@deepseek-ai/dsh-util-workspace-path` from the entry's absolute path and the tree's root, through `useTabInfo().tab.actions.openResource`, landing in the tab's own pane. | | `other` | Shown greyed and not clickable, so the directory is reported whole. | -A level cut by the endpoint's entry cap ends with a marker; an empty level says so; a level that failed shows one line per code — `workspace-file/not-found`, `outside-workspace`, `not-directory` — and the transport's own message otherwise. Reload drops every listed level and asks again for the expanded ones; collapsed levels are fetched again when they next open. A session without a working directory shows a single line instead of a tree. +A level cut by the endpoint's entry cap ends with a marker; an empty level says so; a level that failed shows one line per code — `workspace-file/not-found`, `outside-workspace`, `not-directory` — and the transport's own message otherwise. Reload refreshes the root and expanded levels in place, retaining displayed entries during reads instead of resetting the whole tree; collapsed levels are fetched again when they next open. A session without a working directory shows a single line instead of a tree. -State lives in the type's own store, bucketed by tab id: `root`, `levels` (loading / ready / failed per absolute path), `expanded`, and `scrollTop`, which the body tracks locally while scrolling and commits once when it unmounts. Because the store outlives the body, switching to another sidebar tab and back remounts the tree with its levels intact and its scroll offset restored. The owner's `signal` ends a bucket: on abort the tab is forgotten, and neither a listing that settles afterwards nor the unmount's offset commit writes anything. +State lives 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. ## Model Experience @@ -57,7 +59,8 @@ None; directory listings travel over the Remote and assemble no model request. ## Known Limitations and Deferred Work -- **Listing only.** No search, artifact filter, drag-and-drop, rename, context menu, current-file highlight, or filesystem watching; a level changes only through reload. +- **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. @@ -70,4 +73,4 @@ None. -**Runtime invariant:** No companion is published. The tree's only runtime state is one Slot store per tab, written by the body that owns it and forgotten on the tab's abort signal; there is no second observation of it to compare against. +**Runtime invariant:** No companion is published. The tab-owned face keeps directory reads and watches private, and writes displayed state through its Slot store; tab cancellation releases both. The package exposes no independent observation for a runtime comparison. diff --git a/packages/client/ui-sidebar-files/README.zh.md b/packages/client/ui-sidebar-files/README.zh.md index a0d0abffcd..b6824c4b47 100644 --- a/packages/client/ui-sidebar-files/README.zh.md +++ b/packages/client/ui-sidebar-files/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -右侧 Sidebar 的导航器 tab 类型:把会话的工作区根目录画成一棵树,逐层经线上列出,并把文件打开到 Sidebar 里。它是从引导页进入的页类型,不认领任何地址;它按地址打开文件,交给 `dsh-resource://file` 的查看器认领:`ui-sidebar-right` 里没有任何东西认识本包。 +浏览 Session 的工作区目录树,并在 Sidebar 预览中打开文件。根目录与已展开目录通过直接子项监听自动刷新,手动重新读取仍可用。该 tab 从引导页进入,不认领资源地址。 ## 目录 @@ -25,25 +25,27 @@ kind: "package-reference" ## 注册了什么 - **类型**:`ctx.sidebarRightTabs.register(...)`,kind 为 `files`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-files`,档位 `builtin`,没有 patterns,另有一个打开该类型的引导页入口(order 10,标题与描述取自 `sidebarFiles` 命名空间,图标是共享的文件夹图标)。 -- **正文**:以该 id 为键的 `sidebar.right.pane.tab` slot:strip 下的一行标题行,然后是树。标题行与文档预览(`ui-sidebar-documentpreview`)的相同:根路径,目录部分灰色、最后一段正色,从不省略号截断(比行宽的路径保留末尾、淡出开头),右端是它唯一的控件、重新读取。这一行是复制而非共享,因为插件 bundle 只经平台模块共享运行时代码;待 artifact 与各 slot 的形态定下来后,可以在 `ui-primitives` 放一份供每个 pane 标题行使用。 +- **正文**:以该 id 为键的 `sidebar.right.pane.tab` slot:strip 下的一行标题行,然后是树。标题行与文档预览(`ui-sidebar-documentpreview`)的相同:根路径,目录部分灰色、最后一段正色,从不省略号截断(比行宽的路径保留末尾、淡出开头),右端显示重新读取控件。这一行是复制而非共享,因为插件 bundle 只经平台模块共享运行时代码;待 artifact 与各 slot 的形态定下来后,可以在 `ui-primitives` 放一份供每个 pane 标题行使用。 - **标签页标题**:以该 id 为键的 `sidebar.right.pane.tab.title` slot:类型标签前的一枚 16px 共享 `FileTypeIcon` 文件夹图标。树本身的行不画这枚图标。 -`src/client/` 下七个源文件:`definition.tsx`(类型是什么)、`store.ts`(它保存什么)、`face.ts`(它如何列目录,含 Remote 绑定)、`FilesBody.tsx`(它画什么,含排序与失败行两个辅助函数)、`FilesTitle.tsx`(标签页标题)、`locales.ts`(它说什么)、`index.ts`(接线)。 +`src/client/` 下的源文件:`definition.tsx`(类型是什么)、`store.ts`(它保存什么)、`face.ts`(Remote 读取与监听)、`directory-node.ts`(打开的目录及其生命周期)、`FilesBody.tsx`(它画什么,含排序与失败行两个辅助函数)、`FilesTitle.tsx`(标签页标题)、`locales.ts`(它说什么)、`index.ts`(接线)。 ## 树 -根是会话的工作目录,读自 `useSessions().byId[sessionId].cwd`,标题行里的拆分由 `@deepseek-ai/dsh-util-workspace-path` 的 `pathPartsOf` 给出。每一层以绝对路径为键;子路径是父路径以 `/` 拼上条目名。一层在首次展开时经 `@deepseek-ai/dsh-api-workspace-files` 命名空间的 `remote.workspaceFiles.list(sessionId, absolutePath)` 列出;适配器保留列表的条目与截断标志,丢弃其工作区相对路径。行序为目录优先,其后按自然序、不分大小写的名称排列;dotfiles 与其他条目一样显示。 +根是会话的工作目录,读自 `useSessions().byId[sessionId].cwd`,标题行里的拆分由 `@deepseek-ai/dsh-util-workspace-path` 的 `pathPartsOf` 给出。`/` 和 Windows 盘根等文件系统根路径均可作为树的根。每一层以绝对路径为键;子路径是父路径以 `/` 拼上条目名。一层在首次展开时经 `@deepseek-ai/dsh-api-workspace-files` 命名空间的 `remote.workspaceFiles.list(sessionId, absolutePath)` 列出;适配器保留列表的条目与截断标志,丢弃其工作区相对路径。行序为目录优先,其后按自然序、不分大小写的名称排列;dotfiles 与其他条目一样显示。 | 条目类型 | 行 | |---|---| -| `directory` | 切换展开与折叠;该层在首次打开时拉取,折叠期间保留。 | +| `directory` | 切换展开与折叠;再次打开时重新列举,并恢复仍存在的已展开后代。折叠期间保留已显示条目的缓存。 | | `file` | 经 `useTabInfo().tab.actions.openResource` 打开 `dsh-resource://file/session//`,地址由 `@deepseek-ai/dsh-util-workspace-path` 的 `fileAddressFor` 从条目的绝对路径与树的根生成,落在该 tab 自己的 pane 里。 | | `other` | 灰显且不可点击,从而完整呈现目录内容。 | -被端点条目上限截断的层以一条标记收尾;空层如实说明;失败的层按错误码各显示一行(`workspace-file/not-found`、`outside-workspace`、`not-directory`),其他情况显示传输层自己的消息。重新读取丢弃所有已列出的层并只对展开中的层重新请求;折叠的层在下次打开时重新拉取。没有工作目录的会话只显示一行说明,而不是树。 +被端点条目上限截断的层以一条标记收尾;空层如实说明;失败的层按错误码各显示一行(`workspace-file/not-found`、`outside-workspace`、`not-directory`),其他情况显示传输层自己的消息。重新读取就地刷新根与展开中的层,读取期间保留显示条目,不重置整棵树;折叠的层在下次打开时重新拉取。没有工作目录的会话只显示一行说明,而不是树。 -状态保存在类型自己的存储里,按 tab id 分桶:`root`、`levels`(每个绝对路径的 loading / ready / failed)、`expanded` 与 `scrollTop`——滚动期间偏移由正文自己记录,卸载时一次性写入。存储比 body 活得久,切到其他侧栏 tab 再切回来时树带着已加载的层重新挂载,滚动位置也随之恢复。owner 的 `signal` 终结一个桶:中止时忘掉该 tab,其后才结算的列表与卸载时的偏移提交都什么也不写。 +状态保存在类型自己的存储里,按 tab id 分桶:`root`、`levels`(每个绝对路径的 loading / ready / failed)、`expanded`、`autoRefresh` 与 `scrollTop`——滚动期间偏移由正文自己记录,卸载时一次性写入。存储比 body 活得久,切到其他侧栏 tab 再切回来时树带着已加载的层重新挂载,滚动位置也随之恢复。owner 的 `signal` 终结一个桶:中止时忘掉该 tab,其后才结算的列表与卸载时的偏移提交都什么也不写。 + +每个打开的 `DirectoryNode` 在 Tab 生命周期内持有自己的目标监听;折叠关闭该节点及其不可见后代。自动刷新默认开启;独立开关暂时隐藏,状态、文案、样式与切换逻辑保留。目录读取期间或收尾时收到的变化仍会留待下一次刷新。 ## 模型体验 @@ -57,7 +59,8 @@ kind: "package-reference" ## 已知限制与暂缓事项 -- **只有列目录。**没有搜索、产物过滤、拖拽、重命名、右键菜单、当前文件高亮或文件系统监听;一层只会因重新读取而变化。 +- **只有列目录。**没有搜索、产物过滤、拖拽、重命名、右键菜单或当前文件高亮。 +- **恢复期间的点击。** 父节点尚在恢复时,深层后代的折叠点击可能被忽略;该交互修复仍延期。 - **只有一个根。**树以会话工作目录为根;没有办法浏览到它之上,而 Host 本来也拒绝工作区根之外的路径。 @@ -70,4 +73,4 @@ kind: "package-reference" -**运行时不变量:** 不发布 companion。树唯一的运行时状态是每 tab 一份的 Slot store,由持有它的正文写入、随 tab 的中止信号忘掉;没有第二个观测源可与之比对。 +**运行时不变量:** 不发布 companion。tab 持有的 face 内部管理目录读取与监听,并通过 Slot store 写入显示状态;tab 取消时释放二者。本包未暴露可用于运行时比对的独立观测源。 diff --git a/packages/client/ui-sidebar-files/src/client/FilesBody.tsx b/packages/client/ui-sidebar-files/src/client/FilesBody.tsx index ba33b433c4..3b707f94a4 100644 --- a/packages/client/ui-sidebar-files/src/client/FilesBody.tsx +++ b/packages/client/ui-sidebar-files/src/client/FilesBody.tsx @@ -7,8 +7,7 @@ * file opens through the owner's `tabActions` for a `file:` viewer to claim, and * anything else is shown but refuses to open. The header row is the text * preview's: the root's path, directories greyed and the last segment in full - * ink, then the one control at its end, reload, which drops every listed level - * and asks again for the expanded ones. + * ink, then reload, which refreshes the expanded directories in place. */ import { useEffect, useLayoutEffect, useRef } from 'react' import type { ReactNode, RefObject } from 'react' @@ -104,7 +103,7 @@ function usePathClipped( /** What every level shares: the tab's tree and the two gestures. */ interface TreeContext { readonly state: FilesTabState - readonly onToggle: (path: string) => void + readonly onToggle: (parent: string, path: string) => void readonly onOpen: (path: string) => void readonly t: TranslateNS<'sidebarFiles'> } @@ -116,7 +115,7 @@ function Entry({ parent, entry, tree }: { parent: string; entry: WorkspaceDirect const expanded = tree.state.expanded.includes(path) return (
  • - @@ -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',