Merge pinned master 5ec3ffe337 into session-log-v4

Keep the master refresh separate from the V3-to-V4 lossless-conversion and
checkpoint JSON fixes so the release baseline and each feature change remain
independently reviewable. This checkpoint extends the already-published
4435f6f519 merge before the #4583 child is rebased.

Integrate exactly master 5ec3ffe337062ab5bb4955933008afe8d0a0b915. Its #4156
changes add watched file/document refresh, filesystem watch readiness,
directory expansion restoration, and chat bottom-follow fixes. They update
shared filesystem and Client consumers without changing Session format or
snapshot generation.

The merge has no conflicts. Its 114-file scope and line counts match upstream;
code and prose additions/removals are unchanged. Only the two translation
pairing records incorporate the release branch's retained V4 documentation.

Validation: 762 owner tests passed (1 platform skip), 11 focused Web tests
passed, full build passed, and all 41 doc-sync checks passed. Runtime closure
and Client package checks passed; fs-local publint completed with the existing
source-wildcard export warning.
This commit is contained in:
Tianyi Cui committed 2026-09-19 02:39:04 +08:00
commit 87f0df0550
114 files changed
+4676 -1101

No files matched your search

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md
2026-09-17-sidebar-file-and-directory-auto-refresh.md: f33be6a8d45756d3bac83d0a6e21a4110543e3b2
2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md: 88fe6e7abb4027ec8b5c4fab4f1ad07477aef6fa
@@ -0,0 +1,345 @@
# Agent Note: On-demand watching and automatic refresh for Sidebar previews and file trees
Status: implemented
English | [中文](2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md)
## Problem
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.
The starting implementation has both read paths but no complete automatic-refresh path.
| 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 |
| Workspace file events | `WorkspaceFiles.changes()` forwards the current Session's `fs/observed`; the Client filters by file | The Host does not know which paths are followed; external editors and shell writes produce no such events |
| Files panel | `filesFace` lists directories; `createFilesStore` retains loaded levels and expansion state | Creation, deletion, and renaming require manual refresh; reopening an expanded directory can show old cached entries |
`Resource` holds file metadata, not preview contents. Document Preview or its selected renderer reads the contents. Metadata updates and content reloads therefore remain separate responsibilities: the generic Resource acquires no content cache, and renderers do not directly manage filesystem watchers.
## Decision
On-demand observation connects the existing FS, Workspace file stream, Resource, and Sidebar components. Files and directories share OS watching and the existing Remote stream transport, but consume them differently.
- Document Preview follows the current file. Its Resource publishes new metadata, and the preview invokes its existing reload. The HTML root and referenced CSS/JS files join one `ResourceGroup`; any member change invalidates the whole HTML preview.
- Files manages reads and watchers through a directory-node tree. Each open node loads and watches only its direct entries. The Client loads and subscribes to exactly the subtree levels it opens, without traversing unopened descendants.
- Collapsing a directory releases that directory's watch and those of its hidden descendants. Reopening subscribes and reads again; the old cache is not treated as current.
- Watches carry only metadata or invalidation. Contents still use the existing file readers; directory contents still use `list()`.
- Local Chokidar watches use OS events, without polling the whole Workspace or recursively scanning all descendants by default.
- Both panels retain manual refresh and automatic-refresh functionality, but temporarily hide the automatic-refresh icon button. State and toggle logic remain; new Tabs default to enabled. Hiding the control does not stop observation or automatic refresh.
Notifications invoke existing reload/list operations; a failed reload does not retain previous preview content. The following sections describe the implementation's responsibilities, data flow, and verification.
## Implementation and responsibilities
### FS and Host
| Class or type | File | Responsibility |
|---|---|---|
| `FileSystem` | [fs/src/index.ts](../../../../packages/fs/fs/src/index.ts) | Declare abstract single-target `watch(target, changed, signal)`: a file observes itself; a directory observes its direct entries. Resolve with an async close function once ready. Unsupported providers explicitly throw, without a new FS error code |
| `FsTarget` | [fs/src/types.ts](../../../../packages/fs/fs/src/types.ts) | Reuse the existing target type, without another FS watch-event system or upper-layer parsing of `targetKey` |
| `LocalFileSystem` | [fs-local/src/index.ts](../../../../packages/fs/fs-local/src/index.ts) | Watches a file's parent with target-only filtering, or the directory itself for directory targets; awaits Chokidar readiness and closure; owns the Chokidar dependency |
| `SandboxedFileSystem` | [fs-sandbox/src/index.ts](../../../../packages/fs/fs-sandbox/src/index.ts) | Inherit local read-only observation without duplicating the watcher; retain its write and edit policy checks |
| `SshFileSystem` | [fs-ssh/src/index.ts](../../../../packages/ssh/fs-ssh/src/index.ts) | `watch()` explicitly throws an ordinary exception; the Host converts it to a `workspace-file/watch-unsupported` `RemoteError`, without importing or checking an `FsError` runtime class across packages or passing remote `processPath()` values to local Chokidar |
| `WorkspaceFiles` | [workspace-files/src/index.ts](../../../../packages/api/workspace-files/src/index.ts) | Use `changes(scope, path, signal)` with only the target path. Host stat determines whether directory containment applies, including after type changes; ordinary files retain file-read authority |
| `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 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
| Class, function, or type | File | Responsibility |
|---|---|---|
| `ChangeFeed`, `SessionFeed`, `Follower` | [workspace-files/src/client/change-feed.ts](../../../../packages/api/workspace-files/src/client/change-feed.ts) | Key streams by Session and target path; reuse readiness, path binding, cancellation, and reconnect logic without reorganizing classes just to rename them |
| `createFileResourceProvider` | [workspace-files/src/client/provider.ts](../../../../packages/api/workspace-files/src/client/provider.ts) | Pass the address's Session and path to the target stream; publish complete metadata initially and after notifications, rather than replacing `version` while retaining stale `bytes` |
| `ResourceRegistry`, `ResourceProvider`, `UseResource` | [Resource definitions](../../../../packages/client/resources/src/client/contract.ts), [ResourceRegistry](../../../../packages/client/resources/src/client/resources.ts) | Keep existing streaming, retention counts, and subscriptions; add neither a second `onChange` nor a generic content-reload API |
| `TextPreview` | [TextPreview.tsx](../../../../packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx) | Invoke existing reload when a Resource or group changes and automatic refresh is enabled; retain errors and manual refresh, with the separate automatic-refresh button temporarily hidden |
| `textFace`, `TabReads`, `createTextStore` | [Preview face](../../../../packages/client/ui-sidebar-documentpreview/src/client/face.ts), [Preview store](../../../../packages/client/ui-sidebar-documentpreview/src/client/store.ts) | Own one group per Tab; add default-enabled `autoRefresh` and pending-refresh flag `resourcesDirty`; reuse content-read generations and `loadRevision` |
| Package-private `ResourceGroup` | [resource-group.ts](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/resource-group.ts) | Observes members through existing `resources.source(address)`; `add` joins a resource, `set` reconciles full membership, and `close` releases all members without changing the generic Resource service |
| `DocumentBodyOwner`, `HtmlBody`, `createReadHtmlRelative` | [Renderer inputs](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts), [HTML body](../../../../packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx), [Related-file reader](../../../../packages/client/ui-sidebar-documentpreview/src/client/html/read-relative.ts) | Renderers declare read members through `addResource` and publish the current dependency list through `setResources`; HTML adds CSS/JS resources from Host-reported paths after each read returns and releases unreferenced members after parsing |
| Document renderers | [DocumentContent](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) and renderer directories in the same package | Do not watch directly; keep consuming text, bytes, or renderer revisions; `failed()` ends renderer loading without recording a successful version. Check cancellation of previous work, release of old objects, and retention of existing view preferences during refresh |
| `filesFace`, `FilesInjected` | [Files face](../../../../packages/client/ui-sidebar-files/src/client/face.ts) | Remain the existing business entry point, directly owning each Tab's root node; bind directory reads and change streams and forward expand, collapse, refresh, and automatic-refresh operations without a new `FilesController` |
| Package-private `DirectoryNode` | [directory-node.ts](../../../../packages/client/ui-sidebar-files/src/client/directory-node.ts) | Represents one open directory, owning its stream, read state, and open children; handles local refresh, expansion, recursive closure, and propagation of automatic-refresh state; `setExpanded` updates pending restoration from the latest expansion preferences |
| `createFilesStore`, `FilesTabState`, `LevelState` | [Files store](../../../../packages/client/ui-sidebar-files/src/client/store.ts) | Retain expansion, scrolling, and displayed directory data; distinguish initial loading from refresh over existing content to avoid unmounting subtrees on every background read; hold no watcher or AbortController |
| `FilesBody`, `Level`, `Entry` | [FilesBody.tsx](../../../../packages/client/ui-sidebar-files/src/client/FilesBody.tsx) | Delegate expansion, collapse, refresh, and panel unmount to injected callbacks; render node results without scanning the entire tree into a watch set, using existing framework hooks and store reads |
| Files registration | [Files client/index.ts](../../../../packages/client/ui-sidebar-files/src/client/index.ts) | Add a directory-change callback to the existing `filesFace`; keep async effects in injection, without importing another feature plugin's runtime exports |
## Watch requests and notifications
### One stream per explicit target
Each request names one target path. The Host determines its current type through `stat`; the Client supplies no file/directory kind.
```text
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.
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 |
|---|---|---|
| Ready | This generation's target watch is established | Perform initial or reconnect `stat()`/`list()` and consume changes queued during the read |
| File present or updated | Existing path and version notification; Client stats again | Update Resource path, version, and size from the complete result; reread contents when automatic refresh is enabled |
| File absent | Target path and absence state | Resource reports unavailability; preview retains loaded content and shows the error |
| Directory invalidated | `change` frame in that directory's target stream | Call only that directory's `list()` again, without deduplicating by directory version or refreshing the entire tree |
| Watch failure | Initialization failure reports `workspace-file/watch-unsupported`; runtime errors end the watch | The Client handles failure where it consumes the watch; ordinary reads and manual refresh remain available, without reporting file deletion |
Notifications need neither file contents, complete directory listings, nor per-change patches. Paths identify filenames; current stat supplies size. Modification time used only for freshness remains part of the backend version token, without UI token parsing or another timestamp comparison.
Resource path, version, and size come from the same stat. Keep the existing small notification followed by Client stat instead of expanding the event protocol to save one request. Directory invalidation cannot depend only on directory mtime: a direct child's size or type can change `list()` output, so relevant child events invalidate that level.
### OS watching and access scope
- `FileSystem.watch()` accepts a target resolved by its provider; the FS provider owns the meaning of local or remote paths.
- File watching retains file-read access rules. Existing previews may read some paths outside the Workspace through the selected FS; directory-tree containment must not be imposed on those files.
- Directory watching retains `list()`'s Workspace scope and path checks, including after target type changes, without widening browsing authority.
- File watching covers in-place writes, replacement by a temporary file, deletion, and recreation at the same path while the parent directory remains. The local provider watches that parent directly, filters entries and events to the target file, and waits for the parent watcher to be ready before reporting readiness, including when the file does not yet exist.
- A directory watch observes itself and direct entries, without recursively opening unexpanded children. Creation, deletion, renaming, type changes, and visible metadata changes of direct entries invalidate that level.
- Local watching uses OS events. Deployment controls such as write-stability time or event-coalescing windows, if needed, belong in their owner's configuration rather than scattered component constants.
- OS watching drives UI refresh only. It does not fabricate an agent's authoritative `fs/observed` read observation or change the observed-version policy for editing.
## Complete file-preview data flow
```text
Files.openResource / file.link
→ Sidebar.openTab
→ TabDomain → Resource.pin
→ ResourceRegistry → createFileResourceProvider.open
→ changes(sessionId, path)
→ WorkspaceFiles → FsTarget
→ WorkspaceChangeFeed → FileSystem.watch
→ LocalFileSystem → Chokidar
→ ready → Client.stat → ResourceSnapshot
Editor / Shell / file tool → disk
→ Chokidar → LocalFileSystem
→ WorkspaceChangeFeed → FileSystem.stat
→ Remote.change
→ Client.stat → ResourceSnapshot
→ ResourceGroup → TextPreview
→ reloadPages / reloadAll / prepareRenderer
→ DocumentContent / renderer revision
→ renderer
```
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. 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.
## HTML ResourceGroup
An HTML preview's group contains the HTML root and CSS/JS files actually read by the existing packer. Every member is an ordinary file Resource, with no separate dependency watcher or file type. The group is package-private and reuses Resource subscriptions and retention counts.
```text
index.html Resource ─┐
theme.css Resource ──┼→ ResourceGroup → resourcesDirty → autoRefresh → HTML reload
page.js Resource ───┘
```
- `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 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 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. 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 does not change its state or stop observation.
| State or action | Behavior |
|---|---|
| Automatic refresh enabled | The control is highlighted, uses the existing pause icon, and offers “Disable auto refresh”; changes refresh the corresponding preview or directory node |
| Disable | The control shows the play icon and offers “Enable auto refresh”; observation continues without automatically replacing displayed content |
| Reenable | Refresh once immediately if changes occurred while paused, then resume automatic refresh |
| Original reload button | Read again regardless of automatic-refresh state, without changing that state |
| Expand a new directory while paused | The new directory still loads initially and subscribes; the switch controls only subsequent automatic rereads |
Pause and play use existing icons; manual reload retains its circular arrow. No new icon system is added. The button exposes state through `aria-pressed`, with its name and tooltip in the existing locale dictionaries.
## Complete Files-panel data flow
```text
Files.open
→ FilesBody → Session.cwd + Tab
→ filesFace.start → DirectoryNode.open
→ changes(sessionId, path)
→ Host.watch(depth: 0) → ready
→ DirectoryNode → list(sessionId, path)
→ createFilesStore.levels[path]
→ Level / Entry
Entry.expand
→ filesFace.toggle
→ DirectoryNode.expand → child.open
→ child.watch → ready → child.list
→ createFilesStore.expanded + levels[path]
Process → create / remove / rename
→ Host.change(directory)
→ DirectoryNode.refresh → list(path)
→ createFilesStore.levels[path]
→ DirectoryNode.children ↔ list.entries
→ DirectoryNode.collapse → child.close
```
File previews and the directory tree are independent consumers. A preview can refresh without an open Files panel; Files can update entries without any file preview. Neither a Resource for the whole tree nor a new directory Resource protocol is needed.
The directory panel obtains invalidations and listings through injected Remote callbacks and writes displayed values through its own store. Components retain their existing read interfaces and do not directly hold Chokidar, Remote iterators, or low-level observation objects.
## Directory-node tree and lifecycle
The Files runtime is itself an on-demand directory tree. It does not first construct the whole Workspace tree or separately derive a flat watcher set. Existing `filesFace` owns each Tab's root; `DirectoryNode` owns reads, observation, and release recursively, without another controller class.
| Node responsibility | Behavior |
|---|---|
| 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 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` retains user expansion preferences, but is not another watcher registry. Closing a parent cannot retain active descendant nodes merely because their preferences remain. Reopening first reads the parent, then progressively restores still-present children. Expand and collapse actions update the store and pending restoration even when the parent node is not yet active; restored descendants follow the latest preferences instead of reopening a collapsed branch.
```text
workspace/ watch
├── src/ open watch
│ ├── components/ open watch
│ └── internal/ closed -
└── assets/ closed -
collapse(src/):
workspace/ watch
├── src/ close(src/, components/)
└── assets/ -
```
The parent's watch still detects deletion, renaming, or replacement of a collapsed child, but not changes deep inside it. Listing again on expansion discovers changes made while it was not watched.
| User action or lifecycle event | File preview | Files panel |
|---|---|---|
| First open | Subscribe to the file Resource and read contents | Open the root node, which subscribes and lists |
| Expand child | No effect | The parent opens the child; the child subscribes and reads direct entries, progressively restoring still-present descendants from preferences |
| Collapse child | Open file previews keep their watches | Release that directory and its hidden descendants; expansion preferences and displayed cache may remain |
| Switch Tab or unmount panel | The Tab pin retains file metadata; an unmounted body does not reread, and refreshes on return according to pending Resource changes and toggle state | Retain expanded nodes and watchers for the Tab lifetime; do not introduce another panel-visibility lifetime |
| 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 |
Files watches follow Tab lifetimes. Collapse closes the corresponding subtree; Tab closure releases the root and its entire active tree. Temporarily hiding the panel does not rebuild nodes, keeping implementation within the existing entry point and directory objects. The store retains expansion preferences; nodes use them for progressive restoration only when reopened, without a second global expansion set.
`ResourceRegistry` continues sharing identical complete file Resource addresses. Parent-child ownership prevents duplicate active branches inside one Tab. This version adds no global watcher deduplication across Sessions, windows, or path aliases. Ordinary file rows do not each subscribe to file watches; only files opened as preview Resources acquire independent file observation.
## User flows
### Edit a previewed file
1. The user opens Markdown, code, an image, PDF, HTML, or Office file from Files.
2. The user saves it in an external editor or rewrites it through a shell command.
3. Automatic refresh is enabled by default, so Document Preview displays new content. Disabling it retains current content until manual reload or reenabling.
4. Renderer selection and wrapping preferences remain. Text scrolling uses existing restoration; shorter content limits the available position.
5. Exact position restoration for PDF, HTML, and images is not an additional promise. Existing renderer behavior remains, without a cross-format location system for automatic refresh.
### Browse changing directories
1. The user opens Files; the root lists and begins observation.
2. The user expands `src` and `src/components`; both start their own watchers.
3. An external tool creates a file in `src/components`; only that level refreshes, without collapsing or reloading other directories.
4. The user collapses `src`; its own watch and the `components` watch close, while the root remains watched.
5. External changes continue while collapsed. Reopening `src` rereads it and progressively restores valid descendant expansion, rather than remaining on old cached entries.
### Deletion, renaming, and failure
- 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.
## Readiness, races, and refresh coalescing
Observation, reads, and presentation need a few explicit ordering rules, not disk transactions or an operation log.
- 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 or its completion marks a pending reread, which runs afterwards rather than building an arbitrary queue of refresh jobs.
- File refresh reuses read generations and renderer revisions. Consecutive notifications must not let old requests overwrite new content or endlessly reload an already handled version.
- Background directory refresh retains the displayed listing without a display-unused `refreshing` flag. Only a directory without displayable entries enters `loading`, avoiding subtree flicker and repeated watcher reconstruction.
- After collapse, deletion, or Tab closure, late cancelled reads cannot recreate state or restore watchers. Cancellation closes the Host watcher even while the generator is suspended at a yielded frame; stream and plugin teardown await closure.
- Temporary file disappearance while its parent remains does not end observation. Ordinary file changes and watcher failures remain distinct; same-path file watches cover atomic saves and delete/recreate.
- Existing file reads are not transaction snapshots: writes can occur between stat and content reading. Later notifications trigger refresh; automatic refresh does not promise an atomic snapshot for every read.
## Alternatives considered
**Another Resource `onChange` and content reload.** Existing `open()`, `source()`, and `useResource` already express change, while the generic Resource does not own contents. Duplicate interfaces would make metadata, contents, and renderers each manage refresh state.
**Recursive watching of the whole Workspace.** Opening one file or root directory should not watch every deep directory. Watching the current file and expanded directories ties resource use to what the user has actually opened.
**Keep Session broadcasts and add path registration/unregistration.** The Host needs explicit targets, but not another path set maintained through additional commands. One target per stream directly reuses cancellation, reconnect, and ending semantics.
**Push contents or complete listings.** This mixes observation with reads and duplicates pagination, byte limits, formats, and entry caps. Small notifications followed by existing `read()`/`list()` calls are more direct.
**Apply directory-row additions and removals from Client events.** Filesystem events can coalesce or repeat, while listings own sorting, types, and entry limits. Relisting the affected level retains existing semantics without a directory-patch merge algorithm.
**Call Chokidar directly in the Workspace API.** The API does not own the execution world; SSH paths may exist only remotely. The actual FS provider must implement watching.
## Verification
The owning FS, Workspace API, preview, and Files tests exercise watch lifetimes, metadata updates, and automatic refresh. The [Web document-preview test](../../../../apps/web/tests/document-preview.e2e.ts) exercises previews through the shipped Web composition. Session event logs, persistence formats, and model input are unchanged.
| Scenario | Verified behavior |
|---|---|
| External in-place writes, atomic replacement, shell writes | Open previews refresh without depending on `fs/observed` |
| Text, complete bytes, renderer-owned loading | All three modes refresh; old requests cannot overwrite new results, and Office failures allow a later file change to retry |
| 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, 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 |
| Collapse or reexpand a deep descendant during ancestor restoration | Record the latest preference immediately; restoration opens only branches still expanded |
| Reopen, return to Files, or reconnect | Obtain current listings without depending on replay of missed events |
| Delete/recreate a file, or replace a child directory with a file | File watches recover at the same path while the parent remains; directory listings release invalid subtrees |
| Rapid writes, duplicate notifications, changes during reads or their completion | Converge on current state with finite rereads, without endless refresh |
| Close Tab, unload plugin, cancel initialization | Close all owned watches even when no further frame is requested; late notifications and reads no longer write state |
| Unsupported providers or out-of-workspace directories | Report unavailable observation or access errors explicitly, without watching the wrong execution world or widening directory authority |
Controlled streams and deferred reads verify readiness, cancellation, and expansion during restoration. Real local-watch tests use temporary directories, wait for readiness before acting, and finish on observable state rather than fixed sleeps. The Web preview test and owner-local UI tests cover user-visible behavior; executed command results belong in the PR.
## Consequences
- Automatic watch recovery after a parent directory is deleted and recreated on Linux is deferred. Same-path file recreation while the parent remains stays supported.
- OS file events are not durable messages. Disconnects and process restarts may lose notifications, so every stream generation obtains current state after readiness.
- Open files and expanded directories increase watcher and stream counts. The first version controls this through on-demand lifetimes, without an advance cross-consumer deduplication service.
- Automatic refresh may interrupt reading, especially HTML interactions and Office conversion. View preferences and coalescing remain, but arbitrary embedded-document runtime state is not preserved.
- HTML CSS/JS members come from the existing parser. Deeper dependencies, image dependencies, and runtime loading are not additional parsing work in this change.
- SSH OS watching needs its own remote implementation strategy. This interface declares unsupported operation honestly, without local watchers or implicit polling masquerading as remote support.
## Relationship to existing Agent Notes
Automatic refresh and OS watching retain the Resource model, file-read authorization, and renderer ownership. The following records retain independent rationale; this note owns target-scoped observation, automatic reload, and directory-watch lifetimes.
- [Client Resource model](../../implemented/architecture/2026-09-05-client-resource-model.md): retain addresses, providers, subscriptions, and lifetimes; existing streams carry the new behavior.
- [Workspace file service](../../implemented/architecture/2026-09-05-workspace-files-service.md): read and listing responsibilities remain; target-scoped OS observation replaces the Session-scoped operation-only stream.
- [Document preview operations](../../implemented/architecture/2026-09-08-document-preview-operations.md): content-loading ownership and all three modes remain; automatic refresh uses their existing reload paths.
- [Workspace file-read authority](../../implemented/architecture/2026-09-09-workspace-file-read-authority.md): retain distinct access scopes for file reading and directory browsing; target watches follow their respective rules.
- [Sidebar text preview and file tree](../../implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md): per-Tab expansion, navigation, and scrolling remain; open nodes own automatic directory invalidation and watching.
@@ -0,0 +1,345 @@
# Agent Note: Sidebar 文件预览与目录树按需监听并自动刷新
Status: implemented
[English](2026-09-17-sidebar-file-and-directory-auto-refresh.md) | 中文
## 问题
Sidebar 的 Document Preview 和 Files 面板都需要反映磁盘上的最新状态。变化来源包括 Harness 文件工具、Shell 命令、用户编辑器,以及其他进程;只监听 Harness 自己的文件操作不够。
起始实现已有两条读取路径,但缺少完整的自动刷新链路。
| 对象 | 起点行为 | 缺口 |
|---|---|---|
| 文件 Resource | `ResourceProvider.open()` 提供初始元数据和后续变化,`ResourceRegistry` 管理订阅及持有期,组件通过 `useResource` 读取 | 文件变化源不是系统级 watch |
| Document Preview | `TextPreview` 已比较 Resource 版本与正文版本,并提供三种加载模式的 reload | 检测到变化后仅提示用户点击重新加载 |
| Workspace 文件事件 | `WorkspaceFiles.changes()` 转发当前 Session 的 `fs/observed`,客户端按文件过滤 | Host 不知道具体关注哪些路径;外部编辑器和 Shell 修改不会产生这类事件 |
| Files 面板 | `filesFace` 读取目录,`createFilesStore` 保存已加载层级及展开状态 | 新增、删除、重命名后需要手动刷新;展开过的目录再次打开可能直接展示旧缓存 |
`Resource` 当前承载文件元数据,不持有预览正文。正文由 Document Preview 或它选择的渲染器读取。因此,“元数据更新”和“重新加载正文”应保持各自的职责,不把正文缓存搬进通用 Resource,也不要求每个渲染器直接接触文件系统监听。
## 决策
按需监听连接现有 FS、Workspace 文件流、Resource 和 Sidebar 组件。文件与目录共用系统监听能力和现有 Remote 流传输,但消费方式不同。
- Document Preview:监听当前文件,Resource 发布新元数据,预览自动调用已有 reload。HTML 的根文件和引用的 CSS/JS 都加入同一个 `ResourceGroup`,任一成员变化都使整个 HTML 预览失效。
- Files 面板:以目录节点树管理读取和监听。每个已打开的目录节点只加载并监听自己的直接子项;Client 打开多少层子树,就逐层加载并订阅多少层,不提前遍历未打开的后代。
- 折叠目录时释放该目录及其不可见后代的监听;再次展开时重新订阅并读取,不能把折叠前缓存当作最新结果。
- 监听只传元数据或失效通知;正文仍走现有文件读取接口,目录内容仍走 `list()`。
- 使用本地 Chokidar 系统监听,不定时轮询整个 Workspace,不默认递归扫描所有后代。
- 保留两个面板的手动刷新入口和自动刷新开关功能,但暂时隐藏开关的 icon 按钮。开关状态及切换逻辑不变,新 Tab 默认开启;隐藏入口不关闭监控或自动刷新。
变化通知触发现有 reload/list;刷新失败后不保留旧预览内容。下面说明实现对象的职责、数据流及验证。
## 实现对象与职责
### FS 与 Host
| 类或类型 | 所在文件 | 职责 |
|---|---|---|
| `FileSystem` | [fs/src/index.ts](../../../../packages/fs/fs/src/index.ts) | 声明抽象的单目标 `watch(target, changed, signal)` 方法;文件观察自身,目录观察直接子项;就绪后返回异步关闭函数,不支持的提供方显式抛异常,不新增 FS 错误码 |
| `FsTarget` | [fs/src/types.ts](../../../../packages/fs/fs/src/types.ts) | 沿用现有目标类型,不单独增加 FS 监听事件体系,也不让上层解析 `targetKey` |
| `LocalFileSystem` | [fs-local/src/index.ts](../../../../packages/fs/fs-local/src/index.ts) | 文件监听其父目录并过滤到目标,目录监听自身;等待 Chokidar 就绪和关闭;依赖声明在 `fs-local` |
| `SandboxedFileSystem` | [fs-sandbox/src/index.ts](../../../../packages/fs/fs-sandbox/src/index.ts) | 继承本地只读监听能力,不复制 watcher 实现;保留自身对写入、编辑的策略检查 |
| `SshFileSystem` | [fs-ssh/src/index.ts](../../../../packages/ssh/fs-ssh/src/index.ts) | `watch()` 显式抛出普通异常;Host 转成 `workspace-file/watch-unsupported` 的 `RemoteError`,不跨包导入或判断 `FsError` 实体,也不把远端 `processPath()` 传给本机 Chokidar |
| `WorkspaceFiles` | [workspace-files/src/index.ts](../../../../packages/api/workspace-files/src/index.ts) | 使用仅携带目标路径的 `changes(scope, path, signal)`;Host stat 决定是否执行目录包含检查,目标类型变化后也检查,普通文件沿用文件读取权限 |
| `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) | 接收操作观察与 OS 通知,处理监听错误;关闭独立于生成器拉取进度,并等待 watcher 关闭完成 |
| `WorkspaceFileWatchFrame` | [workspace-files/src/types.ts](../../../../packages/api/workspace-files/src/types.ts) | 仅传路径的请求沿用 `ready`/`change` 帧,不增加请求 kind 或独立目录帧格式 |
### Client 与 Sidebar
| 类、函数或类型 | 所在文件 | 职责 |
|---|---|---|
| `ChangeFeed`、`SessionFeed`、`Follower` | [workspace-files/src/client/change-feed.ts](../../../../packages/api/workspace-files/src/client/change-feed.ts) | 将流的键改为 Session 加目标路径,继续复用既有就绪、路径绑定、取消和重连逻辑;此版不为了命名重排类结构 |
| `createFileResourceProvider` | [workspace-files/src/client/provider.ts](../../../../packages/api/workspace-files/src/client/provider.ts) | 将文件地址中的 Session 与路径传给目标流;首次读取及后续通知都发布完整元数据,不能只换 `version` 而继续沿用旧 `bytes` |
| `ResourceRegistry`、`ResourceProvider`、`UseResource` | [Resource 定义](../../../../packages/client/resources/src/client/contract.ts)、[ResourceRegistry](../../../../packages/client/resources/src/client/resources.ts) | 保持现有流、持有计数和订阅模型;不新增第二套 `onChange`,不为此增加通用正文 reload API |
| `TextPreview` | [TextPreview.tsx](../../../../packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx) | Resource 或 group 变化且开关开启时自动触发现有 reload;保留错误状态与手动刷新,独立自动刷新按钮暂时隐藏 |
| `textFace`、`TabReads`、`createTextStore` | [预览 face](../../../../packages/client/ui-sidebar-documentpreview/src/client/face.ts)、[预览存储](../../../../packages/client/ui-sidebar-documentpreview/src/client/store.ts) | 每个 Tab 持有一个 group,补充默认开启的 `autoRefresh` 和表示待刷新的 `resourcesDirty`;复用正文读取代次与 `loadRevision` |
| 包内 `ResourceGroup` | [resource-group.ts](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/resource-group.ts) | 通过现有 `resources.source(address)` 观察成员;`add` 加入资源,`set` 更新完整成员列表,`close` 释放全部成员,不改变通用 Resource 服务 |
| `DocumentBodyOwner`、`HtmlBody`、`createReadHtmlRelative` | [渲染器输入](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts)、[HTML body](../../../../packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx)、[关联文件读取](../../../../packages/client/ui-sidebar-documentpreview/src/client/html/read-relative.ts) | 渲染器通过 `addResource` 声明读取到的成员,通过 `setResources` 提交本次依赖列表;HTML 在每次读取返回后按 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) | 表示一个已打开的目录,拥有本目录变化流、读取状态和已打开子节点;负责局部刷新、展开、递归关闭,以及向子节点传递自动刷新状态;`setExpanded` 按最新展开偏好更新待恢复节点 |
| `createFilesStore`、`FilesTabState`、`LevelState` | [Files store](../../../../packages/client/ui-sidebar-files/src/client/store.ts) | 保留展开、滚动和目录显示数据;区分首次加载与已有内容上的刷新,避免每次后台重读都把子树卸载;不保存 watcher 或 AbortController |
| `FilesBody`、`Level`、`Entry` | [FilesBody.tsx](../../../../packages/client/ui-sidebar-files/src/client/FilesBody.tsx) | 将展开、折叠、刷新和面板卸载交给注入回调;按节点展示结果渲染,不扫描整棵树生成监听集合,展示仍只读已有框架钩子与存储 |
| Files 注册入口 | [Files client/index.ts](../../../../packages/client/ui-sidebar-files/src/client/index.ts) | 给现有 `filesFace` 增加目录变化流回调;异步副作用留在注入实现,不引入另一个功能插件的运行时导出 |
## 监听接口与事件内容
### 一条流对应一个明确目标
每个请求指定一个目标路径;Host 通过 `stat` 确定当前实际类型,Client 不传文件/目录 kind。
```text
changes(sessionId, path, signal)
changes(scope, path, signal)
```
Host 方法仍通过 `WorkspaceFileScope` 接收解析后的 Session 上下文。Client 传 Session id,不能自行伪造 Host 的工作目录或权限。
当前目标是目录时,Host 就检查 Workspace 包含关系;原本缺失或为普通文件的目标变成目录后也执行检查。Workspace 外的普通文件仍可通过所选 FS 读取与监听。
| 通知 | 内容 | 消费方动作 |
|---|---|---|
| 就绪 | 当前一代目标监听已经建立 | 执行首次或重连后的 `stat()`/`list()`,接收读取期间排队的变化 |
| 文件存在或更新 | 沿用路径和版本通知;Client 收到后重新 stat | 用完整结果更新 Resource 的路径、版本和大小;自动刷新开启时重新读取正文 |
| 文件缺失 | 目标路径和缺失状态 | Resource 报告不可用,预览保留已加载内容并显示错误 |
| 目录失效 | 目录目标流中的 `change` 帧 | 只重新调用这个目录的 `list()`,不按目录版本去重,也不刷新整棵树 |
| 监听失败 | 初始化失败报告 `workspace-file/watch-unsupported`;运行期错误结束监听 | 客户端在监听接入处处理失败,普通读取与手动刷新仍可用;不伪装成文件删除 |
不用把文件正文、完整目录列表或每个变化的补丁塞进通知。文件名由路径表达;文件大小来自当前 stat;修改时间若只为判断变化,继续由后端版本 token 表达,不要求 UI 解析 token 或另外引入时间戳比较。
Resource 发布的路径、版本和大小来自同一次 stat。先沿用现有小通知,再由 Client stat,避免为了减少一次请求扩大事件协议。目录变化不应只比较目录自身 mtime:直接子文件的大小或类型变化也可能影响 `list()` 返回值,应按相关子项事件使目录列表失效。
### 系统监听与访问范围
- `FileSystem.watch()` 接受本提供方解析出的目标;本机或远端路径的含义由 FS 提供方负责。
- 文件监听保持文件读取的访问规则。现有预览允许通过所选 FS 读取部分 Workspace 外的路径,不能把目录树的范围限制顺手强加给这些文件。
- 目录监听保持 `list()` 的 Workspace 范围及路径检查,目标类型变化后也执行检查,不因新增 watch 扩大目录浏览权限。
- 父目录保持存在时,文件监听覆盖原地写入、临时文件替换、删除和同一路径重建。本地提供方直接监听父目录,将条目和事件过滤到目标文件,等待父目录 watcher 就绪后才报告就绪,目标文件尚不存在时也适用。
- 目录监听只关注该目录自身和直接子项,不递归打开尚未展开的子目录;直接子项的创建、删除、重命名、类型或可见元数据变化都使这一层失效。
- 本地实现采用系统事件模式;若需要调整写入稳定时间、事件合并窗口等部署参数,应由拥有它们的配置声明,不散落在组件里。
- 系统 watch 只驱动 UI 刷新,不把外部磁盘变化伪装成某个 agent 已读取文件的 `fs/observed`,不改变文件编辑的已观察版本策略。
## 文件预览的完整数据流
```text
Files.openResource / file.link
→ Sidebar.openTab
→ TabDomain → Resource.pin
→ ResourceRegistry → createFileResourceProvider.open
→ changes(sessionId, path)
→ WorkspaceFiles → FsTarget
→ WorkspaceChangeFeed → FileSystem.watch
→ LocalFileSystem → Chokidar
→ ready → Client.stat → ResourceSnapshot
Editor / Shell / file tool → disk
→ Chokidar → LocalFileSystem
→ WorkspaceChangeFeed → FileSystem.stat
→ Remote.change
→ Client.stat → ResourceSnapshot
→ ResourceGroup → TextPreview
→ reloadPages / reloadAll / prepareRenderer
→ DocumentContent / renderer revision
→ renderer
```
预览正文可以与元数据初始化并行读取。ResourceGroup 以各成员首次元数据为基线,只转发后续变化,不做首读版本对账。
| 加载模式 | 已有刷新入口 | 刷新结果 |
|---|---|---|
| `text-pages` | `reloadPages()` | 废弃旧正文代次,重新读取文本页;不能混合不同版本的页 |
| `bytes-complete` | `reloadAll()` | 重新读取完整字节,PDF、HTML、图片等消费新字节 |
| `renderer` | `prepareRenderer(..., reload = true)` | 增加 `loadRevision`,渲染器取消旧加载并重新请求;Office 失败通过 `failed()` 结束加载,后续文件变化可重试 |
普通渲染器不需要添加 `watch()` 或新的 reload 接口。已有 `DocumentContent` 中的文本、字节和 revision 变化已经能驱动重新加载。
## HTML 的 ResourceGroup
一个 HTML 预览的 group 包含 HTML 根文件,以及现有 HTML 打包器实际读取的 CSS/JS 文件。成员都是普通文件 Resource,不为依赖另建 watcher 或额外文件类型。group 是预览包内对象,复用 Resource 已有的订阅和持有计数。
```text
index.html Resource ─┐
theme.css Resource ──┼→ ResourceGroup → resourcesDirty → autoRefresh → HTML reload
page.js Resource ───┘
```
- `addResource(address)` 在 `readRelated` 返回 Host 解析的 `absolutePath` 后将依赖加入 group;已有成员不重复订阅,不传递或保存正文读取版本。
- `setResources(addresses)` 提交本次解析出的依赖列表;预览拥有者始终保留根文件,其余未再出现的成员释放订阅。
- 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 切换到静态预览时释放依赖监听,只保留根 Resource。
## 自动刷新开关
Document Preview 和 Files 各自保留原 reload 按钮。独立自动刷新开关的 icon 入口暂时隐藏,状态、切换逻辑、文案和样式保留;新 Tab 仍默认 `autoRefresh: true`。隐藏不改变开关状态,也不停止观察。
| 状态或操作 | 行为 |
|---|---|
| 自动刷新开启 | 开关高亮,使用已有暂停图标,提示“关闭自动刷新”;变化自动刷新对应预览或目录节点 |
| 点击关闭 | 开关显示播放图标,提示“开启自动刷新”;继续观察变化,但不自动替换当前内容 |
| 点击重新开启 | 若暂停期间已有变化,立即补一次刷新;之后继续自动刷新 |
| 点击原 reload | 无论开关状态如何,都主动读取一次,不改变开关状态 |
| 自动刷新关闭时展开新目录 | 新目录仍正常首次加载并订阅;开关只控制后续变化引起的自动重读 |
暂停/播放使用现有图标,手动 reload 保留环形箭头,不新增图标系统。按钮通过 `aria-pressed` 表达状态,名称和提示走现有 locale 字典。
## Files 面板的完整数据流
```text
Files.open
→ FilesBody → Session.cwd + Tab
→ filesFace.start → DirectoryNode.open
→ changes(sessionId, path)
→ Host.watch(depth: 0) → ready
→ DirectoryNode → list(sessionId, path)
→ createFilesStore.levels[path]
→ Level / Entry
Entry.expand
→ filesFace.toggle
→ DirectoryNode.expand → child.open
→ child.watch → ready → child.list
→ createFilesStore.expanded + levels[path]
Process → create / remove / rename
→ Host.change(directory)
→ DirectoryNode.refresh → list(path)
→ createFilesStore.levels[path]
→ DirectoryNode.children ↔ list.entries
→ DirectoryNode.collapse → child.close
```
文件预览和目录树是两条独立的消费路径。预览文件可以在 Files 面板没有打开时自动刷新;Files 也可以在没有任何文件预览时自动更新目录项。无需把整棵目录树包装为一个 Resource,也不为此新增目录 Resource 协议。
目录面板直接通过注入的 Remote 业务回调取得失效通知和列表结果,显示值统一写回自己的存储;不绕过组件现有的读取方式,不让组件直接持有 Chokidar、Remote 迭代器或底层观察对象。
## 目录节点树与生命周期
Files 的运行时对象本身就是一棵按需打开的目录树,不先建立整棵 Workspace 树,也不另算一个扁平的 watcher 集合。现有 `filesFace` 持有各 Tab 根节点,读取、监听和释放逐层交给 `DirectoryNode`,不另抽控制器类。
| 节点职责 | 具体行为 |
|---|---|
| 本目录身份 | 持有该目录路径和所属 Session/Tab,文件系统根与 Windows 盘根均可作为树根,列表只包含直接子项 |
| 本目录监听 | 一个打开的节点持有一个目标变化流;收到通知只使本节点列表失效 |
| 本目录读取 | 持有有效 list 请求的代次,待补读状态持续到读取收尾后,结果通过存储 actions 发布 |
| 已打开子目录 | 按直接子目录路径持有子 `DirectoryNode`;未展开目录只是列表中的目录项,不创建活动子节点 |
| 展开 | 目录行触发展开时创建或复用对应子节点,再由子节点建立自己的监听与读取;恢复展开偏好时只打开列表中仍存在的目录 |
| 折叠 | 关闭并移除对应活动子节点;子节点递归关闭后代,然后释放自己的监听和请求 |
| 列表更新 | 保留仍存在的已打开子节点,关闭已删除、重命名或变为文件的分支;不重新打开所有节点 |
| 整树关闭 | 对根节点执行关闭,自然递归释放整棵活动子树,不另遍历全局监听注册表 |
`createFilesStore.expanded` 保存用户的展开偏好,但不是另一份监听注册表。父目录关闭后,即使存储保留了后代展开偏好,也不能继续持有这些后代的活动节点。重新打开时先读取父目录,再对仍存在且需要恢复的子目录逐层建立节点。即使父节点尚未创建,展开和折叠也会更新存储与待恢复状态;后代恢复时遵循最新偏好,不会重新打开已折叠的分支。
```text
workspace/ watch
├── src/ open watch
│ ├── components/ open watch
│ └── internal/ closed -
└── assets/ closed -
collapse(src/):
workspace/ watch
├── src/ close(src/, components/)
└── assets/ -
```
父目录的监听仍能发现折叠目录自身被删除、重命名或替换,但不负责发现其深层内容变化。再次展开时重新 list,补上未监听期间的变化。
| 用户操作或生命周期事件 | 文件预览 | Files 面板 |
|---|---|---|
| 首次打开 | 打开文件 Resource 监听并读取正文 | 打开根节点,由根节点建立监听并读取列表 |
| 展开子目录 | 无影响 | 父节点打开对应子节点;子节点监听并读取直接子项,按偏好逐层恢复仍存在的后代 |
| 折叠子目录 | 已打开的文件预览继续监听 | 释放该目录及其当前不可见后代的监听;可保留展开偏好和显示缓存 |
| 切走 Tab 或面板卸载 | Tab pin 继续持有文件元数据流;正文组件不在时不主动重读,切回来按待处理 Resource 变化与开关状态补刷新 | 此版沿用 Tab 生命周期,保留已展开目录节点和监听;不另外引入面板显隐生命周期 |
| 手动刷新 | 重用当前预览 reload,不重建整套 Resource | 从根节点递归刷新已打开子节点,不进入折叠目录,也不重建全部 watcher |
| 关闭最后一个持有者 | Resource abort,文件流结束,Host 等待 watcher 关闭 | Tab 关闭时终止全部目录流、取消读取并删除 Tab 状态 |
| 连接恢复 | 新流 ready 后重新 stat,即使断线期间没有可回放事件也能发现变化 | 新流 ready 后重新 list 所有仍需观察的目录 |
Files 监听跟随 Tab 生命周期;折叠关闭对应子树,关闭 Tab 则释放根节点与整棵活动树。暂时隐藏面板不额外拆除和重建节点,这使改动集中在现有入口和目录对象。存储保留展开偏好,节点只在重新展开时用这份偏好逐层恢复,不维护第二份全局展开集合。
同一个完整文件 Resource 地址的共享继续由 `ResourceRegistry` 完成。目录节点的父子所有权保证一个 Tab 内同一分支不会重复打开;第一版不增加跨 Session、跨窗口或路径别名的全局 watcher 去重系统。目录下的普通文件项不各自订阅文件 watch,只有真正打开为预览 Resource 的文件才取得独立文件监听。
## 用户可见动线
### 编辑已预览的文件
1. 用户从 Files 打开 Markdown、代码、图片、PDF、HTML 或 Office 文件。
2. 用户在外部编辑器保存,或 Shell 命令改写该文件。
3. 自动刷新默认开启,Document Preview 自动显示新内容;用户关闭开关后可保持当前内容,手动 reload 或重新开启时再读取。
4. 当前渲染器选择和换行偏好保留;文本滚动位置按现有机制恢复,内容缩短时只能落在新内容允许的位置。
5. PDF、HTML 和图片的精确阅读位置恢复不是本方案额外承诺;沿用各渲染器现有能力,避免为自动刷新加入一套跨格式定位系统。
### 浏览正在变化的目录
1. 用户打开 Files,根目录列表加载并开始监听。
2. 用户展开 `src` 和 `src/components`,两个子目录分别开始监听。
3. 外部工具在 `src/components` 创建文件,只有这一层列表刷新,其他目录不折叠、不重新加载。
4. 用户折叠 `src`,其自身和 `components` 的监听关闭,根目录继续监听。
5. 折叠期间外部工具继续修改内容;用户再次展开 `src` 时重新读取,依次恢复仍有效的子目录展开状态,不停留在旧缓存。
### 删除、重命名和失败
- 文件删除后,已有预览内容保留并显示不可用提示;父目录保持存在时,原路径重建后自动恢复读取。重命名视为旧路径消失和新路径出现,不根据 inode 自动改写预览地址。
- 目录删除或重命名后,父目录刷新移除旧行,并释放旧子树监听。新目录按新名称显示,不擅自转移旧路径的展开偏好。
- 自动刷新和手动刷新调用同一套已有 reload,沿用清空旧结果、加载及错误展示行为;不增加保留旧字节或旧 iframe 的分支。目录刷新保留现有列表缓存,避免重建活动子树。
- 后端不支持 watch 时,Host 把初始化异常转成 `workspace-file/watch-unsupported` 的 `RemoteError`;客户端结束监听,继续普通读取与手动刷新,不增加轮询。SSH 的外部磁盘变化不会自动更新预览。
## 就绪、竞态与刷新合并
监听、读取与展示只需要处理少数明确的顺序问题,不扩展成磁盘事务或操作日志。
- 先建立 watch,再发送 ready,然后取得初始 stat/list。读取过程中到达的变化不能丢弃;必要时再读一次,使最终结果收敛到最新状态。
- 事件表示“目标可能变化”,不是一次用户操作的精确回放。Chokidar 的重复通知和连续写入可以合并;文件按版本去重,目录按待刷新状态合并。
- 同一目录最多有一次有效 list 在途;在途或读取收尾期间再次失效,只记录需要补读,随后读取最新状态,不累计任意长度的刷新任务队列。
- 文件自动刷新复用读取代次和 renderer revision。连续通知不能让旧请求覆盖新内容,也不能因同一已处理版本而无限 reload。
- 目录后台刷新保留当前列表,不另存没有显示用途的 `refreshing` 标记;只有没有可显示列表时才进入 `loading`,避免子树闪烁和监听反复重建。
- 目录折叠、删除或 Tab 关闭后,已取消读取的晚到结果不得重新创建状态或恢复监听。即使生成器停在已产出的帧上,取消也会关闭 Host watcher;流与插件拆除等待关闭完成。
- 父目录保持存在时,文件短暂消失不结束监听;正常文件变化与监听自身失败分别处理。同路径文件监听覆盖原子保存与删除重建。
- 原有文件读取并非事务快照,stat 与正文读取之间仍可能发生写入;后续通知触发刷新,自动刷新不保证每次读取都获得原子快照。
## 不采用的替代方案
**在 Resource 上再加一套 `onChange` 和正文 reload。** 现有 `open()`、`source()`、`useResource` 已经表达变化,正文又不归通用 Resource 所有。重复接口会使元数据、正文和渲染器分别维护刷新状态。
**整个 Workspace 递归 watch。** 打开一个文件或一个根目录不应导致监听所有深层目录。按当前文件和展开目录取得监听,资源使用量与用户实际打开的内容相关。
**保留 Session 广播流,再增加路径注册/退订协议。** Host 需要知道具体目标,但不必再维护一个由额外命令修改的路径集合。一目标一流可以直接复用现有取消、重连和结束语义。
**直接下发正文或完整目录列表。** 这会混合监听与内容读取,还要重复分页、字节限制、文件格式和目录条目限制。小通知配合现有 `read()`/`list()` 更直接。
**根据变化事件在客户端增删目录行。** 文件系统通知可能合并或重复,目录列表还有排序、类型和条目上限。重新 list 受影响的一层即可保持现有语义,不需要维护目录增量合并算法。
**在 Workspace API 中直接调用 Chokidar。** API 不拥有执行世界,SSH 路径可能只在远端存在。watch 必须由实际 FS 提供方实现。
## 验证
FS、Workspace API、预览和 Files 各自的测试覆盖监听生命周期、元数据更新和自动刷新。[Web 文档预览测试](../../../../apps/web/tests/document-preview.e2e.ts) 从正式 Web 组合验证文件预览。会话事件日志、持久化格式和模型输入均不变。
| 场景 | 已验证行为 |
|---|---|
| 外部原地写入、原子替换、Shell 写入 | 打开的文件预览自动更新,不依赖 `fs/observed` |
| 文本、完整字节、自主加载渲染器 | 三种模式都刷新,旧请求不能覆盖新结果,Office 失败后允许后续文件变化重试 |
| 成员首次元数据 | 仅建立基线,不额外 reload,也不做首读版本对账 |
| HTML 根文件不变,仅 CSS/JS 改变 | group 触发整个 HTML 重新加载 |
| HTML 删除一条依赖引用 | 本次 group 更新后释放不再引用的成员 |
| 关闭自动刷新后修改,再手动刷新或重新开启 | 暂停期间保持内容,手动操作或重新开启后补刷新;按钮职责独立 |
| 根目录直接子项新增、删除、重命名 | Files 根层自动更新,工作区为文件系统根或 Windows 盘根时也适用 |
| 已展开子目录变化 | 只刷新对应目录层,保留其他层与展开偏好 |
| 未展开的深层目录 | 不创建深层 watcher,不递归遍历 |
| 折叠父目录 | 释放它和不可见后代的监听;文件预览若仍打开则独立继续监听 |
| 祖先恢复期间折叠或重新展开深层后代 | 立即记录最新偏好,恢复时只打开仍需展开的分支 |
| 重新展开、切回 Files、连接恢复 | 重新读取当前列表,不依赖错过事件的回放 |
| 文件删除后重建,或子目录替换为文件 | 父目录保持存在时,文件监听在同一路径恢复;目录列表释放失效子树 |
| 快速写入、重复通知、读取中或收尾时再修改 | 有限次补读并收敛到最新状态,不无限刷新 |
| 关闭 Tab、卸载插件、取消初始化 | 即使不再拉取下一帧,所属监听也完全关闭;晚到通知和读取不再写入状态 |
| 不支持监听的提供方、越界目录请求 | 明确报告支持状态或访问错误,不观察错误执行世界或扩大目录权限 |
可控事件源和延迟读取验证就绪、取消及恢复期间的展开操作。真实本地 watch 测试使用临时目录,等待 watcher 就绪后再操作,通过观察到的状态结束,不靠固定 sleep 猜测事件时机。Web 预览测试和包内 UI 测试覆盖用户可见行为;命令执行结果记录在 PR 中。
## 影响
- Linux 上父目录删除并重建后的自动监听恢复仍延期;父目录保持存在时,同路径文件重建仍受支持。
- OS 文件事件不是持久消息,断线或进程重启可能丢通知,因此每一代流就绪后都要重新取得当前状态。
- 多个已打开文件和展开目录会增加 watcher 与流数量;第一版用按需生命周期控制数量,不提前加入复杂的跨消费者去重服务。
- 自动刷新可能打断阅读,尤其是 HTML 内部交互和 Office 转换。保留视图偏好、合并连续变化,但不承诺保存任意嵌入文档的运行状态。
- HTML 的 CSS/JS 通过现有解析器进入 group;更深依赖、图片依赖和运行时动态加载不作为这次修改的额外解析工程。
- SSH 远端系统监听需要独立的远端实现策略;这里要求接口如实表达不支持,不使用本机 watcher 或隐式轮询冒充远端能力。
## 与已有 Agent Note 的关系
自动刷新和系统监听保留已有 Resource 模型、文件读取授权和渲染器职责。以下记录仍有独立的设计理由;本文负责目标级监听、自动 reload 和目录监听生命周期。
- [Client Resource 模型](../../implemented/architecture/2026-09-05-client-resource-model.zh.md):保留地址、提供方、订阅和持有期;新增行为由已有流承载。
- [Workspace 文件服务](../../implemented/architecture/2026-09-05-workspace-files-service.zh.md):读取与列表接口职责不变;目标级系统监听替代仅转发操作观察的 Session 流。
- [文档预览操作](../../implemented/architecture/2026-09-08-document-preview-operations.zh.md):正文加载所有权和三种加载方式不变;自动刷新使用其现有 reload 路径。
- [Workspace 文件读取授权](../../implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md):保留文件读取与目录浏览的不同访问范围;目标 watch 分别遵循对应规则。
- [Sidebar 文本预览与文件树](../../implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md):按 Tab 的展开、导航和滚动状态不变;已打开节点负责目录自动失效与监听。
@@ -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/rejected/process/2026-08-04-artifact-first-npm-baseline-publication.md
2026-08-04-artifact-first-npm-baseline-publication.md: ab4171c60dea47dbdf8fbd7b9d32a41abf36407d
2026-08-04-artifact-first-npm-baseline-publication.zh.md: 8b1faf75e1ecb616d2895b4f3d123fff1f37fbf6
2026-08-04-artifact-first-npm-baseline-publication.md: 1c317176521afd2e11707f954520039a3f229a44
2026-08-04-artifact-first-npm-baseline-publication.zh.md: c2822ed0b572b60fe23cab7c5dc1ebfbe02230f7
@@ -18,7 +18,7 @@ The publication flow uses an immutable release bundle as its boundary. The pack
The target set contains only `@deepseek-ai/*` workspace packages discovered from `packages/*/*/package.json` and `apps/*/package.json`. The root project, `website/`, vendor, Python, and native workspaces are outside this NPM baseline. Discovery must reject duplicate names, mixed base versions, an unexpected publication privacy state, and unknown packages in the bundle instead of relying on another hand-maintained package-name list.
The prerelease version consists of the package stable base version, a second-precision UTC timestamp captured when the command starts, and the target commit's 10-character short SHA: `<base>-<YYYYMMDDHHmmss>-<short-commit>`. The dist-tag is derived as `dev-<base>`. For example, base `0.0.1`, time `2026-08-04T00:32:00Z`, and commit `909292dd7b` produce version `0.0.1-20260804003200-909292dd7b` and tag `dev-0.0.1`. Retrying one release bundle must retain its version and manifest; repacking creates a version from the new command start time.
The prerelease version consists of the package stable base version, a second-precision UTC timestamp captured when the command starts, and the target commit's 10-character short SHA: `<base>-<YYYYMMDDHHmmss>-<short-commit>`. The dist-tag is derived as `dev-<base>`. For example, base `0.0.1`, time `2026-08-04T00:32:00Z`, and short SHA `<short-commit>` produce version `0.0.1-20260804003200-<short-commit>` and tag `dev-0.0.1`. Retrying one release bundle must retain its version and manifest; repacking creates a version from the new command start time.
The pack phase runs in this order:
@@ -18,7 +18,7 @@ monorepo 中可运行的源码并不能证明发布后的包可运行。workspac
目标集合只包含 `packages/*/*/package.json` 与 `apps/*/package.json` 中命名为 `@deepseek-ai/*` 的 workspace 包。根项目、`website/`、vendor、Python 与 native workspace 不属于该 NPM 基线。发现机制必须拒绝重复包名、不同基础版本、意外的 `private` 发布状态以及集合中的未知包,而不是维护另一份手工包名列表。
预发布版本由包的稳定基础版本、命令启动时精确到秒的 UTC 时间戳和目标 commit 的 10 位短 SHA 组成:`<base>-<YYYYMMDDHHmmss>-<short-commit>`。dist-tag 由基础版本派生为 `dev-<base>`。例如,基础版本 `0.0.1`、时间 `2026-08-04T00:32:00Z` 和 commit `909292dd7b` 生成版本 `0.0.1-20260804003200-909292dd7b` 与 tag `dev-0.0.1`。同一 release bundle 的重试必须沿用原版本和 manifest;重新 pack 会按新的命令启动时间生成新版本。
预发布版本由包的稳定基础版本、命令启动时精确到秒的 UTC 时间戳和目标 commit 的 10 位短 SHA 组成:`<base>-<YYYYMMDDHHmmss>-<short-commit>`。dist-tag 由基础版本派生为 `dev-<base>`。例如,基础版本 `0.0.1`、时间 `2026-08-04T00:32:00Z` 和短 SHA `<short-commit>` 生成版本 `0.0.1-20260804003200-<short-commit>` 与 tag `dev-0.0.1`。同一 release bundle 的重试必须沿用原版本和 manifest;重新 pack 会按新的命令启动时间生成新版本。
pack 阶段按以下顺序执行:
+25 -1
View File
@@ -94,6 +94,7 @@ interface ScrollWorld {
interface ScrollWorldOptions {
readonly failureShot: string
readonly paceMs?: number
readonly replay?: ReplayOverrideDoc
readonly seeds: readonly { fixture: ChatScrollFixture; id: string }[]
}
@@ -117,6 +118,22 @@ function textStream(first: string, done: string, deltaCount: number): StreamChun
]
}
/** Hold replay text after the initial deltas until browser setup releases it. */
function holdTextAfter(world: ScrollWorld, initialDeltas: number): () => void {
const gate = Promise.withResolvers<undefined>()
const dispose = world.scaffold.ctx.on('llm/stream', async function* (_options, next) {
let deltas = 0
for await (const chunk of next()) {
if (chunk.type === 'text-delta' && deltas++ === initialDeltas) await gate.promise
yield chunk
}
})
return () => {
gate.resolve(undefined)
dispose()
}
}
function toolStream(): StreamChunk[] {
const command = [
`: > ${TOOL_READY_FILE}`,
@@ -160,7 +177,7 @@ async function launchScrollWorld(options: ScrollWorldOptions): Promise<ScrollWor
scaffold = await launchWebScaffold({
replayFixture: join(replayDir, 'override-only.jsonl'),
replayOverride,
paceMs: STREAM_PACE_MS,
paceMs: options.paceMs ?? STREAM_PACE_MS,
replayContextWindow: REPLAY_CONTEXT_WINDOW,
})
} else {
@@ -539,6 +556,7 @@ describe('web e2e: long Chat scroll contract', () => {
})
const settled = world.scaffold.whenTurnSettled(60_000)
const releaseText = holdTextAfter(world, 1)
try {
const composer = world.page.locator('[data-composer-input][contenteditable="true"]').last()
await composer.fill(LIVE_TEXT_PROMPT)
@@ -552,6 +570,7 @@ describe('web e2e: long Chat scroll contract', () => {
await wheelTranscript(world.page, 420)
const readerAnchor = await visibleFlowAnchor(world.page)
const chunksAfterAnchor = world.assistantFrames.filter(frame => frame.type === 'chunk').length
releaseText()
await expect.poll(
() => world.assistantFrames.filter(frame => frame.type === 'chunk').length,
{ timeout: 10_000 },
@@ -562,6 +581,7 @@ describe('web e2e: long Chat scroll contract', () => {
await nextPaint(world.page)
await expectSameFlowTop(world.page, readerAnchor)
} finally {
releaseText()
releaseHistory()
}
@@ -905,6 +925,7 @@ describe('web e2e: long Chat scroll contract', () => {
it.skipIf(MODE === 'record')('touch-style fling scrolling owns streaming bottom-follow without wheel input', async () => {
await withScrollWorld({
failureShot: 'web-e2e-chat-scroll-fling-stream',
paceMs: 0,
replay: [
replayEntry(toolStream()),
replayEntry(textStream(LIVE_FLING_FIRST, LIVE_FLING_DONE, 240)),
@@ -916,6 +937,7 @@ describe('web e2e: long Chat scroll contract', () => {
await openSeed(world.page, INPUTS_FIXTURE, INPUTS_FIXTURE.markers.assistant(INPUTS_FIXTURE.turns))
const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
const settled = world.scaffold.whenTurnSettled(60_000)
const releaseText = holdTextAfter(world, 16)
let released = false
try {
const composer = world.page.locator('[data-composer-input][contenteditable="true"]').last()
@@ -953,12 +975,14 @@ describe('web e2e: long Chat scroll contract', () => {
await expectBottom(world.page)
await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0)
const chunksAtRepin = world.assistantFrames.filter(frame => frame.type === 'chunk').length
releaseText()
await expect.poll(
() => world.assistantFrames.filter(frame => frame.type === 'chunk').length,
{ timeout: 15_000 },
).toBeGreaterThan(chunksAtRepin + 5)
await expectBottom(world.page)
} finally {
releaseText()
if (!released) await writeFile(releasePath, 'release\n').catch(() => {})
}
+23 -9
View File
@@ -271,7 +271,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) +
await openFile('smoke.md')
await expect.poll(() => viewer.innerText()).toBe('Markdown')
await preview.getByRole('heading', { name: 'Markdown smoke', exact: true }).waitFor({ timeout: 15_000 })
expect(await preview.getByText('Rendered from the workspace.', { exact: true }).isVisible()).toBe(true)
await preview.getByText('Rendered from the workspace.', { exact: true }).waitFor({ state: 'visible' })
const heading = await preview.getByRole('heading', { name: 'Markdown smoke', exact: true }).innerText()
const markdownTail = preview.getByRole('heading', { name: 'Markdown tail', exact: true })
await expect.poll(() => preview.locator('[data-textpreview-more]').isEnabled()).toBe(true)
@@ -379,13 +379,23 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) +
const html = page.frameLocator('[data-html-preview]')
await html.getByRole('heading', { name: 'HTML smoke', exact: true }).waitFor({ timeout: 15_000 })
await expect.poll(() => html.locator('#result').innerText()).toBe('INLINE_OK')
await expect.poll(() => previewNetworkRequests).toBe(1)
await expect.poll(() => html.locator('img[src="https://preview.invalid/developer-tools.png"]')
.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBe(1)
await expect.poll(() => html.locator('#local-result').innerText()).toBe('LOCAL_JS_OK')
await expect.poll(() => html.locator('#outside-result').innerText()).toBe('OUTSIDE_JS_OK')
await expect.poll(() => html.locator('#local-result').evaluate(node => getComputedStyle(node).color)).toBe('rgb(12, 34, 56)')
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)')
expect(await iframe.getAttribute('src')).not.toBe(beforeStyleSave)
const beforeScriptSave = await iframe.getAttribute('src')
await writeFile(join(cwd, 'local.js'), 'document.getElementById("local-result").textContent="LOCAL_JS_REFRESHED";')
await expect.poll(() => html.locator('#local-result').innerText()).toBe('LOCAL_JS_REFRESHED')
expect(await iframe.getAttribute('src')).not.toBe(beforeScriptSave)
await page.getByRole('tab', { name: /Trajectory/ }).click()
await scaffold.ctx.settings.update('ui-developer-tools', { enabled: false })
await expect.poll(() => page.getByRole('tab', { name: /Trajectory/ }).count()).toBe(0)
@@ -400,9 +410,9 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) +
`- Viewer: ${await viewer.innerText()}`,
`- Sandbox: ${await iframe.getAttribute('sandbox')}`,
`- Inline script: ${await html.locator('#result').innerText()}`,
`- Local script: ${await html.locator('#local-result').innerText()}`,
`- Local script after save: ${await html.locator('#local-result').innerText()}`,
`- Outside-workspace script: ${await html.locator('#outside-result').innerText()}`,
`- Local stylesheet: ${await html.locator('#local-result').evaluate(node => getComputedStyle(node).color)}`,
`- Local stylesheet after save: ${await html.locator('#local-result').evaluate(node => getComputedStyle(node).color)}`,
`- Parent access: ${await html.locator('#parent-result').innerText()} (${await html.locator('#parent-result').getAttribute('data-error')})`,
`- Parent unchanged: ${String(await page.locator('html').getAttribute('data-document-preview-escape') === null)}`,
].join('\n'))
@@ -594,11 +604,15 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) +
initialReading = await reading.isVisible()
expect(initialReading).toBe(true)
expect(await preview.locator('[data-code-preview]').count()).toBe(0)
const indicator = await reading.boundingBox()
const scroller = await body.boundingBox()
if (indicator === null || scroller === null) throw new Error('reading indicator or document body is not rendered')
expect(indicator.y).toBeGreaterThanOrEqual(scroller.y)
expect(indicator.y + indicator.height).toBeLessThanOrEqual(scroller.y + scroller.height)
const bounds = await reading.evaluate((node) => {
const body = node.closest('[data-textpreview-body]')
if (body === null) throw new Error('reading indicator has no document body')
const indicator = node.getBoundingClientRect()
const scroller = body.getBoundingClientRect()
return { top: indicator.top - scroller.top, bottom: scroller.bottom - indicator.bottom }
})
expect(bounds.top).toBeGreaterThanOrEqual(0)
expect(bounds.bottom).toBeGreaterThanOrEqual(0)
await successShot(page, 'code-reading')
} finally {
releaseRead.resolve(undefined)
+14 -1
View File
@@ -27,6 +27,7 @@ describe('web e2e: requested SVG is explicitly delivered', () => {
let tripwire: ReturnType<typeof watchConsole>
let cwd: string
let replayRoot: string | undefined
const connectionDiagnostics: string[] = []
beforeAll(async () => {
let replayOverride: string | undefined
@@ -48,6 +49,18 @@ describe('web e2e: requested SVG is explicitly delivered', () => {
viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE, timezoneId: 'Asia/Shanghai',
})
tripwire = watchConsole(page)
page.on('console', (message) => {
if (message.text().startsWith('[connection]')) connectionDiagnostics.push(message.text())
})
page.on('websocket', (socket) => {
const openedAt = Date.now()
let received = 0
socket.on('framereceived', () => { received++ })
socket.on('socketerror', (error) => { connectionDiagnostics.push(error) })
socket.on('close', () => {
connectionDiagnostics.push(`WebSocket closed after ${Date.now() - openedAt}ms and ${received} received frames`)
})
})
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]')
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
@@ -110,7 +123,7 @@ describe('web e2e: requested SVG is explicitly delivered', () => {
// The scaffold workspace is not a git repository, so the changed-files card lists the written SVG from the write call alone.
expect(await page.locator('[data-changed-files]').count()).toBe(1)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
expect(tripwire.warnings, connectionDiagnostics.join('\n')).toEqual([])
})
it.skipIf(MODE === 'record')('replays the delivered file and Chinese conversation', async () => {
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: 912eea4256a1816d34d14b2ad7e1e6e8a116b2a3
config-catalog.zh.md: 094427917a942ba3293d14bae261782c88799c7a
config-catalog.md: 118cf3ca1322ce03256a0fa0dc01f73a8f2bc00d
config-catalog.zh.md: 972bdb9198feeea42b43269a92fe597fd8a07c1e
+1 -1
View File
@@ -880,7 +880,7 @@ export interface Config {
}
```
Source: [`packages/fs/fs-local/src/index.ts:43`](../packages/fs/fs-local/src/index.ts)
Source: [`packages/fs/fs-local/src/index.ts:45`](../packages/fs/fs-local/src/index.ts)
<a id="deepseek-aidsh-fs-sandbox"></a>
+1 -1
View File
@@ -882,7 +882,7 @@ export interface Config {
}
```
来源:[`packages/fs/fs-local/src/index.ts:43`](../packages/fs/fs-local/src/index.ts)
来源:[`packages/fs/fs-local/src/index.ts:45`](../packages/fs/fs-local/src/index.ts)
<a id="deepseek-aidsh-fs-sandbox"></a>
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/filesystem.md
filesystem.md: c93d5e250860c19ab41f133848f5038b78f1bd57
filesystem.zh.md: e9011d35b9a52fd42439a23e78fe4bff7ae41c11
filesystem.md: a4ed5460ac333b57520b241f31f5c809e574f858
filesystem.zh.md: 6f06c140c1a9a2c685aebd6339cd1ee97d4303bb
+10
View File
@@ -292,6 +292,16 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
Abstract filesystem provider. Targets must preserve identity across aliases; reads expose regular UTF-8 text or typed errors, listings are stable and content-free, and mutations are atomic. Optional guards add stale protection without changing the unguarded provider contract.
```ts cordis-catalog
/**
* 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; 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.
*/
abstract watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise<void>>
/**
* Resolve a model/plugin-supplied path into a stable {@link FsTarget}. May perform I/O (a
* remote/sandboxed backend may need a round-trip to map a path to a stable identity), hence
+10
View File
@@ -292,6 +292,16 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
Abstract filesystem provider. Targets must preserve identity across aliases; reads expose regular UTF-8 text or typed errors, listings are stable and content-free, and mutations are atomic. Optional guards add stale protection without changing the unguarded provider contract.
```ts cordis-catalog
/**
* 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; 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.
*/
abstract watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise<void>>
/**
* Resolve a model/plugin-supplied path into a stable {@link FsTarget}. May perform I/O (a
* remote/sandboxed backend may need a round-trip to map a path to a stable identity), hence
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/sidebar-right.md
sidebar-right.md: 663139313edb3b2341874c05030faa8c26998944
sidebar-right.zh.md: bdab35cb88bb62c33e447b67592858c0c92f7a4c
sidebar-right.md: 9ad6bd63ed63c5c59dbe29cd1229a6412ad22de2
sidebar-right.zh.md: d0a967f400ea80ab696e24e1b4f46d7d8664eff8
+5 -5
View File
@@ -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<ArrayBuffer>` 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<ArrayBuffer>` 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/<child>?parent=<parent>&mode=<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)).
+5 -5
View File
@@ -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<ArrayBuffer>`。这些瞬时缓冲区按只读方式借用,不得进入持久布局或 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<ArrayBuffer>`。这些瞬时缓冲区按只读方式借用,不得进入持久布局或 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/<child>?parent=<parent>&mode=<mode>`。资源提供方保留一个显式寻址的 subagent Conversation,并通过共享 Conversation Factory 渲染([README](../../packages/client/ui-subagent/README.zh.md))。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/workspace.md
workspace.md: 80ea3157dbd904e168e2809ce8aa0b8641c1b2d9
workspace.zh.md: 727c612e58cdbe54b0ba0e6292803d4543e5a32e
workspace.md: 8ecf54a2860ab5d442c4856f6efd03900de1ce9b
workspace.zh.md: dd4aed5f271aff0256115b03c85ec8b50ad0c502
+6 -6
View File
@@ -415,15 +415,15 @@ Host Remote file reads and workspace directory observations over the composed fi
@Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>
/**
* Stream every `fs/observed` observation of a file inside the Session's
* workspace. Only instrumented filesystem operations report here; the OS is
* not watched.
* 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 path - target path; the Host determines its type and confines directories to the workspace.
* @param signal - generation cancellation.
* @returns `ready` once the Host observation queue is active and the workspace
* root is resolved, then queued and live observations in emission order.
* @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, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>
@Remote({ mode: 'stream' }) changes(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>
```
Source: [`packages/api/workspace-files/src/index.ts`](../../packages/api/workspace-files/src/index.ts)
+6 -6
View File
@@ -415,15 +415,15 @@ Host Remote file reads and workspace directory observations over the composed fi
@Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>
/**
* Stream every `fs/observed` observation of a file inside the Session's
* workspace. Only instrumented filesystem operations report here; the OS is
* not watched.
* 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 path - target path; the Host determines its type and confines directories to the workspace.
* @param signal - generation cancellation.
* @returns `ready` once the Host observation queue is active and the workspace
* root is resolved, then queued and live observations in emission order.
* @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, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>
@Remote({ mode: 'stream' }) changes(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>
```
Source: [`packages/api/workspace-files/src/index.ts`](../../packages/api/workspace-files/src/index.ts)
@@ -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: f7e40138d8b790f993e3cc8a15d4216ca966cabb
README.zh.md: 04548bed7ffc1698e9a05ffbc93157ea07d6f932
README.md: 6e5025b8f5543d810fa951acf5baa32a3c060494
README.zh.md: c913145966cc982e10c7a533274941c6c52b8b55
+17 -16
View File
@@ -1,5 +1,5 @@
---
description: "Workspace file service for the web GUI: bounded file reads through the composed filesystem, plus directory listing and instrumented filesystem observation inside the Session workspace root."
description: "Workspace file service for the web GUI: bounded reads and target-scoped filesystem watches, plus directory listing inside the Session workspace root."
kind: "package-reference"
---
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
Use this package to preview files readable through a Session's filesystem from the web client. It reads UTF-8 text by page, reads bounded byte windows or complete files, resolves related files from a base file's directory, and reports file metadata. File reads may target paths outside the workspace; directory listing and instrumented filesystem observations remain workspace-scoped. The service exposes no mutation operation.
Use this package to preview files readable through a Session's filesystem from the web client. It reads UTF-8 text by page, reads bounded byte windows or complete files, resolves related files from a base file's directory, and reports file metadata. Follow changes to a file or a directory's direct entries. File reads and watches may target paths outside the workspace; directory listing and watches remain workspace-scoped. The service exposes no mutation operation.
## Table of Contents
@@ -25,7 +25,7 @@ Use this package to preview files readable through a Session's filesystem from t
<a id="use-this-package"></a>
## 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, 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,11 +35,11 @@ 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()` | stream of `WorkspaceFileWatchFrame` | Subscription readiness, then filesystem observations inside the workspace root |
| `changes(path)` | stream of `WorkspaceFileWatchFrame` | Subscription readiness, then invalidations of one file or a directory's direct entries |
### Addressing and paths
`read`, `readBytes`, `readAll`, `readRelated`, and `stat` accept an absolute path or one relative to the selected Session's workspace root. The composed filesystem decides whether the path is readable; the service does not impose workspace containment on file reads. `readRelated` resolves a relative filesystem path from the base file's directory, including when either file is outside the workspace. These methods report the file's absolute path in the filesystem's execution world. `list` remains workspace-scoped and reports the listed directory relative to that root. `changes` likewise reports only instrumented filesystem observations inside the workspace root.
`read`, `readBytes`, `readAll`, `readRelated`, and `stat` accept an absolute path or one relative to the selected Session's workspace root. The composed filesystem decides whether the path is readable; the service does not impose workspace containment on file reads. `readRelated` resolves a relative filesystem path from the base file's directory, including when either file is outside the workspace. These methods report the file's absolute path in the filesystem's execution world. `list` remains workspace-scoped and reports the listed directory relative to that root. `changes` uses the same path resolution: file watches follow file-read authority, while directory watches remain workspace-scoped.
### Pages
@@ -51,11 +51,11 @@ Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, the Session store, and
### File-read and directory checks
Every operation first uses `lstat` to reject a missing path, a final symlink, or the wrong file kind. File operations then resolve and read through the composed filesystem without an additional workspace-containment check. `list` alone requires the resolved directory to remain inside the workspace root. The configured page, window, complete-file, and listing caps still apply. Text pages additionally reject invalid UTF-8 and NUL bytes; byte reads do not decode content. An empty path is a `gateway/bad-request`.
File reads and directory listing first use `lstat` to reject a missing path, a final symlink, or the wrong file kind. File operations then resolve and read through the composed filesystem without an additional workspace-containment check. Directory listing and watching require the resolved directory to remain inside the workspace root. The configured page, window, complete-file, and listing caps still apply. Text pages additionally reject invalid UTF-8 and NUL bytes; byte reads do not decode content. An empty read or listing path is a `gateway/bad-request`.
### The change feed
`changes` is a `stream` Remote. A generation registers its observation queue and resolves the Session workspace root before yielding `{ kind: 'ready' }`. It then yields `{ kind: 'change', change }`, where `change` is `{ absolutePath, version }` for a present file or `{ absolutePath, absent: true }` for one observed gone. The source is `fs/observed`, filtered to targets inside that root; the operating system is not watched. Observations after the generation's first pull are queued, including while the root resolves. The generation ends on cancellation or plugin disposal.
`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
@@ -70,7 +70,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
### Failures
Each failure is one `RemoteError` code with typed details, declared in [`src/types.ts`](src/types.ts): `workspace-file/not-found`, `workspace-file/outside-workspace` (directory listing only), `workspace-file/too-large` (with `limit`, the applicable page, window, or complete-file cap), `workspace-file/not-text`, `workspace-file/not-regular-file` (`kind`: `directory`, `symlink`, or `other`), and `workspace-file/not-directory` (`kind`: `file`, `symlink`, or `other`). Callers branch on the code, never on message text.
Each failure is one `RemoteError` code with typed details, declared in [`src/types.ts`](src/types.ts): `workspace-file/not-found`, `workspace-file/outside-workspace` (directory listing or watching), `workspace-file/watch-unsupported` (watch initialization failed), `workspace-file/too-large` (with `limit`, the applicable page, window, or complete-file cap), `workspace-file/not-text`, `workspace-file/not-regular-file` (`kind`: `directory`, `symlink`, or `other`), and `workspace-file/not-directory` (`kind`: `file`, `symlink`, or `other`). Callers branch on the code, never on message text.
### Client file resources
@@ -78,9 +78,9 @@ The browser export registers the `file` provider into `ctx.resources` and requir
A `session/<sessionId>/<path>` address carries the authorizing Session and a relative or absolute path; leading slashes are preserved, as in `dsh-resource://file/session/s//etc/hosts`. The Host receives the path unchanged and owns resolution and access checks; the Client needs no Session `cwd`. `absolute/<path>` remains parseable but has no authorizing Session and fails with `workspace-file/unknown-workspace`, without borrowing current or Tab Session. Unsupported addresses fail with `workspace-file/unsupported-address`. [Workspace-path](../../util/workspace-path/README.md) owns the grammar; the generic Resource layer knows only the address and `signal`.
The provider waits for the Host's `ready` frame before its first `stat`, queues changes during the read, then binds the follower to `stat.absolutePath`. Both queued and live changes match that Host-returned path. A new write version updates metadata while retaining the last byte size; duplicate versions are ignored. An absent notice re-stats the file. A failed stat keeps the address followed; a later write can recover it, and any Session write can trigger a retry before the first successful path binding. Frames are `RemoteResult` values, and programming exceptions remain uncaught.
The provider waits for the Host's `ready` frame before its first `stat`, queues changes during the read, then binds the follower to `stat.absolutePath`. Both queued and live changes match that Host-returned path. A new version or absence notice re-stats the file, refreshing its path, version, and byte size together; duplicate versions are ignored. A failed stat keeps the address followed so a later target change can recover it. If observation ends before ready, including `watch-unsupported`, an uncancelled provider still performs one stat and yields its result. Frames are `RemoteResult` values, and programming exceptions remain uncaught.
One supervised `changes` stream serves every followed file in a Session. Followers match absolute paths with backslashes normalized to slashes. Carrier loss reconnects through the Gateway supervisor; a Host-ended or terminally failed feed ends its followers and leaves their last metadata readable until reopened. The last follower leaving disposes the stream, a successor waits for that disposal, and plugin teardown awaits all pending closes. The provider declares `ResourceProtocolMap.file`; the text preview declares its Sidebar line-navigation parameters.
One supervised `changes` stream serves each Session and requested path; consumers of that target share it. Followers match absolute paths with backslashes normalized to slashes. Carrier loss reconnects through the Gateway supervisor and a new ready frame triggers stat again; a Host-ended or terminally failed feed ends its followers and leaves their last metadata readable until reopened. The last follower leaving disposes the stream, a successor waits for that disposal, and plugin teardown awaits all pending closes. The provider declares `ResourceProtocolMap.file`; the text preview declares its Sidebar line-navigation parameters.
-----
@@ -92,7 +92,7 @@ One supervised `changes` stream serves every followed file in a Session. Followe
### Design concept
Reads through `ctx.fs` use the backend's read authority; the sandboxing backend fences writes and edits, not reads. A Typert lookup derives `WorkspaceFileScope` from a live Session header or the persistence service's header-only `stat`, so cold subagent Sessions need neither Agent activation nor event-body reads. The service adds regular-file checks and bounded transfer, while workspace containment belongs only to directory listing and change observation. A page is cut from `streamText`, which decodes and rejects non-UTF-8 chunk by chunk: the cutter counts lines before the window without keeping them, admits each in-window segment against the byte cap before buffering it, and returns at the first character past the window. One `stat` before the stream names the version and size the page reports.
Reads through `ctx.fs` use the backend's read authority; the sandboxing backend fences writes and edits, not reads. A Typert lookup derives `WorkspaceFileScope` from a live Session header or the persistence service's header-only `stat`, so cold subagent Sessions need neither Agent activation nor event-body reads. The service adds regular-file checks and bounded transfer, while workspace containment belongs only to directory listing and directory watching. A page is cut from `streamText`, which decodes and rejects non-UTF-8 chunk by chunk: the cutter counts lines before the window without keeping them, admits each in-window segment against the byte cap before buffering it, and returns at the first character past the window. One `stat` before the stream names the version and size the page reports.
Complete-file reads delegate size enforcement to `fs.readBytes` and encode the returned bytes as base64 for Remote responses.
@@ -101,9 +101,9 @@ Complete-file reads delegate size enforcement to `fs.readBytes` and encode the r
| File | Role |
|---|---|
| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`: the `workspaceFiles` service and Remote namespace, `Config`, the gates, the page cutter, `read`, `readBytes`, `readAll`, `readRelated`, `stat`, `list` |
| [`src/changes.ts`](src/changes.ts) | `WorkspaceChangeFeed`: `fs/observed` subscription and one queue per open `changes` generation |
| [`src/changes.ts`](src/changes.ts) | `WorkspaceChangeFeed`: target watchers, matching `fs/observed` notifications, and one queue per generation |
| [`src/types.ts`](src/types.ts) | Wire types and the `RemoteErrorDetailsMap` codes, published as `./types` for Client packages |
| [`src/client/index.ts`](src/client/index.ts), [`provider.ts`](src/client/provider.ts), [`change-feed.ts`](src/client/change-feed.ts) | Browser plugin, file metadata, and per-Session change feed |
| [`src/client/index.ts`](src/client/index.ts), [`provider.ts`](src/client/provider.ts), [`change-feed.ts`](src/client/change-feed.ts) | Browser plugin, file metadata, and per-target change feeds |
| [`src/client/types.ts`](src/client/types.ts), [`remote.ts`](src/client/remote.ts) | Resource values, parameters, Client error codes, and generated Remote types |
| — | No runtime invariant companion is published; every Host answer is derived from `ctx.fs` and the sandbox policy at call time. |
@@ -138,12 +138,13 @@ None; this package neither assembles nor sends a provider request.
<a id="known-limitations-and-deferred-work"></a>
- **Instrumented operations only** — `changes` relays `fs/observed` emissions; a file changed by a subprocess, a shell command, or the user's editor produces no frame.
- **Directory scope only** — `list` and `changes` stay inside the Session workspace even though file preview reads may use any path readable by the filesystem backend.
- **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.
+17 -16
View File
@@ -1,5 +1,5 @@
---
description: "面向 Web GUI 的工作区文件服务:通过组合文件系统进行有界文件读取,并在 Session 工作区根内列举目录和观察已埋点的文件系统操作。"
description: "面向 Web GUI 的工作区文件服务:有界文件读取、按目标监听文件系统变化,以及 Session 工作区根内的目录列举。"
kind: "package-reference"
---
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
使用本包可从 Web Client 预览 Session 文件系统允许读取的文件。它按页读取 UTF-8 文本、按有界窗口或完整文件读取原始字节、从基文件目录解析关联文件,并报告文件元数据。文件读取可以指向工作区外路径;目录列举与已埋点的文件系统观察仍限定于工作区。本服务不提供修改操作。
使用本包可从 Web Client 预览 Session 文件系统允许读取的文件。它按页读取 UTF-8 文本、按有界窗口或完整文件读取原始字节、从基文件目录解析关联文件,并报告文件元数据。可以跟随一个文件或一个目录直接子项的变化。文件读取和监听可以指向工作区外路径;目录列举和监听仍限定于工作区。本服务不提供修改操作。
## 目录
@@ -25,7 +25,7 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
把本包与 `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, 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,11 +35,11 @@ kind: "package-reference"
| `readAll(path)` | `WorkspaceFileBytes`,其中 `offset: 0`、`eof: true` | `maxFileBytes` 内的完整原始字节;超大文件失败,不截断 |
| `readRelated(path, relativePath)` | `WorkspaceFileBytes` | Host 从基文件目录解析出的文件的完整字节 |
| `list(path)` | `WorkspaceDirectoryListing { path, entries, truncated }` | 一个目录的直接子项 |
| `changes()` | `WorkspaceFileWatchFrame` 流 | 订阅就绪确认,随后为工作区根内的文件系统观察 |
| `changes(path)` | `WorkspaceFileWatchFrame` 流 | 订阅就绪确认,随后为单个文件或目录直接子项的失效通知 |
### 寻址与路径
`read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 接受绝对路径或相对于所选 Session 工作区根的路径。组合文件系统决定路径是否可读;本服务不额外要求文件读取限定于工作区。`readRelated` 从基文件所在目录解析相对文件系统路径,基文件或目标文件位于工作区外时同样适用。这些方法以文件系统执行环境中的绝对路径报告文件。`list` 仍限定于工作区,并以相对于该根的路径报告被列举目录。`changes` 同样只报告工作区根内已埋点的文件系统观察。
`read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 接受绝对路径或相对于所选 Session 工作区根的路径。组合文件系统决定路径是否可读;本服务不额外要求文件读取限定于工作区。`readRelated` 从基文件所在目录解析相对文件系统路径,基文件或目标文件位于工作区外时同样适用。这些方法以文件系统执行环境中的绝对路径报告文件。`list` 仍限定于工作区,并以相对于该根的路径报告被列举目录。`changes` 使用相同的路径解析:文件监听沿用文件读取权限,目录监听仍限定于工作区。
### 分页
@@ -51,11 +51,11 @@ kind: "package-reference"
### 文件读取与目录检查
每项操作都先通过 `lstat` 拒绝不存在的路径、末端符号链接或错误的文件类型。文件操作随后通过组合文件系统解析和读取,不做额外的工作区包含检查。只有 `list` 要求解析后的目录仍位于工作区内。配置的分页、窗口、完整文件和目录列举上限仍然适用。文本页还拒绝无效 UTF-8 与 NUL 字节;字节读取不解码内容。空路径是 `gateway/bad-request`。
文件读取和目录列举先通过 `lstat` 拒绝不存在的路径、末端符号链接或错误的文件类型。文件操作随后通过组合文件系统解析和读取,不做额外的工作区包含检查。目录列举和监听要求解析后的目录仍位于工作区内。配置的分页、窗口、完整文件和目录列举上限仍然适用。文本页还拒绝无效 UTF-8 与 NUL 字节;字节读取不解码内容。读取或列举的空路径是 `gateway/bad-request`。
### 变更流
`changes` 是 `stream` 模式的 Remote。一代流注册观察队列并解析 Session 工作区根之后,才产出 `{ kind: 'ready' }`。随后产出 `{ kind: 'change', change }`,其中 `change` 对存在的文件为 `{ absolutePath, version }`,对被观察到已消失的文件为 `{ absolutePath, absent: true }`。来源是按该根内目标过滤的 `fs/observed`;操作系统并未被监视。一代流首次拉取后的观察都会排队,包括解析根期间的观察。流在取消或插件释放时结束。
`changes` 是 `stream` 模式的 Remote,只接受目标路径。Host 从 `stat` 取得实际目标类型;只要当前类型是目录,就要求位于工作区内,目标类型变化后也执行该检查。一代流先注册观察队列、解析目标并建立 `fs.watch`,然后产出 `{ kind: 'ready' }`。随后产出 `{ kind: 'change', change }`,其中 `change` 为当前 stat 元数据 `{ absolutePath, version }` 或 `{ absolutePath, absent: true }`。本地提供方使用 Chokidar,目录仅监听直接子项。匹配目标的 `fs/observed` 也触发失效。流在初始化期间排队变化。取消会独立于消费方拉取帧的进度关闭 watcher,流或插件拆除等待关闭完成。父目录保持存在时,缺失文件仍可监听同一路径的重建。
### 配置
@@ -70,7 +70,7 @@ kind: "package-reference"
### 失败
每种失败都是一个带类型化 details 的 `RemoteError` 代码,声明于 [`src/types.ts`](src/types.ts):`workspace-file/not-found`、`workspace-file/outside-workspace`(仅目录列举)、`workspace-file/too-large`(带 `limit`,即适用的页、窗口或完整文件上限)、`workspace-file/not-text`、`workspace-file/not-regular-file`(`kind` 为 `directory`、`symlink` 或 `other`)以及 `workspace-file/not-directory`(`kind` 为 `file`、`symlink` 或 `other`)。调用方按代码分支,绝不按消息文本。
每种失败都是一个带类型化 details 的 `RemoteError` 代码,声明于 [`src/types.ts`](src/types.ts):`workspace-file/not-found`、`workspace-file/outside-workspace`(目录列举或监听)、`workspace-file/watch-unsupported`(监听初始化失败)、`workspace-file/too-large`(带 `limit`,即适用的页、窗口或完整文件上限)、`workspace-file/not-text`、`workspace-file/not-regular-file`(`kind` 为 `directory`、`symlink` 或 `other`)以及 `workspace-file/not-directory`(`kind` 为 `file`、`symlink` 或 `other`)。调用方按代码分支,绝不按消息文本。
### Client 文件资源
@@ -78,9 +78,9 @@ kind: "package-reference"
`session/<sessionId>/<path>` 地址携带授权 Session,以及相对或绝对路径;前导斜杠保留,例如 `dsh-resource://file/session/s//etc/hosts`。Host 原样接收路径,负责解析与权限检查;Client 不需要 Session `cwd`。`absolute/<path>` 仍可解析,但没有授权 Session,以 `workspace-file/unknown-workspace` 失败,不借用当前或 Tab Session。不支持的地址以 `workspace-file/unsupported-address` 失败。语法由 [workspace-path](../../util/workspace-path/README.zh.md) 定义;Resource 泛型层只认地址和 `signal`。
提供方等到 Host 的 `ready` 帧后才发首次 `stat`,读取期间将变更排队,随后将跟随者绑定到 `stat.absolutePath`。排队与实时变更都按该 Host 返回路径匹配。新的写入版本更新元数据并保留最近的字节大小;重复版本被忽略。消失通知会重新 stat 文件。stat 失败后仍跟随地址,后续写入可使其恢复;首次成功绑定路径前,Session 内任何写入都可触发重试。帧是 `RemoteResult` 值,编程异常不被捕获。
提供方等到 Host 的 `ready` 帧后才发首次 `stat`,读取期间将变更排队,随后将跟随者绑定到 `stat.absolutePath`。排队与实时变更都按该 Host 返回路径匹配。新版本或消失通知会重新 stat,同时更新路径、版本和字节大小;重复版本被忽略。stat 失败后仍跟随地址,后续目标变化可使其恢复。如果监听在 ready 前结束,包括 `watch-unsupported`,未取消的提供方仍执行一次 stat 并产出结果。帧是 `RemoteResult` 值,编程异常不被捕获。
每个 Session 的所有被跟随文件共用一条受监督的 `changes` 流。跟随者按反斜杠归一为斜杠的绝对路径匹配。载体掉线由 Gateway 监督器重连;Host 结束或终态失败的流会结束其跟随者,最后的元数据仍可读取,直到重新打开。最后一个跟随者离开时释放流,后继流等待该释放完成,插件拆除等待所有在途关闭。提供者声明 `ResourceProtocolMap.file`;文本预览声明其 Sidebar 行号导航参数。
每个 Session 加请求路径对应一条受监督的 `changes` 流,同一目标的消费方共享该流。跟随者按反斜杠归一为斜杠的绝对路径匹配。载体掉线由 Gateway 监督器重连,新 ready 帧触发重新 stat;Host 结束或终态失败的流会结束其跟随者,最后的元数据仍可读取,直到重新打开。最后一个跟随者离开时释放流,后继流等待该释放完成,插件拆除等待所有在途关闭。提供者声明 `ResourceProtocolMap.file`;文本预览声明其 Sidebar 行号导航参数。
-----
@@ -92,7 +92,7 @@ kind: "package-reference"
### 设计概念
经 `ctx.fs` 的读取使用后端的读取权限;沙箱后端限制写与编辑,而不限制读取。Typert lookup 从 live Session header 或持久层的 header-only `stat` 导出 `WorkspaceFileScope`,所以 cold subagent Session 不需要激活 Agent 或读取事件正文。本服务增加普通文件检查与有界传输,工作区包含要求只属于目录列举与变更观察。页从 `streamText` 切出,后者逐块解码并拒绝非 UTF-8:切页器对窗口之前的行只计数不保留,对窗口内的每个片段先按字节上限验收再缓冲,并在窗口之后的第一个字符处返回。流之前的一次 `stat` 给出页所报告的版本与大小。
经 `ctx.fs` 的读取使用后端的读取权限;沙箱后端限制写与编辑,而不限制读取。Typert lookup 从 live Session header 或持久层的 header-only `stat` 导出 `WorkspaceFileScope`,所以 cold subagent Session 不需要激活 Agent 或读取事件正文。本服务增加普通文件检查与有界传输,工作区包含要求只属于目录列举与目录监听。页从 `streamText` 切出,后者逐块解码并拒绝非 UTF-8:切页器对窗口之前的行只计数不保留,对窗口内的每个片段先按字节上限验收再缓冲,并在窗口之后的第一个字符处返回。流之前的一次 `stat` 给出页所报告的版本与大小。
完整文件读取将大小上限检查交给 `fs.readBytes`,并将返回的字节编码为 base64,供 Remote 响应使用。
@@ -101,9 +101,9 @@ kind: "package-reference"
| 文件 | 职责 |
|---|---|
| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`:`workspaceFiles` 服务与 Remote 命名空间、`Config`、四道关、切页器、`read`、`readBytes`、`readAll`、`readRelated`、`stat`、`list` |
| [`src/changes.ts`](src/changes.ts) | `WorkspaceChangeFeed`:`fs/observed` 订阅与每个打开的 `changes` generation 各一条队列 |
| [`src/changes.ts`](src/changes.ts) | `WorkspaceChangeFeed`:目标监听、匹配的 `fs/observed` 通知与每代流各自的队列 |
| [`src/types.ts`](src/types.ts) | 线路类型与 `RemoteErrorDetailsMap` 错误码,以 `./types` 发布给 Client 包 |
| [`src/client/index.ts`](src/client/index.ts)、[`provider.ts`](src/client/provider.ts)、[`change-feed.ts`](src/client/change-feed.ts) | 浏览器插件、文件元数据与每 Session 变更流 |
| [`src/client/index.ts`](src/client/index.ts)、[`provider.ts`](src/client/provider.ts)、[`change-feed.ts`](src/client/change-feed.ts) | 浏览器插件、文件元数据与按目标建立的变更流 |
| [`src/client/types.ts`](src/client/types.ts)、[`remote.ts`](src/client/remote.ts) | 资源值、参数、Client 错误码与生成的 Remote 类型 |
| — | 不发布运行时 invariant 伴生件;每个 Host 答案都在调用时由 `ctx.fs` 与沙箱策略推导。 |
@@ -138,12 +138,13 @@ Typert 生成 `./typert` 与 `./remote` 暴露的 Host 与 Client Remote 产物
<a id="known-limitations-and-deferred-work"></a>
- **仅覆盖已埋点操作**——`changes` 转发 `fs/observed` 的发射;子进程、shell 命令或用户编辑器改动的文件不产生任何帧。
- **仅目录受限**——尽管文件预览可以读取文件系统后端允许的任意路径,`list` 与 `changes` 仍限定在 Session 工作区内。
- **提供方监听支持**——包括 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` 或流终态失败后,已打开的值保持最后已知状态,直到重新打开。
+95 -46
View File
@@ -1,21 +1,19 @@
/**
* Producer of the `changes` stream: every `fs/observed` emission whose target
* lies inside a generation's workspace root becomes one frame of that
* generation. Instrumented filesystem operations emit these observations; the
* operating system is not watched.
* Each generation acknowledges its observation queue and resolved workspace
* root with `ready` before emitting any queued or live changes.
* Target-scoped filesystem watches and `fs/observed` invalidations for `changes`.
* Each generation sends `ready` after watcher initialization, then reads current
* target metadata for matching queued and live invalidations.
*/
import type { Context } from '@deepseek-ai/cordis'
import { Deque } from '@deepseek-ai/dsh-deque'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import type { FsObservation, FsTarget } from '@deepseek-ai/dsh-fs'
import type { WorkspaceFileWatchFrame } from './types.ts'
/** One `fs/observed` emission as received, before any generation filters it. */
type Observed = readonly [target: FsTarget, observation: FsObservation]
/** 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<ChangeFollower>()
@@ -24,90 +22,141 @@ export class WorkspaceChangeFeed {
ctx.on('fs/observed', (target, observation) => {
for (const follower of this.followers) follower.push([target, observation])
})
ctx.effect(() => () => {
for (const follower of this.followers) follower.close()
ctx.effect(() => async () => {
const followers = [...this.followers]
await Promise.all(followers.map(follower => follower.close()))
this.followers.clear()
}, 'workspace-files.changes')
}
/**
* Open one generation reporting observations inside `workspaceRoot`.
* Open one target watch; directory targets remain inside `workspaceRoot`.
* @param workspaceRoot - the session's workspace root path.
* @param path - target path, resolved relative to the workspace root; Host metadata determines its type.
* @param signal - generation cancellation.
* @returns `ready` after observation is active and the root resolves, then
* observations made after the generation was first pulled, in emission order.
* @returns `ready` after watching starts, then current metadata for target invalidations.
*/
async *follow(workspaceRoot: string, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame> {
async *follow(workspaceRoot: string, path: string, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame> {
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<void>) | 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 (signal.aborted) return undefined
if (aborted()) return undefined
throw error
})
if (root === undefined || signal.aborted || follower.isClosed) return
if (root === undefined || aborted() || follower.isClosed) return
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
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)
else if (!follower.isClosed) follower.push([target])
}, signal)
if (follower.error !== undefined) throw follower.error
} catch (error) {
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 })
}
follower.initialized.resolve(unwatch)
if (aborted()) return
yield { kind: 'ready' }
for await (const [target, observation] of follower.read(signal)) {
if (!this.ctx.fs.contains(root, target)) continue
for await (const [observed] of follower.read()) {
if (observed.targetKey !== target.targetKey) continue
const info = await stat()
if (aborted()) return
const absolutePath = this.ctx.fs.processPath(target)
yield {
kind: 'change',
change: observation.kind === 'present'
? { absolutePath, version: observation.version }
change: info !== undefined
? { absolutePath, version: info.version }
: { absolutePath, absent: true },
}
}
} finally {
this.followers.delete(follower)
follower.close()
follower.initialized.resolve(unwatch)
await follower.close()
}
}
}
/** One generation's queue: observations wait here until its consumer pulls them. */
class ChangeFollower {
readonly controller = new AbortController()
readonly initialized = Promise.withResolvers<(() => Promise<void>) | undefined>()
error: Error | undefined
private readonly queue = new Deque<Observed>()
private wake: (() => void) | undefined
private closed = false
private closing: Promise<void> | 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<void> {
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.controller.abort()
}
/** Drain until closed or aborted; anything still queued then is dropped with the generation. */
async *read(signal: AbortSignal): AsyncIterable<Observed> {
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<void>((resolve) => { this.wake = resolve })
this.wake = undefined
async *read(): AsyncIterable<Observed> {
while (!this.closed) {
const observed = this.queue.popFront()
if (observed !== undefined) {
yield observed
continue
}
} finally {
signal.removeEventListener('abort', abort)
await new Promise<void>((resolve) => { this.wake = resolve })
this.wake = undefined
}
if (this.error !== undefined) throw this.error
}
}
@@ -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 `/`.
*/
@@ -83,7 +83,7 @@ class Follower implements AsyncIterable<WorkspaceFileNotice> {
while (true) {
const next = this.pending.shift()
if (next !== undefined) {
if (this.hostKey === undefined || next.key === this.hostKey) yield next.notice
if (next.notice.kind === 'refresh' || this.hostKey === undefined || next.key === this.hostKey) yield next.notice
continue
}
if (this.ended) return
@@ -96,35 +96,37 @@ class Follower implements AsyncIterable<WorkspaceFileNotice> {
}
}
/** The stream and followers of one session. */
/** The stream and followers of one Session and requested path. */
class SessionFeed {
private readonly followers = new Set<Follower>()
private readonly stream: SupervisedStream<WorkspaceFileWatchFrame>
private closed = false
private started = false
private acknowledged = false
/**
* @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(
remote: WorkspaceFilesRemote,
sessionId: SessionId,
path: string,
after: Promise<void> | undefined,
private readonly onClose: (closed: Promise<void>) => void,
) {
this.stream = remote.$stream<WorkspaceFileWatchFrame>({
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, 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()
@@ -156,7 +158,11 @@ class SessionFeed {
case 'ready':
item.accept()
this.started = true
for (const follower of this.followers) follower.start()
for (const follower of this.followers) {
follower.start()
if (this.acknowledged) follower.push({ kind: 'refresh' }, '')
}
this.acknowledged = true
break
case 'change': {
const key = keyOf(frame.change.absolutePath)
@@ -211,15 +217,15 @@ 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<SessionId, SessionFeed>()
/** Streams still closing, by session: the session's next feed opens after its predecessor has settled. */
private readonly closing = new Map<SessionId, Promise<void>>()
private readonly sessions = new Map<string, SessionFeed>()
/** Streams still closing, by Session and path; a successor waits for its predecessor. */
private readonly closing = new Map<string, Promise<void>>()
/**
* @param remote - the Remote face carrying `$stream` and `workspaceFiles.changes`.
@@ -231,19 +237,20 @@ 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 whose workspace holds the file.
* @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.
* @param signal - ends the follow.
* @returns a single-consumer subscription with Host-path binding and explicit disposal.
*/
follow(sessionId: SessionId, signal: AbortSignal): Follower {
const feed = signal.aborted ? undefined : this.feedOf(sessionId)
follow(sessionId: SessionId, path: string, signal: AbortSignal): Follower {
const feed = signal.aborted ? undefined : this.feedOf(sessionId, path)
const leave = (): void => {
signal.removeEventListener('abort', leave)
follower.end()
@@ -268,18 +275,19 @@ export class ChangeFeed {
await Promise.all(this.closing.values())
}
private feedOf(sessionId: SessionId): SessionFeed {
const existing = this.sessions.get(sessionId)
private feedOf(sessionId: SessionId, path: string): SessionFeed {
const key = JSON.stringify([sessionId, path])
const existing = this.sessions.get(key)
if (existing !== undefined) return existing
const feed = new SessionFeed(this.remote, sessionId, this.closing.get(sessionId), (closed) => {
this.sessions.delete(sessionId)
const feed = new SessionFeed(this.remote, sessionId, path, this.closing.get(key), (closed) => {
this.sessions.delete(key)
// A dispose that rejects is still a settled close: nothing remains to wait for.
const tracked: Promise<void> = closed.then(() => undefined, () => undefined).then(() => {
if (this.closing.get(sessionId) === tracked) this.closing.delete(sessionId)
if (this.closing.get(key) === tracked) this.closing.delete(key)
})
this.closing.set(sessionId, tracked)
this.closing.set(key, tracked)
})
this.sessions.set(sessionId, feed)
this.sessions.set(key, feed)
return feed
}
}
@@ -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(
@@ -57,7 +57,7 @@ export function createFileResourceProvider(
}
const { sessionId, path } = resolved.value
// Queue changes delivered to this Client while stat is pending.
const notices = changes.follow(sessionId, signal)
const notices = changes.follow(sessionId, path, signal)
const stat = (): Promise<RemoteResult<WorkspaceFileStat>> => remote.workspaceFiles.stat(sessionId, path, signal)
// Read through a call: a plain `signal.aborted` is narrowed to `false` by
// the first check and would read as always-false after the later awaits.
@@ -66,7 +66,13 @@ export function createFileResourceProvider(
// on the file, so a write can still bring the file live.
let current: WorkspaceFileStat | undefined
try {
if (!await notices.ready || aborted()) return
if (!await notices.ready) {
if (aborted()) return
const result = await stat()
if (!aborted()) yield result
return
}
if (aborted()) return
const first = await stat()
if (aborted()) return
if (first.ok) {
@@ -77,6 +83,7 @@ export function createFileResourceProvider(
yield first
}
for await (const notice of notices) {
if (aborted()) return
if (current === undefined) {
// Still gone: nothing new to report.
if (notice.kind === 'absent') continue
@@ -84,9 +91,6 @@ export function createFileResourceProvider(
// Frames report observations: holding this version already means the
// consumer learns nothing new.
if (notice.version === current.version) continue
current = { ...current, version: notice.version }
yield { ok: true, value: current }
continue
}
// A Host notice may mean stale content.
const again = await stat()
@@ -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. */
export type WorkspaceFileNotice = WorkspaceFileEdit
/** A target change or a request to restat after reconnection. */
export type WorkspaceFileNotice = WorkspaceFileEdit | { readonly kind: 'refresh' }
+7 -7
View File
@@ -352,17 +352,17 @@ export class WorkspaceFiles extends TypertRemoteService {
}
/**
* Stream every `fs/observed` observation of a file inside the Session's
* workspace. Only instrumented filesystem operations report here; the OS is
* not watched.
* 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 path - target path; the Host determines its type and confines directories to the workspace.
* @param signal - generation cancellation.
* @returns `ready` once the Host observation queue is active and the workspace
* root is resolved, then queued and live observations in emission order.
* @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, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame> {
return this.feed.follow(workspaceFileScope.workspaceRoot, signal)
changes(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame> {
return this.feed.follow(workspaceFileScope.workspaceRoot, path, signal)
}
/** Apply the page defaults and caps here, so the request never carries them implicitly. */
+12 -10
View File
@@ -115,28 +115,28 @@ 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' }
@@ -144,9 +144,11 @@ export type WorkspaceFileWatchFrame =
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 }
@@ -7,12 +7,13 @@ import type { ResourceProvider } from '@deepseek-ai/dsh-client-resources/client'
import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
import { describe, expect, it, onTestFinished } from 'vitest'
import { apply, inject } from '../src/client/index.ts'
import { FakeRemote, settle } from './fake-remote.client.ts'
import { FakeRemote } from './fake-remote.client.ts'
describe('workspace-files client apply', () => {
it('registers the file provider over ctx.remote, and unregisters it with the fiber', async () => {
const ctx = new Context()
const remote = new FakeRemote()
remote.autoReady = false
const controller = new AbortController()
const pulls: Array<Promise<unknown>> = []
ctx.provide('remote', remote as never)
@@ -34,6 +35,7 @@ describe('workspace-files client apply', () => {
const fiber = ctx.plugin({ inject: [...inject], apply })
onTestFinished(async () => {
await fiber.dispose()
await remote.dispose()
await Promise.all(pulls)
})
await fiber.await()
@@ -43,13 +45,21 @@ describe('workspace-files client apply', () => {
// A session address reaches the Host with its relative or absolute path.
pulls.push(registered[0]!.open(sessionFileAddress('s1', 'a.txt'), { signal })[Symbol.asyncIterator]().next())
pulls.push(registered[0]!.open(sessionFileAddress('s1', '/etc/hosts'), { signal })[Symbol.asyncIterator]().next())
await settle()
await Promise.all([remote.waitForChanges(0), remote.waitForChanges(1)])
expect(remote.opened.map(watch => [watch.sessionId, watch.path])).toEqual([['s1', 'a.txt'], ['s1', '/etc/hosts']])
expect(remote.stats).toEqual([])
await remote.ready(0)
await remote.waitForStat(0)
expect(remote.stats.map(pending => [pending.sessionId, pending.path])).toEqual([['s1', 'a.txt']])
await remote.ready(1)
await remote.waitForStat(1)
expect(remote.stats.map(pending => [pending.sessionId, pending.path])).toEqual([['s1', 'a.txt'], ['s1', '/etc/hosts']])
expect(remote.opened).toHaveLength(1)
expect(remote.opened).toHaveLength(2)
await fiber.dispose()
await Promise.all(pulls)
expect(released).toBe(1)
expect(remote.opened[0]!.source.aborted).toBe(true)
expect(remote.opened.map(watch => watch.source.aborted)).toEqual([true, true])
expect(remote.disposed).toHaveLength(2)
})
})
@@ -1,277 +1,307 @@
/**
* The change feed's promises: one Host stream per session, delivery by absolute
* path, and a follower's life bounded by its signal or by
* the stream's end.
*/
/** Target-scoped Host streams, canonical-path delivery, and follower disposal. */
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, onTestFinished } from 'vitest'
import { ChangeFeed } from '../src/client/change-feed.ts'
import type { WorkspaceFileWatchFrame } from '../src/types.ts'
import { FakeRemote, peek, settle } from './fake-remote.client.ts'
import { FakeRemote } from './fake-remote.client.ts'
const S1 = 's1' as SessionId
const S2 = 's2' as SessionId
const PATH = '/w/a.txt'
function harness() {
const remote = new FakeRemote()
remote.autoReady = false
const feed = new ChangeFeed(remote)
const followers: Array<{ controller: AbortController; it: AsyncIterator<unknown> }> = []
onTestFinished(async () => {
for (const follower of followers) follower.controller.abort()
await remote.dispose()
await Promise.all(followers.map(follower => Promise.resolve(follower.it.return?.())))
await feed.settle()
expect(remote.opened.every(watch => watch.source.aborted)).toBe(true)
})
const follow = (sessionId: SessionId, path: string, controller = new AbortController()) => {
const follower = feed.follow(sessionId, controller.signal)
const follower = feed.follow(sessionId, path, controller.signal)
follower.bind(path)
return { it: follower[Symbol.asyncIterator](), controller }
const result = { it: follower[Symbol.asyncIterator](), controller, ready: follower.ready }
followers.push(result)
return result
}
return { remote, feed, follow }
}
describe('ChangeFeed — one Host stream per session', () => {
it('starts a later follower from the existing session acknowledgement without opening another stream', async () => {
const { remote, feed } = harness()
const controller = new AbortController()
const first = feed.follow(S1, controller.signal)
try {
await expect(first.ready).resolves.toBe(true)
const second = feed.follow(S1, controller.signal)
await expect(second.ready).resolves.toBe(true)
expect(remote.calls).toEqual(['changes', 'accept'])
expect(remote.opened).toHaveLength(1)
second.bind('/w/second.txt')
const iterator = second[Symbol.asyncIterator]()
await remote.opened[0]!.source.deliver({
kind: 'change', change: { absolutePath: '/w/second.txt', version: 'v1' },
})
await expect(iterator.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 'v1' } })
controller.abort()
await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined })
await feed.settle()
expect(remote.disposed).toEqual(['workspace file changes of s1'])
} finally {
controller.abort()
await feed.settle()
}
})
it('shares one stream among the followers of a session and opens another per session', async () => {
const { remote, follow } = harness()
follow(S1, '/w/a.txt')
follow(S1, '/w/b.txt')
await settle()
expect(remote.opened.map(o => o.sessionId)).toEqual([S1])
follow(S2, '/w/a.txt')
await settle()
expect(remote.opened.map(o => o.sessionId)).toEqual([S1, S2])
})
it('disposes the session stream when its last follower leaves and reopens for the next', async () => {
const { remote, follow } = harness()
const a = follow(S1, '/w/a.txt')
const b = follow(S1, '/w/b.txt')
await settle()
a.controller.abort()
await settle()
expect(remote.disposed).toEqual([])
b.controller.abort()
await settle()
expect(remote.disposed).toEqual(['workspace file changes of s1'])
expect(remote.opened[0]!.source.aborted).toBe(true)
follow(S1, '/w/c.txt')
await settle()
expect(remote.opened).toHaveLength(2)
})
it('opens the next stream of a session only after the previous dispose settled, and settle() waits for it', async () => {
describe('ChangeFeed — one Host stream per Session and target', () => {
it('starts a later follower from the existing target acknowledgement without opening another stream', async () => {
const { remote, feed, follow } = harness()
let release!: () => void
remote.disposeGate = new Promise<void>((resolve) => { release = resolve })
const a = follow(S1, '/w/a.txt')
await settle()
a.controller.abort()
await settle()
const first = follow(S1, PATH)
await remote.ready(0)
await expect(first.ready).resolves.toBe(true)
const second = follow(S1, PATH)
await expect(second.ready).resolves.toBe(true)
expect(remote.calls).toEqual(['changes', 'accept'])
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' } })
first.controller.abort()
second.controller.abort()
await expect(second.it.next()).resolves.toEqual({ done: true, value: undefined })
await feed.settle()
expect(remote.disposed).toEqual(['workspace file changes of s1'])
// The next follower registers at once, but its Host stream waits for the close.
follow(S1, '/w/b.txt')
await settle()
expect(remote.opened).toHaveLength(1)
let settled = false
void feed.settle().then(() => { settled = true })
await settle()
expect(settled).toBe(false)
release()
await settle()
})
it('shares a stream only when both Session and target match', async () => {
const { remote, follow } = harness()
const one = follow(S1, PATH)
const twin = follow(S1, PATH)
const otherPath = follow(S1, '/w/b.txt')
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 }) => ({ sessionId, path }))).toEqual([
{ sessionId: S1, path: PATH },
{ sessionId: S1, path: '/w/b.txt' },
{ sessionId: S2, path: PATH },
])
})
it('disposes a target stream when its last follower leaves and reopens for the next', async () => {
const { remote, feed, follow } = harness()
const a = follow(S1, PATH)
const b = follow(S1, PATH)
const { source } = await remote.ready(0)
a.controller.abort()
await expect(a.it.next()).resolves.toEqual({ done: true, value: undefined })
expect(remote.disposed).toEqual([])
await source.deliver({ kind: 'change', change: { absolutePath: PATH, version: 'v1' } })
await expect(b.it.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 'v1' } })
b.controller.abort()
await feed.settle()
expect(remote.disposed).toEqual(['workspace file changes of s1'])
expect(source.aborted).toBe(true)
const next = follow(S1, PATH)
await remote.ready(1)
await expect(next.ready).resolves.toBe(true)
expect(remote.opened).toHaveLength(2)
})
it('waits for the same target to close while another target can open, and settle waits for the close', async () => {
const { remote, feed, follow } = harness()
const gate = remote.holdDisposal()
const a = follow(S1, PATH)
await remote.ready(0)
a.controller.abort()
await remote.waitForDispose(0)
const b = follow(S1, PATH)
let settled = false
const closing = feed.settle().then(() => { settled = true })
const independent = follow(S1, '/w/b.txt')
const otherWatch = await remote.ready(1)
await expect(independent.ready).resolves.toBe(true)
expect(otherWatch.path).toBe('/w/b.txt')
expect(remote.opened).toHaveLength(2)
expect(settled).toBe(false)
gate.resolve(undefined)
await closing
await remote.ready(2)
await expect(b.ready).resolves.toBe(true)
expect(remote.opened.map(watch => watch.path)).toEqual([PATH, '/w/b.txt', PATH])
expect(settled).toBe(true)
// Nothing left closing: settle() resolves at once.
await feed.settle()
})
it('keeps waiting for the newest close when two closes of one session overlap', async () => {
it('keeps waiting for the newest close when two closes of one target overlap', async () => {
const { remote, feed, follow } = harness()
let release!: () => void
remote.disposeGate = new Promise<void>((resolve) => { release = resolve })
const a = follow(S1, '/w/a.txt')
await settle()
const firstGate = remote.holdDisposal()
const a = follow(S1, PATH)
await remote.ready(0)
a.controller.abort()
await settle()
// The second feed waits for the first close, then its only follower leaves too.
const b = follow(S1, '/w/b.txt')
await settle()
await remote.waitForDispose(0)
const b = follow(S1, PATH)
const secondGate = remote.holdDisposal()
b.controller.abort()
await settle()
await remote.waitForDispose(1)
await expect(b.ready).resolves.toBe(false)
let settled = false
void feed.settle().then(() => { settled = true })
release()
await settle()
await settle()
expect(settled).toBe(true)
const closing = feed.settle().then(() => { settled = true })
firstGate.resolve(undefined)
await remote.waitForStreamEnd(1)
expect(remote.disposed).toHaveLength(2)
follow(S1, '/w/c.txt')
await settle()
expect(settled).toBe(false)
secondGate.resolve(undefined)
await closing
expect(settled).toBe(true)
const next = follow(S1, PATH)
await remote.ready(2)
await expect(next.ready).resolves.toBe(true)
expect(remote.opened).toHaveLength(3)
})
it('treats a dispose that rejects as settled, so the next stream still opens', async () => {
it('treats a rejected dispose as settled so the same target can reopen', async () => {
const { remote, feed, follow } = harness()
// Rejected only once dispose() has taken the gate, so the rejection always has a handler.
let fail!: (error: Error) => void
remote.disposeGate = new Promise<void>((_resolve, reject) => { fail = reject })
const a = follow(S1, '/w/a.txt')
await settle()
const gate = remote.holdDisposal()
const a = follow(S1, PATH)
await remote.ready(0)
a.controller.abort()
await settle()
follow(S1, '/w/b.txt')
await settle()
expect(remote.opened).toHaveLength(1)
fail(new Error('carrier gone'))
await settle()
expect(remote.opened).toHaveLength(2)
await remote.waitForDispose(0)
const b = follow(S1, PATH)
const independent = follow(S1, '/w/b.txt')
await remote.ready(1)
await expect(independent.ready).resolves.toBe(true)
expect(remote.opened.map(watch => watch.path)).toEqual([PATH, '/w/b.txt'])
gate.reject(new Error('carrier gone'))
remote.disposeGate = undefined
await remote.ready(2)
await expect(b.ready).resolves.toBe(true)
expect(remote.opened).toHaveLength(3)
await feed.settle()
})
})
describe('ChangeFeed — delivery', () => {
it('routes a frame to the followers of its path, whichever separator the Host spells', async () => {
it('routes only matching canonical paths to shared followers, normalizing Host separators', async () => {
const { remote, follow } = harness()
// A stat and a change frame may spell the same Host path with different separators.
const mine = follow(S1, 'C:/w/a b.txt')
const twin = follow(S1, 'C:/w/a b.txt')
const other = follow(S1, 'C:/w/other.txt')
await settle()
const source = remote.opened[0]!.source
source.push({ kind: 'change', change: { absolutePath: 'C:/w/a b.txt', version: 'v1' } })
source.push({ kind: 'change', change: { absolutePath: 'C:\\w\\a b.txt', absent: true } })
const { source } = await remote.ready(0)
const otherWatch = await remote.ready(1)
await source.deliver({ kind: 'change', change: { absolutePath: 'C:/w/a b.txt', version: 'v1' } })
await source.deliver({ kind: 'change', change: { absolutePath: 'C:\\w\\a b.txt', absent: true } })
await expect(mine.it.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 'v1' } })
await expect(mine.it.next()).resolves.toEqual({ done: false, value: { kind: 'absent' } })
await expect(twin.it.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 'v1' } })
await expect(peek(other.it)).resolves.toBe('silent')
// One follower of a path leaving does not silence the other.
mine.controller.abort()
await settle()
source.push({ kind: 'change', change: { absolutePath: 'C:/w/a b.txt', version: 'v2' } })
await source.deliver({ kind: 'change', change: { absolutePath: 'C:/w/other.txt', version: 'wrong-target' } })
await source.deliver({ kind: 'change', change: { absolutePath: 'C:/w/a b.txt', version: 'v2' } })
await expect(twin.it.next()).resolves.toEqual({ done: false, value: { kind: 'absent' } })
await expect(twin.it.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 'v2' } })
await otherWatch.source.deliver({ kind: 'change', change: { absolutePath: 'C:/w/other.txt', version: 'own-target' } })
await expect(other.it.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 'own-target' } })
})
it('queues frames reported before the consumer starts pulling', async () => {
const { remote, follow } = harness()
const mine = follow(S1, '/w/a.txt')
await settle()
remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: '/w/a.txt', version: 'v1' } })
await settle()
const mine = follow(S1, PATH)
const { source } = await remote.ready(0)
await source.deliver({ kind: 'change', change: { absolutePath: PATH, version: 'v1' } })
await source.deliver({ kind: 'change', change: { absolutePath: PATH, version: 'v2' } })
await expect(mine.it.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 'v1' } })
await expect(mine.it.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 'v2' } })
})
it('keeps sessions apart', async () => {
it('keeps Sessions apart even when their target paths match', async () => {
const { remote, follow } = harness()
const one = follow(S1, '/w/a.txt')
const two = follow(S2, '/w/a.txt')
await settle()
remote.opened[1]!.source.push({ kind: 'change', change: { absolutePath: '/w/a.txt', version: 'v2' } })
await expect(two.it.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 'v2' } })
await expect(peek(one.it)).resolves.toBe('silent')
const one = follow(S1, PATH)
const two = follow(S2, PATH)
const firstWatch = await remote.ready(0)
const secondWatch = await remote.ready(1)
await secondWatch.source.deliver({ kind: 'change', change: { absolutePath: PATH, version: 's2-version' } })
await expect(two.it.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 's2-version' } })
await firstWatch.source.deliver({ kind: 'change', change: { absolutePath: PATH, version: 's1-version' } })
await expect(one.it.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 's1-version' } })
})
it('emits refresh on a later ready for every follower of that target only', async () => {
const { remote, follow } = harness()
const one = follow(S1, PATH)
const twin = follow(S1, PATH)
const other = follow(S1, '/w/b.txt')
const { source } = await remote.ready(0)
const otherWatch = await remote.ready(1)
await source.deliver({ kind: 'ready' })
await expect(one.it.next()).resolves.toEqual({ done: false, value: { kind: 'refresh' } })
await expect(twin.it.next()).resolves.toEqual({ done: false, value: { kind: 'refresh' } })
await otherWatch.source.deliver({ kind: 'change', change: { absolutePath: '/w/b.txt', version: 'v1' } })
await expect(other.it.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 'v1' } })
expect(remote.calls).toEqual(['changes', 'changes', 'accept', 'accept', 'accept'])
})
})
describe('ChangeFeed — a follower ends', () => {
it('ends every follower and disposes the session stream for an unknown wire frame kind', async () => {
const { remote, feed } = harness()
const controller = new AbortController()
const first = feed.follow(S1, controller.signal)
const second = feed.follow(S1, controller.signal)
const firstIterator = first[Symbol.asyncIterator]()
const secondIterator = second[Symbol.asyncIterator]()
try {
await expect(Promise.all([first.ready, second.ready])).resolves.toEqual([true, true])
const endings = Promise.all([firstIterator.next(), secondIterator.next()])
const source = remote.opened[0]!.source
// The Remote double supplies decoded wire data, including an unknown protocol tag.
const wireFrame: unknown = JSON.parse('{"kind":"future-frame"}')
source.push(wireFrame as WorkspaceFileWatchFrame)
await expect(endings).resolves.toEqual([
{ done: true, value: undefined },
{ done: true, value: undefined },
])
await feed.settle()
expect(source.aborted).toBe(true)
expect(remote.disposed).toEqual(['workspace file changes of s1'])
expect(remote.opened).toHaveLength(1)
} finally {
controller.abort()
await Promise.all([firstIterator.return?.(), secondIterator.return?.()])
await feed.settle()
}
it('ends every shared follower and disposes the target stream for an unknown wire frame kind', async () => {
const { remote, feed, follow } = harness()
const first = follow(S1, PATH)
const second = follow(S1, PATH)
const { source } = await remote.ready(0)
await expect(Promise.all([first.ready, second.ready])).resolves.toEqual([true, true])
const endings = Promise.all([first.it.next(), second.it.next()])
// The Remote double supplies decoded wire data, including an unknown protocol tag.
const wireFrame: unknown = JSON.parse('{"kind":"future-frame"}')
source.push(wireFrame as WorkspaceFileWatchFrame)
await expect(endings).resolves.toEqual([
{ done: true, value: undefined },
{ done: true, value: undefined },
])
await feed.settle()
expect(source.aborted).toBe(true)
expect(remote.disposed).toEqual(['workspace file changes of s1'])
expect(remote.opened).toHaveLength(1)
})
it('ends on its signal and drops nothing queued before it', async () => {
const { remote, follow } = harness()
const mine = follow(S1, '/w/a.txt')
await settle()
remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: '/w/a.txt', version: 'v1' } })
await settle()
const mine = follow(S1, PATH)
const { source } = await remote.ready(0)
await source.deliver({ kind: 'change', change: { absolutePath: PATH, version: 'v1' } })
mine.controller.abort()
await expect(mine.it.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 'v1' } })
await expect(mine.it.next()).resolves.toEqual({ done: true, value: undefined })
})
it('is empty when the signal is already aborted, without opening a stream', async () => {
const { remote, feed } = harness()
it('is empty when the signal is already aborted without opening a stream', async () => {
const { remote, feed, follow } = harness()
const controller = new AbortController()
controller.abort()
const it = feed.follow(S1, controller.signal)[Symbol.asyncIterator]()
await expect(it.next()).resolves.toEqual({ done: true, value: undefined })
await settle()
const follower = follow(S1, PATH, controller)
await expect(follower.ready).resolves.toBe(false)
await expect(follower.it.next()).resolves.toEqual({ done: true, value: undefined })
await feed.settle()
expect(remote.opened).toEqual([])
})
it('unregisters when the consumer breaks out after a notice', async () => {
const { remote, follow } = harness()
const mine = follow(S1, '/w/a.txt')
await settle()
remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: '/w/a.txt', version: 'v1' } })
await mine.it.next()
const { remote, feed, follow } = harness()
const mine = follow(S1, PATH)
const { source } = await remote.ready(0)
await source.deliver({ kind: 'change', change: { absolutePath: PATH, version: 'v1' } })
await expect(mine.it.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 'v1' } })
await mine.it.return?.()
await settle()
await feed.settle()
expect(remote.disposed).toEqual(['workspace file changes of s1'])
expect(source.aborted).toBe(true)
})
it('ends every follower when the Host closes the session stream', async () => {
const { remote, follow } = harness()
const a = follow(S1, '/w/a.txt')
const b = follow(S1, '/w/b.txt')
await settle()
remote.opened[0]!.source.end()
it('ends shared followers when the Host closes their target stream without ending other targets', async () => {
const { remote, feed, follow } = harness()
const a = follow(S1, PATH)
const b = follow(S1, PATH)
const other = follow(S1, '/w/b.txt')
const { source } = await remote.ready(0)
const otherWatch = await remote.ready(1)
source.end()
await expect(a.it.next()).resolves.toEqual({ done: true, value: undefined })
await expect(b.it.next()).resolves.toEqual({ done: true, value: undefined })
// The next follower starts a fresh stream rather than joining the dead one.
follow(S1, '/w/c.txt')
await settle()
expect(remote.opened).toHaveLength(2)
await feed.settle()
await otherWatch.source.deliver({ kind: 'change', change: { absolutePath: '/w/b.txt', version: 'v1' } })
await expect(other.it.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 'v1' } })
const next = follow(S1, PATH)
await remote.ready(2)
await expect(next.ready).resolves.toBe(true)
expect(remote.opened).toHaveLength(3)
})
it('ends every follower when the session stream fails', async () => {
const { remote, follow } = harness()
const a = follow(S1, '/w/a.txt')
await settle()
remote.opened[0]!.source.fail(new Error('carrier gone for good'))
it('ends every follower when its target stream fails', async () => {
const { remote, feed, follow } = harness()
const a = follow(S1, PATH)
const b = follow(S1, PATH)
const { source } = await remote.ready(0)
source.fail(new Error('carrier gone for good'))
await expect(a.it.next()).resolves.toEqual({ done: true, value: undefined })
await expect(b.it.next()).resolves.toEqual({ done: true, value: undefined })
await feed.settle()
expect(source.aborted).toBe(true)
})
})
File diff suppressed because it is too large. Load diff
@@ -4,6 +4,7 @@
* classifies its end the way the real one does.
*/
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 } from '../src/types.ts'
import type { SupervisedStream, SupervisedStreamOptions, WorkspaceFilesRemote } from '../src/client/remote.ts'
@@ -14,14 +15,17 @@ export class Source<T> implements AsyncIterable<T> {
{ kind: 'value'; value: T; delivered?: () => void } | { kind: 'end' } | { kind: 'fail'; error: unknown }
> = []
private wake: (() => void) | undefined
aborted = false
constructor(signal: AbortSignal) {
this.aborted = signal.aborted
signal.addEventListener('abort', () => {
this.aborted = true
this.wake?.()
}, { once: true })
get aborted(): boolean {
return this.signal.aborted
}
constructor(private readonly signal: AbortSignal) {
signal.addEventListener('abort', this.abort, { once: true })
}
private readonly abort = (): void => {
this.wake?.()
}
push(value: T): void {
@@ -48,21 +52,25 @@ export class Source<T> implements AsyncIterable<T> {
}
async *[Symbol.asyncIterator](): AsyncIterator<T> {
while (true) {
if (this.aborted) return
const next = this.queue.shift()
if (next === undefined) {
await new Promise<void>((resolve) => { this.wake = resolve })
this.wake = undefined
continue
try {
while (true) {
if (this.aborted) return
const next = this.queue.shift()
if (next === undefined) {
await new Promise<void>((resolve) => { this.wake = resolve })
this.wake = undefined
continue
}
if (next.kind === 'value') {
yield next.value
next.delivered?.()
continue
}
if (next.kind === 'end') return
throw next.error
}
if (next.kind === 'value') {
yield next.value
next.delivered?.()
continue
}
if (next.kind === 'end') return
throw next.error
} finally {
this.signal.removeEventListener('abort', this.abort)
}
}
}
@@ -78,10 +86,11 @@ export interface PendingStat {
/** One opened Host watch whose acknowledgement and changes the spec controls. */
interface OpenedWatch {
readonly sessionId: SessionId
readonly path: string
readonly source: Source<WorkspaceFileWatchFrame>
}
/** The scripted Remote: every stat waits for the spec, every session stream is a {@link Source}. */
/** Scripted stats and target-scoped Host streams, with explicit delivery and disposal barriers. */
export class FakeRemote implements WorkspaceFilesRemote {
readonly calls: Array<'changes' | 'accept' | 'stat'> = []
readonly opened: OpenedWatch[] = []
@@ -89,11 +98,59 @@ export class FakeRemote implements WorkspaceFilesRemote {
readonly stats: PendingStat[] = []
private readonly statWaiters = new Map<number, Array<(stat: PendingStat) => void>>()
private readonly watchWaiters = new Map<number, Array<(watch: OpenedWatch) => void>>()
private readonly disposeWaiters = new Map<number, Array<() => void>>()
private readonly streams: Array<{ readonly done: Promise<void>; dispose(): Promise<void> }> = []
private readonly disposeGates: Array<PromiseWithResolvers<undefined>> = []
private closed = false
/** False lets a spec keep the Host subscription unacknowledged. */
autoReady = true
/** When set, every stream dispose waits for it before settling. */
disposeGate: Promise<void> | undefined
/** Hold subsequent stream disposals until the spec releases or rejects this gate. */
holdDisposal(): PromiseWithResolvers<undefined> {
const gate = Promise.withResolvers<undefined>()
this.disposeGates.push(gate)
this.disposeGate = gate.promise
return gate
}
/** Acknowledge one opened Host subscription and await the feed's acceptance. */
async ready(index: number): Promise<OpenedWatch> {
const watch = await this.waitForChanges(index)
await watch.source.deliver({ kind: 'ready' })
return watch
}
/** Wait until the feed requests an indexed stream disposal, before its gate settles. */
waitForDispose(index: number): Promise<void> {
if (this.disposed[index] !== undefined) return Promise.resolve()
return new Promise((resolve) => {
const waiters = this.disposeWaiters.get(index) ?? []
waiters.push(resolve)
this.disposeWaiters.set(index, waiters)
})
}
/** Wait for one supervised iterator to finish independently of its disposal gate. */
waitForStreamEnd(index: number): Promise<void> {
const stream = this.streams[index]
if (stream === undefined) throw new Error(`No supervised stream at index ${index}`)
return stream.done
}
/** Release gates, settle pending stats, and await every fake stream, including rejected disposals. */
async dispose(): Promise<void> {
this.closed = true
for (const gate of this.disposeGates) gate.resolve(undefined)
for (const request of this.stats) request.resolve(this.closedStat())
await Promise.allSettled(this.streams.map(stream => stream.dispose()))
}
private closedStat(): RemoteResult<WorkspaceFileStat> {
return { ok: false, error: new RemoteError('gateway/internal', 'test ended', {}) }
}
/** Wait for an indexed stat request without advancing or assuming scheduler timing. */
waitForStat(index: number): Promise<PendingStat> {
const stat = this.stats[index]
@@ -118,9 +175,27 @@ export class FakeRemote implements WorkspaceFilesRemote {
$stream<Item>(options: SupervisedStreamOptions<Item>): SupervisedStream<Item> {
const controller = new AbortController()
const disposed = this.disposed
const calls = this.calls
const done = Promise.withResolvers<undefined>()
let closing: Promise<void> | undefined
const dispose = (): Promise<void> => {
if (closing !== undefined) return closing
const index = this.disposed.length
this.disposed.push(options.name)
controller.abort(new Error('disposed'))
const gate = this.disposeGate
closing = (async () => {
try {
await gate
} finally {
await done.promise
}
})()
for (const waiter of this.disposeWaiters.get(index) ?? []) waiter()
this.disposeWaiters.delete(index)
return closing
}
this.streams.push({ done: done.promise, dispose })
return {
async *[Symbol.asyncIterator]() {
try {
@@ -135,12 +210,7 @@ export class FakeRemote implements WorkspaceFilesRemote {
done.resolve(undefined)
}
},
dispose: async () => {
disposed.push(options.name)
controller.abort(new Error('disposed'))
await this.disposeGate
await done.promise
},
dispose,
}
}
@@ -153,12 +223,13 @@ export class FakeRemote implements WorkspaceFilesRemote {
this.stats.push(stat)
for (const waiter of this.statWaiters.get(index) ?? []) waiter(stat)
this.statWaiters.delete(index)
if (this.closed) resolve(this.closedStat())
}),
changes: (sessionId: SessionId, signal?: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame> => {
changes: (sessionId: SessionId, path: string, signal?: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame> => {
this.calls.push('changes')
if (signal === undefined) throw new Error('the feed must hand its signal to the Host stream')
const source = new Source<WorkspaceFileWatchFrame>(signal)
const watch = { sessionId, 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)
@@ -171,8 +242,3 @@ export class FakeRemote implements WorkspaceFilesRemote {
/** Let queued microtasks and background pumps settle. */
export const settle = (): Promise<void> => new Promise((resolve) => { setTimeout(resolve, 0) })
/** The next item, or `'silent'` when none arrives within a tick. */
export async function peek<T>(it: AsyncIterator<T>): Promise<IteratorResult<T> | 'silent'> {
return Promise.race([it.next(), settle().then(() => 'silent' as const)])
}
@@ -1,11 +1,13 @@
/** Host-resolved file identities across pending stats, retries, and disposal. */
/** Host-resolved file identities across pending stats, reconnects, and disposal. */
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
import { describe, expect, it, onTestFinished } from 'vitest'
import { ChangeFeed } from '../src/client/change-feed.ts'
import { createFileResourceProvider } from '../src/client/provider.ts'
import { FakeRemote, settle } from './fake-remote.client.ts'
import type { WorkspaceFileStat } from '../src/types.ts'
import { FakeRemote } from './fake-remote.client.ts'
const SESSION = 'host-only' as SessionId
const RELATIVE = 'linked/a b.txt'
@@ -14,20 +16,23 @@ const CANONICAL = '/host/canonical/a b.txt'
function harness() {
const remote = new FakeRemote()
remote.autoReady = false
const changes = new ChangeFeed(remote)
const provider = createFileResourceProvider(remote, changes)
const resources: Array<{ controller: AbortController; iterator: AsyncIterator<RemoteResult<WorkspaceFileStat>> }> = []
onTestFinished(async () => {
for (const resource of resources) resource.controller.abort()
await remote.dispose()
await Promise.all(resources.map(resource => Promise.resolve(resource.iterator.return?.())))
await changes.settle()
expect(remote.opened.every(watch => watch.source.aborted)).toBe(true)
})
const open = (address = ADDRESS) => {
const controller = new AbortController()
const iterator = provider.open(address, { signal: controller.signal })[Symbol.asyncIterator]()
onTestFinished(async () => {
controller.abort()
for (const request of remote.stats) {
request.resolve({ ok: false, error: new RemoteError('gateway/internal', 'test ended', {}) })
}
await iterator.return?.()
await changes.settle()
})
return { iterator, controller }
const resource = { iterator, controller }
resources.push(resource)
return resource
}
return { remote, changes, open }
}
@@ -37,6 +42,8 @@ describe('Host-resolved file paths', () => {
const { remote, open } = harness()
const { iterator } = open()
const first = iterator.next()
const watch = await remote.ready(0)
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 })
@@ -46,11 +53,9 @@ describe('Host-resolved file paths', () => {
it('does not stat an opened changes stream until the Host acknowledges its subscription', async () => {
const { remote, open } = harness()
remote.autoReady = false
const { iterator } = open()
const first = iterator.next()
const { source } = await remote.waitForChanges(0)
await settle()
expect(remote.calls).toEqual(['changes'])
expect(remote.stats).toEqual([])
@@ -61,9 +66,8 @@ describe('Host-resolved file paths', () => {
await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v0' } } })
})
it.each(['abort', 'end', 'failure'] as const)('settles %s before Host ready without sending a stat or leaving a stream', async (ending) => {
it.each(['abort', 'end', 'failure', 'watch-unsupported'] as const)('settles %s before Host ready with one fallback stat unless aborted', async (ending) => {
const { remote, changes, open } = harness()
remote.autoReady = false
const { iterator, controller } = open()
const first = iterator.next()
const { source } = await remote.waitForChanges(0)
@@ -71,159 +75,241 @@ describe('Host-resolved file paths', () => {
case 'abort': controller.abort(); break
case 'end': source.end(); break
case 'failure': source.fail(new Error('workspace root unavailable')); break
case 'watch-unsupported':
source.fail(new RemoteError('workspace-file/watch-unsupported', 'watch unavailable', { path: RELATIVE }))
break
default: throw new Error(`Unexpected stream ending: ${ending satisfies never}`)
}
await expect(first).resolves.toEqual({ done: true, value: undefined })
await remote.waitForDispose(0)
await changes.settle()
expect(remote.calls).toEqual(['changes'])
expect(remote.stats).toEqual([])
expect(remote.disposed).toEqual([`workspace file changes of ${SESSION}`])
if (ending === 'abort') {
expect(remote.calls).toEqual(['changes'])
expect(remote.stats).toEqual([])
await expect(first).resolves.toEqual({ done: true, value: undefined })
} else {
const request = await remote.waitForStat(0)
expect(request).toMatchObject({ sessionId: SESSION, path: RELATIVE, signal: controller.signal })
const result: RemoteResult<WorkspaceFileStat> = ending === 'failure'
? { ok: false, error: new RemoteError('workspace-file/not-found', 'missing', { path: RELATIVE }) }
: { ok: true, value: { absolutePath: CANONICAL, version: 'v0', bytes: 3 } }
request.resolve(result)
await expect(first).resolves.toEqual({ done: false, value: result })
await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined })
expect(remote.calls).toEqual(['changes', 'stat'])
expect(remote.stats).toHaveLength(1)
}
expect(remote.disposed).toEqual(['workspace file changes of ' + SESSION])
expect(source.aborted).toBe(true)
})
it('accepts another generation ready without resetting metadata or re-statting existing files', async () => {
it('ends without a frame when aborted during the fallback stat', async () => {
const { remote, changes, open } = harness()
const { iterator, controller } = open()
const first = iterator.next()
const { source } = await remote.waitForChanges(0)
source.fail(new RemoteError('workspace-file/watch-unsupported', 'watch unavailable', { path: RELATIVE }))
const request = await remote.waitForStat(0)
expect(request).toMatchObject({ sessionId: SESSION, path: RELATIVE, signal: controller.signal })
controller.abort()
request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'late', bytes: 9 } })
await expect(first).resolves.toEqual({ done: true, value: undefined })
await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined })
await changes.settle()
expect(remote.calls).toEqual(['changes', 'stat'])
expect(remote.stats).toHaveLength(1)
expect(remote.disposed).toEqual(['workspace file changes of ' + SESSION])
expect(source.aborted).toBe(true)
})
it.each(['v0', 'v1'])('re-stats complete metadata on a later ready even when the stat version is %s', async (version) => {
const { remote, open } = harness()
const { iterator } = open()
const first = iterator.next()
const { source } = await remote.ready(0)
const request = await remote.waitForStat(0)
request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0', bytes: 3 } })
await first
await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v0', bytes: 3 } } })
const next = iterator.next()
const source = remote.opened[0]!.source
await source.deliver({ kind: 'ready' })
await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v1' } })
expect(remote.stats).toHaveLength(1)
expect(remote.calls).toEqual(['changes', 'accept', 'stat', 'accept'])
await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v1', bytes: 3 } } })
const refresh = await remote.waitForStat(1)
expect(refresh).toMatchObject({ sessionId: SESSION, path: RELATIVE })
expect(remote.calls).toEqual(['changes', 'accept', 'stat', 'accept', 'stat'])
refresh.resolve({ ok: true, value: { absolutePath: CANONICAL, version, bytes: 12 } })
await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version, bytes: 12 } } })
expect(remote.opened).toHaveLength(1)
expect(remote.stats).toHaveLength(2)
})
it('retries a failed opening stat after reconnect ready without requiring a change frame', async () => {
const { remote, open } = harness()
const { iterator } = open()
const first = iterator.next()
const { source } = await remote.ready(0)
const request = await remote.waitForStat(0)
const error = new RemoteError('workspace-file/not-found', 'missing', { path: RELATIVE })
request.resolve({ ok: false, error })
await expect(first).resolves.toEqual({ done: false, value: { ok: false, error } })
const next = iterator.next()
await source.deliver({ kind: 'ready' })
const retry = await remote.waitForStat(1)
expect(retry).toMatchObject({ sessionId: SESSION, path: RELATIVE })
retry.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v1', bytes: 5 } })
await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v1', bytes: 5 } } })
expect(remote.stats).toHaveLength(2)
})
it.each([
['relative', ADDRESS, RELATIVE],
['absolute', sessionFileAddress(SESSION, '/shortcut/a.txt'), '/shortcut/a.txt'],
])('filters queued and live %s changes using the Host canonical path, not the input path', async (_, address, path) => {
const { remote, open } = harness()
])('filters queued and live %s changes using the Host canonical path and re-stats the input path', async (_, address, path) => {
const { remote, changes, open } = harness()
const { iterator } = open(address)
const first = iterator.next()
const watch = await remote.ready(0)
expect(watch).toMatchObject({ sessionId: SESSION, path })
const request = await remote.waitForStat(0)
expect(request).toMatchObject({ sessionId: SESSION, path })
const source = remote.opened[0]!.source
// Delivery is acknowledged while stat is still unresolved, before a key can be bound.
const source = watch.source
await source.deliver({ kind: 'change', change: { absolutePath: '/other/file.txt', version: 'other-before-stat' } })
await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v1' } })
await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'write-v1' } })
request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0', bytes: 3 } })
await expect(first).resolves.toMatchObject({ value: { ok: true, value: { version: 'v0' } } })
await expect(iterator.next()).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v1', bytes: 3 } } })
await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v0', bytes: 3 } } })
const queued = iterator.next()
const queuedStat = await remote.waitForStat(1)
expect(queuedStat).toMatchObject({ sessionId: SESSION, path })
// Different stat and notice versions prevent duplicate suppression from hiding a misrouted frame.
queuedStat.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'read-v1', bytes: 6 } })
await expect(queued).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'read-v1', bytes: 6 } } })
const live = iterator.next()
await source.deliver({ kind: 'change', change: { absolutePath: '/other/file.txt', version: 'other-after-stat' } })
await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v2' } })
await expect(iterator.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v2' } } })
expect(remote.stats).toHaveLength(1)
await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'write-v2' } })
const liveStat = await remote.waitForStat(2)
expect(liveStat).toMatchObject({ sessionId: SESSION, path })
liveStat.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'read-v2', bytes: 9 } })
await expect(live).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'read-v2', bytes: 9 } } })
source.end()
await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined })
await changes.settle()
expect(remote.stats).toHaveLength(3)
expect(remote.opened).toHaveLength(1)
})
it.each(['write'] as const)('recovers an initial failed stat through %s and filters the retry backlog after binding', async () => {
const { remote, open } = harness()
it('recovers an initial failed stat through a target write and filters the retry backlog after binding', async () => {
const { remote, changes, open } = harness()
const { iterator } = open()
const first = iterator.next()
const watch = await remote.ready(0)
expect(watch.path).toBe(RELATIVE)
const request = await remote.waitForStat(0)
const error = new RemoteError('workspace-file/not-found', 'missing', { path: RELATIVE })
request.resolve({ ok: false, error })
await expect(first).resolves.toEqual({ done: false, value: { ok: false, error } })
const source = remote.opened[0]!.source
const source = watch.source
const retried = iterator.next()
await source.deliver({ kind: 'change', change: { absolutePath: '/unknown-key-trigger.txt', version: 'trigger' } })
await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'trigger' } })
const retry = await remote.waitForStat(1)
expect(retry).toMatchObject({ sessionId: SESSION, path: RELATIVE })
await source.deliver({ kind: 'change', change: { absolutePath: '/other/file.txt', version: 'other' } })
await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v3' } })
retry.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v2', bytes: 5 } })
await expect(retried).resolves.toEqual({
done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v2', bytes: 5 } },
})
await expect(iterator.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v3' } } })
expect(remote.stats).toHaveLength(2)
await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'write-v3' } })
retry.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'read-v2', bytes: 5 } })
await expect(retried).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'read-v2', bytes: 5 } } })
const queued = iterator.next()
const update = await remote.waitForStat(2)
update.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'read-v3', bytes: 8 } })
await expect(queued).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'read-v3', bytes: 8 } } })
source.end()
await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined })
await changes.settle()
expect(remote.stats).toHaveLength(3)
})
it('binds a changed canonical path after disappearance before filtering writes received during that stat', async () => {
const { remote, open } = harness()
const { remote, changes, open } = harness()
const { iterator } = open()
const first = iterator.next()
const { source } = await remote.ready(0)
const request = await remote.waitForStat(0)
request.resolve({ ok: true, value: { absolutePath: '/host/old-target.txt', version: 'v0' } })
await first
await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: '/host/old-target.txt', version: 'v0' } } })
const reloaded = iterator.next()
await remote.opened[0]!.source.deliver({ kind: 'change', change: { absolutePath: '/host/old-target.txt', absent: true } })
await source.deliver({ kind: 'change', change: { absolutePath: '/host/old-target.txt', absent: true } })
const retry = await remote.waitForStat(1)
const source = remote.opened[0]!.source
await source.deliver({ kind: 'change', change: { absolutePath: '/host/old-target.txt', version: 'old-target-write' } })
await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v2' } })
retry.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v1' } })
await expect(reloaded).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1' } } })
await expect(iterator.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v2' } } })
expect(remote.stats).toHaveLength(2)
await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'write-v2' } })
retry.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'read-v1', bytes: 7 } })
await expect(reloaded).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'read-v1', bytes: 7 } } })
const queued = iterator.next()
const update = await remote.waitForStat(2)
expect(update).toMatchObject({ sessionId: SESSION, path: RELATIVE })
update.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'read-v2', bytes: 10 } })
await expect(queued).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'read-v2', bytes: 10 } } })
source.end()
await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined })
await changes.settle()
expect(remote.stats).toHaveLength(3)
expect(remote.opened).toHaveLength(1)
})
it('drops a late stat after abort and waits for the session stream disposal', async () => {
it('drops a late stat after abort and waits for target stream disposal', async () => {
const { remote, changes, open } = harness()
const { iterator, controller } = open()
const first = iterator.next()
const { source } = await remote.ready(0)
const request = await remote.waitForStat(0)
await remote.opened[0]!.source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v1' } })
const gate = Promise.withResolvers<undefined>()
remote.disposeGate = gate.promise
try {
controller.abort()
request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0' } })
await expect(first).resolves.toEqual({ done: true, value: undefined })
expect(remote.opened[0]!.source.aborted).toBe(true)
let settled = false
const closing = changes.settle().then(() => { settled = true })
await settle()
expect(settled).toBe(false)
gate.resolve(undefined)
await closing
expect(settled).toBe(true)
expect(remote.disposed).toEqual([`workspace file changes of ${SESSION}`])
} finally {
gate.resolve(undefined)
}
await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v1' } })
const gate = remote.holdDisposal()
controller.abort()
await remote.waitForDispose(0)
request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0' } })
await expect(first).resolves.toEqual({ done: true, value: undefined })
expect(source.aborted).toBe(true)
let settled = false
const closing = changes.settle().then(() => { settled = true })
await remote.waitForStreamEnd(0)
expect(settled).toBe(false)
gate.resolve(undefined)
await closing
expect(settled).toBe(true)
expect(remote.disposed).toEqual(['workspace file changes of ' + SESSION])
})
it('aborts while waiting for a predecessor to close without sending a stat', async () => {
it('aborts while waiting for the same target predecessor to close without sending a stat', async () => {
const { remote, changes, open } = harness()
const previous = open()
const first = previous.iterator.next()
await remote.ready(0)
const request = await remote.waitForStat(0)
request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0' } })
await first
const gate = Promise.withResolvers<undefined>()
remote.disposeGate = gate.promise
try {
previous.controller.abort()
const next = open()
const pending = next.iterator.next()
next.controller.abort()
await expect(pending).resolves.toEqual({ done: true, value: undefined })
expect(remote.stats).toHaveLength(1)
gate.resolve(undefined)
await changes.settle()
expect(remote.disposed).toHaveLength(2)
} finally {
gate.resolve(undefined)
}
await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: CANONICAL, version: 'v0' } } })
const gate = remote.holdDisposal()
previous.controller.abort()
await remote.waitForDispose(0)
const next = open()
const pending = next.iterator.next()
next.controller.abort()
await expect(pending).resolves.toEqual({ done: true, value: undefined })
expect(remote.stats).toHaveLength(1)
expect(remote.opened).toHaveLength(1)
gate.resolve(undefined)
await changes.settle()
expect(remote.disposed).toHaveLength(2)
})
it.each([true, false])('releases an unconsumed notification follower after the first stat (success: %s)', async (ok) => {
const { remote, changes, open } = harness()
const { iterator } = open()
const first = iterator.next()
const { source } = await remote.ready(0)
const request = await remote.waitForStat(0)
request.resolve(ok
const result: RemoteResult<WorkspaceFileStat> = ok
? { ok: true, value: { absolutePath: CANONICAL, version: 'v0' } }
: { ok: false, error: new RemoteError('workspace-file/not-found', 'missing', { path: RELATIVE }) })
await first
: { ok: false, error: new RemoteError('workspace-file/not-found', 'missing', { path: RELATIVE }) }
request.resolve(result)
await expect(first).resolves.toEqual({ done: false, value: result })
await iterator.return?.()
await changes.settle()
expect(remote.opened[0]!.source.aborted).toBe(true)
expect(remote.disposed).toEqual([`workspace file changes of ${SESSION}`])
expect(source.aborted).toBe(true)
expect(remote.disposed).toEqual(['workspace file changes of ' + SESSION])
})
})
@@ -1,56 +1,60 @@
/**
* The `file` provider's frame stream: how the two address scopes resolve to a
* Host call and a change-feed key, the opening stat, the write version that
* carries no content, the disappearance that stats again, failures
* as frames, and the life bounded by the signal.
*/
/** File-provider metadata reads, target subscriptions, failures, and cancellation. */
import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import { absoluteFileAddress, sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
import type { WorkspaceFileStat } from '../src/types.ts'
import { describe, expect, it, onTestFinished } from 'vitest'
import { ChangeFeed } from '../src/client/change-feed.ts'
import { createFileResourceProvider } from '../src/client/provider.ts'
import { FakeRemote, settle } from './fake-remote.client.ts'
import { FakeRemote } from './fake-remote.client.ts'
const S1 = 's1' as SessionId
const S2 = 's2' as SessionId
/** The relative path the address carries, and the absolute path the Host's frames spell for it under S1's root. */
const REL_PATH = 'a b.txt'
const HOST_PATH = '/w/a b.txt'
const ADDRESS = sessionFileAddress(S1, REL_PATH)
/** A file outside every workspace root, addressed absolutely. */
const ABS_PATH = '/etc/hosts'
const ABS_ADDRESS = sessionFileAddress(S1, ABS_PATH)
const stat = (version: string, bytes: number): WorkspaceFileStat => ({ absolutePath: HOST_PATH, version, bytes })
const notFound = (): RemoteFailure => new RemoteError('workspace-file/not-found', 'no such file', { path: REL_PATH })
function opened(address = ADDRESS) {
function harness() {
const remote = new FakeRemote()
remote.autoReady = false
const changes = new ChangeFeed(remote)
const provider = createFileResourceProvider(remote, changes)
const controller = new AbortController()
const it = provider.open(address, { signal: controller.signal })[Symbol.asyncIterator]()
const resources: Array<{ controller: AbortController; it: AsyncIterator<RemoteResult<WorkspaceFileStat>> }> = []
onTestFinished(async () => {
controller.abort()
for (const request of remote.stats) {
request.resolve({ ok: false, error: new RemoteError('gateway/internal', 'test ended', {}) })
}
await it.return?.()
for (const resource of resources) resource.controller.abort()
await remote.dispose()
await Promise.all(resources.map(resource => Promise.resolve(resource.it.return?.())))
await changes.settle()
expect(remote.opened.every(watch => watch.source.aborted)).toBe(true)
})
return { remote, provider, changes, controller, it }
const open = (address = ADDRESS) => {
const controller = new AbortController()
const it = provider.open(address, { signal: controller.signal })[Symbol.asyncIterator]()
const resource = { controller, it }
resources.push(resource)
return resource
}
return { remote, changes, open }
}
function opened(address = ADDRESS) {
const bench = harness()
return { ...bench, ...bench.open(address) }
}
/** Open, answer the opening stat, and hand back the bench once the first frame is out. */
async function live(version = 'v0', bytes = 3) {
const bench = opened()
const first = bench.it.next()
await settle()
bench.remote.stats[0]!.resolve({ ok: true, value: stat(version, bytes) })
await first
await bench.remote.ready(0)
const request = await bench.remote.waitForStat(0)
request.resolve({ ok: true, value: stat(version, bytes) })
await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: stat(version, bytes) } })
return bench
}
@@ -84,130 +88,282 @@ describe('file provider — the address', () => {
expect(remote.opened).toEqual([])
})
it('hands the Host a session address\'s relative path and follows the stat absolute path', async () => {
it('watches and stats the relative input path while matching the Host canonical path', async () => {
const { remote, it } = opened()
const first = it.next()
await settle()
expect(remote.stats[0]).toMatchObject({ sessionId: S1, path: REL_PATH })
remote.stats[0]!.resolve({ ok: true, value: stat('v0', 3) })
await first
// The Host's frame names the file absolutely; the follower keyed by the resolved path receives it.
remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
await expect(it.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1' } } })
const watch = await remote.ready(0)
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) })
await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: stat('v0', 3) } })
const next = it.next()
await watch.source.deliver({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
const update = await remote.waitForStat(1)
expect(update).toMatchObject({ sessionId: S1, path: REL_PATH })
update.resolve({ ok: true, value: stat('v1', 7) })
await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: stat('v1', 7) } })
})
it('reads an absolute path through the Session in its address, with the absolute path as both Host path and follow key', async () => {
it('watches and stats an absolute path through the Session in its address', async () => {
const { remote, it } = opened(ABS_ADDRESS)
const first = it.next()
await settle()
expect(remote.opened.map(o => o.sessionId)).toEqual([S1])
expect(remote.stats[0]).toMatchObject({ sessionId: S1, path: ABS_PATH })
remote.stats[0]!.resolve({ ok: true, value: { absolutePath: ABS_PATH, version: 'v0', bytes: 3 } })
const watch = await remote.ready(0)
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 } })
await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: ABS_PATH, version: 'v0', bytes: 3 } } })
remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: ABS_PATH, version: 'v1' } })
await expect(it.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1' } } })
const next = it.next()
await watch.source.deliver({ kind: 'change', change: { absolutePath: ABS_PATH, version: 'v1' } })
const update = await remote.waitForStat(1)
expect(update).toMatchObject({ sessionId: S1, path: ABS_PATH })
update.resolve({ ok: true, value: { absolutePath: ABS_PATH, version: 'v1', bytes: 8 } })
await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: ABS_PATH, version: 'v1', bytes: 8 } } })
})
it('opens one change stream per session named by the addresses', async () => {
const remote = new FakeRemote()
const provider = createFileResourceProvider(remote, new ChangeFeed(remote))
const signal = new AbortController().signal
void provider.open(sessionFileAddress(S1, 'a.txt'), { signal })[Symbol.asyncIterator]().next()
void provider.open(sessionFileAddress(S2, 'a.txt'), { signal })[Symbol.asyncIterator]().next()
await settle()
expect(remote.opened.map(o => o.sessionId)).toEqual([S1, S2])
expect(remote.stats.map(pending => pending.sessionId)).toEqual([S1, S2])
it('opens separate streams for the same path in different Sessions', async () => {
const { remote, open } = harness()
const one = open(sessionFileAddress(S1, 'a.txt'))
const first = one.it.next()
await remote.ready(0)
const request = await remote.waitForStat(0)
request.resolve({ ok: true, value: { absolutePath: '/one/a.txt', version: 's1-v0' } })
await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: '/one/a.txt', version: 's1-v0' } } })
const two = open(sessionFileAddress(S2, 'a.txt'))
const second = two.it.next()
await remote.ready(1)
const other = await remote.waitForStat(1)
other.resolve({ ok: true, value: { absolutePath: '/two/a.txt', version: 's2-v0' } })
await expect(second).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: '/two/a.txt', version: 's2-v0' } } })
expect(remote.opened.map(watch => [watch.sessionId, watch.path])).toEqual([[S1, 'a.txt'], [S2, 'a.txt']])
expect(remote.stats.map(pending => [pending.sessionId, pending.path])).toEqual([[S1, 'a.txt'], [S2, 'a.txt']])
})
})
describe('file provider — the opening stat', () => {
it('stats the decoded relative path in the session the address names and yields its metadata', async () => {
it('stats the decoded relative path after ready and yields its metadata', async () => {
const { remote, it, controller } = opened()
const first = it.next()
await settle()
await remote.ready(0)
const request = await remote.waitForStat(0)
expect(remote.stats).toHaveLength(1)
expect(remote.stats[0]).toMatchObject({ sessionId: S1, path: REL_PATH, signal: controller.signal })
remote.stats[0]!.resolve({ ok: true, value: stat('v0', 3) })
await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v0', bytes: 3 } } })
expect(remote.calls).toEqual(['changes', 'accept', 'stat'])
expect(request).toMatchObject({ sessionId: S1, path: REL_PATH, signal: controller.signal })
request.resolve({ ok: true, value: stat('v0', 3) })
await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: stat('v0', 3) } })
})
it('does not stat or emit metadata when a ready observer cancels before the provider resumes', async () => {
const { remote, changes, it, controller } = opened()
const observer = changes.follow(S1, REL_PATH, controller.signal)
// Register this readiness reaction before the provider awaits the shared acknowledgement.
const cancelled = observer.ready.then((acknowledged) => {
if (acknowledged) controller.abort()
return acknowledged
})
const first = it.next()
const { source } = await remote.ready(0)
await expect(cancelled).resolves.toBe(true)
await expect(first).resolves.toEqual({ done: true, value: undefined })
await changes.settle()
expect(remote.calls).toEqual(['changes', 'accept'])
expect(remote.stats).toEqual([])
expect(remote.disposed).toEqual(['workspace file changes of s1'])
expect(source.aborted).toBe(true)
})
it('omits bytes when the backend reports none', async () => {
const { remote, it } = opened()
const first = it.next()
await settle()
remote.stats[0]!.resolve({ ok: true, value: { absolutePath: HOST_PATH, version: 'v0' } })
await remote.ready(0)
const request = await remote.waitForStat(0)
request.resolve({ ok: true, value: { absolutePath: HOST_PATH, version: 'v0' } })
await expect(first).resolves.toStrictEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v0' } } })
})
it('yields the Host failure as a frame and keeps following the address', async () => {
it('yields an initial Host failure and recovers when the watched target changes', async () => {
const { remote, it } = opened()
const first = it.next()
await settle()
const { source } = await remote.ready(0)
const initial = await remote.waitForStat(0)
const error = notFound()
remote.stats[0]!.resolve({ ok: false, error })
initial.resolve({ ok: false, error })
await expect(first).resolves.toEqual({ done: false, value: { ok: false, error } })
const source = remote.opened[0]!.source
// Still gone: no stat, no frame; the pull stays open for what comes next.
source.push({ kind: 'change', change: { absolutePath: HOST_PATH, absent: true } })
const pending = it.next()
await expect(Promise.race([pending, settle().then(() => 'silent' as const)])).resolves.toBe('silent')
await source.deliver({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
const retry = await remote.waitForStat(1)
retry.resolve({ ok: true, value: stat('v1', 5) })
await expect(pending).resolves.toEqual({ done: false, value: { ok: true, value: stat('v1', 5) } })
expect(remote.stats).toHaveLength(2)
})
it('does not re-stat or emit another absence after an initial failure', async () => {
const { remote, it, changes } = opened()
const first = it.next()
const { source } = await remote.ready(0)
const request = await remote.waitForStat(0)
const error = notFound()
request.resolve({ ok: false, error })
await expect(first).resolves.toEqual({ done: false, value: { ok: false, error } })
const pending = it.next()
await source.deliver({ kind: 'change', change: { absolutePath: HOST_PATH, absent: true } })
source.end()
await expect(pending).resolves.toEqual({ done: true, value: undefined })
await changes.settle()
expect(remote.stats).toHaveLength(1)
// The agent creates the file: the write stats again and the value goes live.
source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
await settle()
remote.stats[1]!.resolve({ ok: true, value: stat('v1', 5) })
await expect(pending).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v1', bytes: 5 } } })
})
it('ends without a frame when aborted during the stat', async () => {
const { remote, it, controller } = opened()
const first = it.next()
await settle()
await remote.ready(0)
const request = await remote.waitForStat(0)
controller.abort()
remote.stats[0]!.resolve({ ok: false, error: new RemoteError('gateway/internal', 'aborted', {}) })
request.resolve({ ok: false, error: new RemoteError('gateway/internal', 'aborted', {}) })
await expect(first).resolves.toEqual({ done: true, value: undefined })
})
it('shares one change stream between two files of a session', async () => {
const remote = new FakeRemote()
const provider = createFileResourceProvider(remote, new ChangeFeed(remote))
const signal = new AbortController().signal
void provider.open(sessionFileAddress(S1, 'a.txt'), { signal })[Symbol.asyncIterator]().next()
void provider.open(sessionFileAddress(S1, 'b.txt'), { signal })[Symbol.asyncIterator]().next()
await settle()
expect(remote.opened).toHaveLength(1)
it('opens separate streams for different files and waits for each target acknowledgement', async () => {
const { remote, open } = harness()
const a = open(sessionFileAddress(S1, 'a.txt'))
const b = open(sessionFileAddress(S1, 'b.txt'))
const first = a.it.next()
const second = b.it.next()
const watch = await remote.ready(0)
expect(watch.path).toBe('a.txt')
const request = await remote.waitForStat(0)
expect(remote.stats).toHaveLength(1)
expect(request.path).toBe('a.txt')
request.resolve({ ok: true, value: { absolutePath: '/w/a.txt', version: 'a0' } })
await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: '/w/a.txt', version: 'a0' } } })
const otherWatch = await remote.ready(1)
expect(otherWatch.path).toBe('b.txt')
const otherRequest = await remote.waitForStat(1)
otherRequest.resolve({ ok: true, value: { absolutePath: '/w/b.txt', version: 'b0' } })
await expect(second).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: '/w/b.txt', version: 'b0' } } })
expect(remote.opened).toHaveLength(2)
expect(remote.stats).toHaveLength(2)
})
it('shares one target stream across consumers while keeping their stats and cancellation independent', async () => {
const { remote, changes, open } = harness()
const a = open()
const b = open()
const first = a.it.next()
const second = b.it.next()
const { source } = await remote.ready(0)
const requests = await Promise.all([remote.waitForStat(0), remote.waitForStat(1)])
for (const request of requests) {
expect(request).toMatchObject({ sessionId: S1, path: REL_PATH })
request.resolve({ ok: true, value: stat('v0', 3) })
}
await expect(Promise.all([first, second])).resolves.toEqual([
{ done: false, value: { ok: true, value: stat('v0', 3) } },
{ done: false, value: { ok: true, value: stat('v0', 3) } },
])
expect(remote.opened).toHaveLength(1)
a.controller.abort()
await expect(a.it.next()).resolves.toEqual({ done: true, value: undefined })
expect(source.aborted).toBe(false)
const next = b.it.next()
await source.deliver({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
const update = await remote.waitForStat(2)
expect(update.signal).toBe(b.controller.signal)
update.resolve({ ok: true, value: stat('v1', 9) })
await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: stat('v1', 9) } })
b.controller.abort()
await expect(b.it.next()).resolves.toEqual({ done: true, value: undefined })
await changes.settle()
expect(remote.disposed).toEqual(['workspace file changes of s1'])
})
})
describe('file provider — Host writes', () => {
it('reports a write with its version and keeps the byte count', async () => {
const { remote, it } = await live()
remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
await expect(it.next()).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v1', bytes: 3 } } })
it.each([
['changed byte count', stat('v1', 17)],
['a newer version than the notification', stat('v2', 9)],
['an omitted byte count', { absolutePath: HOST_PATH, version: 'v1' }],
])('re-reads complete metadata after a write with %s', async (_, metadata) => {
const { remote, it, controller } = await live()
const next = it.next()
await remote.opened[0]!.source.deliver({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
const request = await remote.waitForStat(1)
expect(request).toMatchObject({ sessionId: S1, path: REL_PATH, signal: controller.signal })
request.resolve({ ok: true, value: metadata })
await expect(next).resolves.toStrictEqual({ done: false, value: { ok: true, value: metadata } })
expect(remote.stats).toHaveLength(2)
})
it('ignores a held version and still reads a later different version', async () => {
const { remote, it } = await live('v0')
const source = remote.opened[0]!.source
const pending = it.next()
await source.deliver({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v0' } })
await source.deliver({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
const request = await remote.waitForStat(1)
request.resolve({ ok: true, value: stat('v1', 5) })
await expect(pending).resolves.toEqual({ done: false, value: { ok: true, value: stat('v1', 5) } })
expect(remote.stats).toHaveLength(2)
})
it('does not read or emit metadata for a duplicate version before the stream ends', async () => {
const { remote, it, changes } = await live('v0')
const source = remote.opened[0]!.source
const pending = it.next()
await source.deliver({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v0' } })
source.end()
await expect(pending).resolves.toEqual({ done: true, value: undefined })
await changes.settle()
expect(remote.stats).toHaveLength(1)
})
it('ignores a frame carrying the version it already holds', async () => {
const { remote, it } = await live('v0')
const source = remote.opened[0]!.source
source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v0' } })
// The pull outlives the silent tick: the frame that finally answers it is v1.
const pending = it.next()
await expect(Promise.race([pending, settle().then(() => 'silent' as const)])).resolves.toBe('silent')
source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
await expect(pending).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1' } } })
})
it('does not lose a write reported during the opening stat', async () => {
it('does not lose a write delivered while the opening stat is unresolved', async () => {
const { remote, it } = opened()
const first = it.next()
await settle()
remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
await settle()
remote.stats[0]!.resolve({ ok: true, value: stat('v0', 3) })
await expect(first).resolves.toMatchObject({ value: { ok: true, value: { version: 'v0' } } })
await expect(it.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1' } } })
const { source } = await remote.ready(0)
const initial = await remote.waitForStat(0)
await source.deliver({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
initial.resolve({ ok: true, value: stat('v0', 3) })
await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: stat('v0', 3) } })
const next = it.next()
const update = await remote.waitForStat(1)
update.resolve({ ok: true, value: stat('v1', 11) })
await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: stat('v1', 11) } })
expect(remote.stats).toHaveLength(2)
})
it('queues a further change while metadata refresh is unresolved', async () => {
const { remote, it } = await live()
const source = remote.opened[0]!.source
const first = it.next()
await source.deliver({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
const initial = await remote.waitForStat(1)
await source.deliver({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v2' } })
initial.resolve({ ok: true, value: stat('v1', 5) })
await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: stat('v1', 5) } })
const next = it.next()
const update = await remote.waitForStat(2)
update.resolve({ ok: true, value: stat('v2', 12) })
await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: stat('v2', 12) } })
expect(remote.stats).toHaveLength(3)
})
it('yields a metadata failure and retries even when the next notice repeats the last successful version', async () => {
const { remote, it } = await live()
const source = remote.opened[0]!.source
const next = it.next()
await source.deliver({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
const update = await remote.waitForStat(1)
const error = new RemoteError('gateway/internal', 'metadata unavailable', {})
update.resolve({ ok: false, error })
await expect(next).resolves.toEqual({ done: false, value: { ok: false, error } })
const recovered = it.next()
await source.deliver({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v0' } })
const retry = await remote.waitForStat(2)
retry.resolve({ ok: true, value: stat('v2', 8) })
await expect(recovered).resolves.toEqual({ done: false, value: { ok: true, value: stat('v2', 8) } })
})
})
@@ -224,45 +380,58 @@ describe('file provider — a reported disappearance', () => {
expect(remote.disposed).toEqual(['workspace file changes of s1'])
})
it('stats again and, when the file is still there, yields its fresh metadata', async () => {
it('stats again and yields fresh metadata when the file is still there', async () => {
const { remote, it } = await live('v0', 3)
remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, absent: true } })
const next = it.next()
await settle()
await remote.opened[0]!.source.deliver({ kind: 'change', change: { absolutePath: HOST_PATH, absent: true } })
const request = await remote.waitForStat(1)
expect(remote.stats).toHaveLength(2)
remote.stats[1]!.resolve({ ok: true, value: stat('v2', 9) })
await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v2', bytes: 9 } } })
request.resolve({ ok: true, value: stat('v2', 9) })
await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: stat('v2', 9) } })
})
it('yields the not-found frame and keeps following, so a later write stats again and brings the file back', async () => {
it('yields not-found and keeps following so a later write can bring the file back', async () => {
const { remote, it } = await live('v0', 3)
const source = remote.opened[0]!.source
source.push({ kind: 'change', change: { absolutePath: HOST_PATH, absent: true } })
const next = it.next()
await settle()
await source.deliver({ kind: 'change', change: { absolutePath: HOST_PATH, absent: true } })
const request = await remote.waitForStat(1)
const error = notFound()
remote.stats[1]!.resolve({ ok: false, error })
request.resolve({ ok: false, error })
await expect(next).resolves.toEqual({ done: false, value: { ok: false, error } })
source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v3' } })
const back = it.next()
await settle()
remote.stats[2]!.resolve({ ok: true, value: stat('v3', 8) })
await expect(back).resolves.toEqual({ done: false, value: { ok: true, value: { absolutePath: HOST_PATH, version: 'v3', bytes: 8 } } })
await source.deliver({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v3' } })
const retry = await remote.waitForStat(2)
retry.resolve({ ok: true, value: stat('v3', 8) })
await expect(back).resolves.toEqual({ done: false, value: { ok: true, value: stat('v3', 8) } })
})
})
describe('file provider — the end', () => {
it('ends when its signal aborts and releases the session stream', async () => {
const { remote, it, controller } = await live()
it('does not start another stat for a change queued before cancellation', async () => {
const { remote, it, controller, changes } = await live()
await remote.opened[0]!.source.deliver({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
controller.abort()
await expect(it.next()).resolves.toEqual({ done: true, value: undefined })
await settle()
await changes.settle()
const next = it.next()
await expect(Promise.race([next, remote.waitForStat(1)])).resolves.toEqual({ done: true, value: undefined })
expect(remote.stats).toHaveLength(1)
})
it('ends a pending pull when its signal aborts and releases the target stream', async () => {
const { remote, it, controller, changes } = await live()
const next = it.next()
controller.abort()
await expect(next).resolves.toEqual({ done: true, value: undefined })
await changes.settle()
expect(remote.disposed).toEqual(['workspace file changes of s1'])
})
it('ends when the Host closes the session stream', async () => {
const { remote, it } = await live()
it('ends when the Host closes the target stream', async () => {
const { remote, it, changes } = await live()
remote.opened[0]!.source.end()
await expect(it.next()).resolves.toEqual({ done: true, value: undefined })
await changes.settle()
expect(remote.disposed).toEqual(['workspace file changes of s1'])
})
})
@@ -9,6 +9,7 @@ interface Config { root: string }
/** Empty provider-owned workspace; no operation accesses the host filesystem. */
export default class ProviderCwdFileSystem extends FileSystem {
override watch(): never { throw new Error('Fixture does not support watching') }
static inject = ['sandboxPolicy']
static Config: schema<Config> = schema.object({ root: schema.string().required() })
private readonly config: Config
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
README.md: ca16822299c06c23e4bf206d626719e609908d42
README.zh.md: 9d6aada08a3a32551ea129d61068e71811415ac3
README.md: b916802917bfdd406fb333174371507d466b8e19
README.zh.md: 01de4d633cb1501a03200aa96f72e89622d0a7d8
+1 -1
View File
@@ -74,7 +74,7 @@ Settings → General exposes a persisted, localized `Normal` / `Compact` convers
<a id="scroll-ownership"></a>
## Scroll ownership
Chat restores semantic anchors across history prepend and renderer remounts. Pinned scroll deliveries without reader movement update follow ownership immediately, before subsequent layout changes can invalidate their floor. Reader movement remains pending until the sampling interval or `scrollend`, even inside the follow threshold, so layout growth cannot erase small scroll gestures. Submitting transcript input or steering immediately restores tail following and clears an older pending reader sample. While the reader is pinned to the floor, `ResizeObserver` follows the new floor and selects the latest loaded Turn without reading row geometry. Once the reader moves away, flow-height changes preserve the top position and the reading-line geometry selects the active Turn. Turn-rail previews paint above sticky Markdown code-block banners, while the rail frame remains inside the transcript band above the composer.
Chat restores semantic anchors across history prepend and renderer remounts, with browser scroll anchoring disabled on its scrollport only while following the tail. Pinned scroll deliveries without reader movement, and reader input that reaches the exact floor, update follow ownership immediately, before subsequent layout changes can invalidate their floor. Other reader movement remains pending until the sampling interval or `scrollend`, even inside the follow threshold, so layout growth cannot erase small scroll gestures. Submitting transcript input or steering immediately restores tail following and clears an older pending reader sample. While the reader is pinned to the floor, `ResizeObserver` follows the new floor and selects the latest loaded Turn without reading row geometry. Once the reader moves away, flow-height changes preserve the top position and the reading-line geometry selects the active Turn. Turn-rail previews paint above sticky Markdown code-block banners, while the rail frame remains inside the transcript band above the composer.
The turn rail mounts only visible marks, overscan, and the focused mark's neighbors. Its fixed pitch and observed viewport size determine scroll offsets without reading the DOM scroll extent. Initial placement waits for the body's restored active Turn and the rail's first usable viewport size. The ref controls activate a Turn or scroll the rail independently; the transcript itself remains fully mounted.
+1 -1
View File
@@ -74,7 +74,7 @@ Assistant Markdown 中的 HTTP(S) 链接在普通点击时会在该类型已注
<a id="scroll-ownership"></a>
## 滚动归属
Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点。没有读者移动的贴底滚动事件会立即更新跟随归属,避免后续布局变化使其底部位置失效。读者移动即使位于跟随阈值内,也保持待处理直到采样周期或 `scrollend`,防止布局增长抵消小幅滚动操作。提交正文输入或 steering 时立即恢复跟随底部,并清除先前待处理的读者滚动采样。读者跟随底部时,`ResizeObserver` 追随新的底部,并且无需读取行几何就选中最后一个已加载轮次;读者离开底部后,高度变化会保持顶部位置,再由阅读线几何选择活跃轮次。轮次导航预览位于 Markdown 代码块粘性头栏上方,而导航外框始终处于 composer 上方的 transcript 区域内。
Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且只在跟随底部时禁用自己滚动区域的浏览器自动锚定。没有读者移动的贴底滚动事件,以及确实到达最底部的读者输入,会立即更新跟随归属,避免后续布局变化使其底部位置失效。其他读者移动即使位于跟随阈值内,也保持待处理直到采样周期或 `scrollend`,防止布局增长抵消小幅滚动操作。提交正文输入或 steering 时立即恢复跟随底部,并清除先前待处理的读者滚动采样。读者跟随底部时,`ResizeObserver` 追随新的底部,并且无需读取行几何就选中最后一个已加载轮次;读者离开底部后,高度变化会保持顶部位置,再由阅读线几何选择活跃轮次。轮次导航预览位于 Markdown 代码块粘性头栏上方,而导航外框始终处于 composer 上方的 transcript 区域内。
轮次轨道只挂载可见刻度、预加载范围及键盘焦点刻度的相邻项。固定间距和观察到的视口尺寸决定滚动偏移,不读取 DOM 滚动总高度。初始定位等待正文恢复活跃轮次,以及轨道首次获得可用视口尺寸。ref 控制接口分别支持激活轮次和仅滚动轨道;正文自身仍完整挂载。
@@ -20,6 +20,11 @@
container-type: inline-size;
}
.root[data-chat-following-tail] .scroll,
:global([data-conversation-scroll]):has(.root[data-chat-following-tail]) {
overflow-anchor: none;
}
:global([data-conversation-scroll]) .root {
flex: 0 0 auto;
min-height: auto;
@@ -207,7 +207,7 @@ export function ChatView({
})
return (
<div className={css.root}>
<div className={css.root} data-chat-following-tail={scroll.followingTail ? '' : undefined}>
<div ref={scroll.listRef} className={css.scroll}>
{scroll.initialized && (
<TurnNavigator
@@ -118,13 +118,14 @@ export class ChatReading {
}
/**
* Handle pinned layout movement immediately and defer genuine reader sampling.
* @param scroll - attributed scroll delivery; reader movement remains pending until sampled.
* Handle pinned layout movement and reader arrivals at the floor immediately.
* @param scroll - attributed scroll delivery; other reader movement remains pending until sampled.
*/
readonly onScroll = (scroll: ViewportScroll): void => {
if (this.state.followingTail && !scroll.movedByReader) {
if ((!scroll.movedByReader && this.state.followingTail)
|| (scroll.movedByReader && scroll.metrics.top >= scroll.metrics.floor)) {
this.followTail()
this.sampled?.({ position: null, movedByReader: false, followingTail: true })
this.sampled?.({ position: null, movedByReader: scroll.movedByReader, followingTail: true })
return
}
this.sampleTimer ??= window.setTimeout(this.flushSample, SCROLL_SAMPLE_INTERVAL_MS)
@@ -2430,6 +2430,7 @@ describe('ChatView', () => {
readerScroll(scroller, 100) // far from bottom
const backButton = view.getByLabelText('回到底部')
expect(backButton).toBeTruthy()
expect(view.container.querySelector('[data-chat-following-tail]')).toBeNull()
// Streaming growth must NOT drag a scrolled-away reader down.
act(() => {
h.setChat({ partial: { turn: 1, step: 1, blocks: [{ kind: 'text', text: 'grow' }] } })
@@ -2437,6 +2438,7 @@ describe('ChatView', () => {
expect(scroller.scrollTop).toBe(100)
fireEvent.click(backButton)
expect(scroller.scrollTop).toBe(700)
expect(view.container.querySelector('[data-chat-following-tail]')).not.toBeNull()
// At the bottom again: follow re-arms and the button unmounts.
expect(view.queryByLabelText('回到底部')).toBeNull()
})
@@ -2555,6 +2557,25 @@ describe('ChatView', () => {
expect(scroller.scrollTop).toBe(680)
})
it('keeps following when reader input reaches the floor before growth and scrollend', () => {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
const metrics = installScrollMetrics(scroller, 1_000, 300)
readerScroll(scroller, 100)
expect(view.getByLabelText('回到底部')).toBeTruthy()
scroller.scrollTop = 700
fireEvent.scroll(scroller)
metrics.setHeight(1_030)
act(() => { h.setSession({ running: true }) })
fireEvent(scroller, new Event('scrollend'))
expect(scroller.scrollTop).toBe(730)
expect(view.queryByLabelText('回到底部')).toBeNull()
expect(h.chatScroll.read()).toBeNull()
})
it('follows a new submission immediately while an earlier scroll sample is pending', () => {
const nodes = [user(1, 'q'), assistant(2, 'a')]
const h = makeHarness({ nodes })
@@ -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
@@ -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.
<a id="addresses"></a>
## Addresses
@@ -43,16 +43,16 @@ A tab uses the Session address built by `fileAddressFor`, carrying a relative or
<a id="how-it-reads"></a>
## 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<ArrayBuffer>` 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 `<base href>` 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 `<base href>` 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 `<img>` 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.
@@ -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。
<a id="addresses"></a>
## 地址
@@ -43,16 +43,16 @@ tab 使用 `fileAddressFor` 构造的 Session 地址,携带相对或绝对路
<a id="how-it-reads"></a>
## 怎么读
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<ArrayBuffer>`,供 `{ 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 引导载荷嵌入脚本文本。`<base href>` 将依赖解析交给浏览器,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 引导载荷嵌入脚本文本。`<base href>` 将依赖解析交给浏览器,HTTPS 资源也由浏览器处理。本地模块 import、CSS `url()`/`@import` 和动态 `fetch` 不使用 Host 文件访问。读取失败、无效 UTF-8 或超出上限都使预览失败,不发布部分资源包。替换或卸载文档会释放其 Blob URL。
PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态图片上下文中渲染,带 12px 内边距和圆角。宽图按固有纵横比缩小到面板宽度,小图按固有 CSS 像素尺寸居中,超高图纵向滚动。渲染器既不提供缩放,也不提供拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 Blob URL。
@@ -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",
@@ -311,6 +311,11 @@
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-hover);
}
.tool[data-textpreview-tool='auto-refresh'][aria-pressed='true'] {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-hover);
}
.viewerTool {
flex: none;
width: auto;
@@ -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.
@@ -16,7 +15,8 @@ import clsx from 'clsx'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import {
FileTypeIcon, IconNowrapFillRegular, IconRefreshOutlineRegular, IconWrapFillRegular, Menu, Tooltip, classifyFileType,
FileTypeIcon, IconNowrapFillRegular, IconPauseOutlineRegular, IconPlayOutlineRegular,
IconRefreshOutlineRegular, IconWrapFillRegular, Menu, Tooltip, classifyFileType,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path'
import type { TextInjected } from './face.ts'
@@ -96,6 +96,7 @@ export type TextPreviewProps =
export function TextPreview({
useTabInfo, useResource, useStore, actions, loadPage, reloadPages,
loadAll, reloadAll, prepareRenderer, useDocumentPreviews, renderSlot, t,
addResource, setResources,
}: TextPreviewProps): ReactNode {
const { tab } = useTabInfo()
const { navigation, signal } = tab
@@ -116,6 +117,15 @@ export function TextPreview({
const mode = selected?.loading
const contentRendererId = mode === 'renderer' ? selected?.id : undefined
const current = (state?.mode ?? 'text-pages') === mode && state?.contentRendererId === contentRendererId ? state : undefined
const add = useCallback((address: string) => {
addResource(tab.id, address, signal)
}, [addResource, tab.id, signal])
const set = useCallback((addresses: readonly string[]) => {
setResources(tab.id, [tab.contentId, ...addresses], signal)
}, [setResources, tab.id, tab.contentId, signal])
useEffect(() => {
set([])
}, [set, selected?.id])
const bodyRef = useRef<HTMLDivElement | null>(null)
const scrollportRef = useRef<HTMLElement | null>(null)
const storedScrollTopRef = useRef(0)
@@ -195,11 +205,25 @@ export function TextPreview({
const rendererReload = useCallback((): void => {
if (canRead && selected !== undefined) prepareRenderer(tab.id, signal, selected.id, meta.value?.version, true)
}, [canRead, prepareRenderer, tab.id, signal, selected?.id, meta.value?.version])
const observedVersion = meta.value?.version
const changed = (current?.version !== undefined && observedVersion !== undefined
&& observedVersion !== current.version && observedVersion !== current.observedVersion)
|| state?.resourcesDirty === true
const reload = useCallback((): void => {
if (!canRead) return
if (mode === 'text-pages') reloadPages(tab.id, file, signal, observedVersion)
else if (mode === 'bytes-complete') reloadAll(tab.id, file, signal, observedVersion)
else rendererReload()
}, [canRead, mode, reloadPages, reloadAll, rendererReload, tab.id, file, signal, observedVersion])
useEffect(() => {
if (state?.autoRefresh && changed && current !== undefined && !current.loading && meta.status === 'live') reload()
}, [state?.autoRefresh, changed, current?.loading, meta.status, reload])
const content = useMemo((): DocumentContent | undefined => {
if (mode === 'renderer') {
if (current === undefined) return undefined
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') {
@@ -243,19 +267,10 @@ export function TextPreview({
}
const next = loadedThrough + 1
const { name } = pathPartsOf(displayPath)
const observedVersion = meta.value?.version
const changed = current?.version !== undefined && observedVersion !== undefined
&& observedVersion !== current.version && observedVersion !== current.observedVersion
const loadNext = (): void => {
if (!canRead || current?.loading || current?.eof) return
loadPage(tab.id, file, next, signal, meta.value?.version)
}
const reload = (): void => {
if (!canRead) return
if (mode === 'text-pages') reloadPages(tab.id, file, signal, meta.value?.version)
else if (mode === 'bytes-complete') reloadAll(tab.id, file, signal, meta.value?.version)
else rendererReload()
}
return (
<div className={css.preview} data-textpreview-state="text" data-textpreview-url={tab.contentId} data-document-preview={selected.id}>
{meta.failure !== undefined && hasContent
@@ -326,6 +341,15 @@ export function TextPreview({
</Tooltip>
)}
{content !== undefined && renderSlot('sidebar.right.tab.document.action', { content }, { entryKey: selected.id, hookContext: useTabInfo })}
<span hidden>
<Tooltip label={t(state.autoRefresh ? 'autoRefresh.disable' : 'autoRefresh.enable')} side="bottom" delayMs={500}>
<button type="button" className={css.tool} aria-label={t('autoRefresh')}
aria-pressed={state.autoRefresh} data-textpreview-tool="auto-refresh"
onClick={() => { actions.toggledAutoRefresh(tab.id) }}>
{state.autoRefresh ? <IconPauseOutlineRegular /> : <IconPlayOutlineRegular />}
</button>
</Tooltip>
</span>
<Tooltip label={t('reload')} side="bottom" delayMs={500}>
<button
type="button"
@@ -359,6 +383,7 @@ export function TextPreview({
)}
{content !== undefined && renderSlot('sidebar.right.tab.document', {
resourceAddress: tab.contentId, content, wrap: state.wrap, scrollportRef: bindScrollport,
addResource: add, setResources: set,
}, {
entryKey: selected.id, hookContext: useTabInfo,
fallback: <p className={css.statusLine}>{t('rendererUnavailable', { name: selected.title() })}</p>,
@@ -23,12 +23,18 @@ 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
/** Ordinary file content or a renderer-owned loading request; text accumulates until eof. */
@@ -0,0 +1,50 @@
/** File-resource membership and invalidation for one document preview. */
import type { Resources, ResourceSnapshot } from '@deepseek-ai/dsh-client-resources/client'
import type { WorkspaceFileStat } from '@deepseek-ai/dsh-api-workspace-files/types'
/** Forwards member metadata changes to one document preview. */
export class ResourceGroup {
private readonly members = new Map<string, () => void>()
/** @param resources - shared file sources. @param changed - marks the owning preview stale. */
constructor(private readonly resources: Resources, private readonly changed: () => void) {}
/**
* Subscribe once; initial metadata establishes the baseline for later changes.
* @param address - complete resource address.
*/
add(address: string): void {
if (this.members.has(address)) return
const source = this.resources.source(address)
let previous: string | undefined
const observe = (): void => {
const next = source.getSnapshot() as ResourceSnapshot<WorkspaceFileStat>
if (next.status !== 'live' && next.status !== 'failed') return
const key = next.status === 'live' ? `live:${next.value?.version}` : `failed:${next.failure?.code}`
const changed = previous !== undefined && key !== previous
previous = key
if (changed) this.changed()
}
this.members.set(address, source.subscribe(observe))
observe()
}
/**
* Release dependencies absent from the current document.
* @param addresses - complete membership of the latest document load.
*/
set(addresses: readonly string[]): void {
const retained = new Set(addresses)
for (const address of retained) this.add(address)
for (const [address, release] of this.members) {
if (retained.has(address)) continue
this.members.delete(address)
release()
}
}
/** Release every member when the preview tab ends. */
close(): void {
this.set([])
}
}
@@ -22,9 +22,15 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { ReadDocumentBytes, ReadWorkspaceFilePage, SessionFile } from './rpc.ts'
import type { TextStore } from './store.ts'
import type { DocumentLoadMode } from './document/registry.ts'
import type { Resources } from '@deepseek-ai/dsh-client-resources/client'
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
* held, arriving past the first line, is not kept: the tab's pages are dropped
@@ -84,6 +90,7 @@ export interface TextInjected {
* which also arms the one abort listener that forgets the tab.
*/
interface TabReads {
group: ResourceGroup
generation: number
version: string | undefined
mode: DocumentLoadMode
@@ -95,11 +102,13 @@ interface TabReads {
* Bind the preview's face to one paged read and one complete-byte read.
* @param read - the bound `workspaceFiles.read` call.
* @param readAll - ordinary complete-byte Remote read.
* @param resources - shared metadata sources for the document and its dependencies.
* @returns the Slot `inject` factory: bound actions in, face out. The slot's session id is unused because the address carries its own.
*/
export function textFace(
read: ReadWorkspaceFilePage,
readAll: ReadDocumentBytes,
resources: Resources,
): (sessionId: SessionId, actions: BoundActions<TextStore>) => TextInjected {
return (_sessionId: SessionId, actions: BoundActions<TextStore>): TextInjected => {
const tabs = new Map<TabId, TabReads>()
@@ -108,10 +117,14 @@ export function textFace(
const readsOf = (tabId: TabId, signal: AbortSignal): TabReads => {
const held = tabs.get(tabId)
if (held !== undefined) return held
const created: TabReads = { generation: 0, version: undefined, mode: 'text-pages' }
const created: TabReads = {
generation: 0, version: undefined, mode: 'text-pages',
group: new ResourceGroup(resources, () => { actions.resourceChanged(tabId) }),
}
tabs.set(tabId, created)
signal.addEventListener('abort', () => {
created.controller?.abort()
created.group.close()
tabs.delete(tabId)
actions.forget(tabId)
}, { once: true })
@@ -191,6 +204,12 @@ export function textFace(
else loadAll(tabId, file, signal, observedVersion)
}
return {
addResource: (tabId, address, signal) => {
if (!signal.aborted) readsOf(tabId, signal).group.add(address)
},
setResources: (tabId, addresses, signal) => {
if (!signal.aborted) readsOf(tabId, signal).group.set(addresses)
},
loadPage, reloadPages: restart, loadAll,
prepareRenderer: (tabId, signal, rendererId, observedVersion, reload = false) => {
if (signal.aborted) return
@@ -9,7 +9,6 @@ import { LoadingIndicator } from '../LoadingIndicator.tsx'
import { createHtmlDocument } from './bootstrap.ts'
import { createBasicHtmlDocument } from './basic-document.ts'
import { packHtml } from './pack.ts'
import type { ReadHtmlRelative } from './pack.ts'
import { createReadHtmlRelative } from './read-relative.ts'
import type { ReadHtmlRelated } from './read-relative.ts'
import type {} from './locales.ts'
@@ -25,18 +24,23 @@ export interface HtmlBodyInjected {
readonly readRelated: ReadHtmlRelated
}
type FrameInput = {
type FrameInput = Pick<HtmlBodyProps, 'resourceAddress' | 'readRelated' | 'addResource' | 'setResources'> & {
readonly data: Uint8Array<ArrayBuffer>
readonly readRelative: ReadHtmlRelative
readonly signal: AbortSignal
}
type FrameState = FrameInput & { readonly url: string | undefined }
type FrameState = Pick<FrameInput, 'data' | 'readRelated'> & { readonly url: string | undefined }
/** One mounted file owns its root Blob; replacing content also replaces the browsing context. */
function HtmlFrame({ data, readRelative, t }: FrameInput & { t: HtmlBodyProps['t'] }): ReactNode {
function HtmlFrame({ data, resourceAddress, readRelated, addResource, setResources, signal, t }: FrameInput & { t: HtmlBodyProps['t'] }): ReactNode {
const [frame, setFrame] = useState<FrameState>()
useEffect(() => {
const controller = new AbortController()
const resources = new Set<string>()
const readRelative = createReadHtmlRelative(readRelated, resourceAddress, signal, (address) => {
resources.add(address)
addResource(address)
})
let url: string | undefined
void (async () => {
try {
@@ -44,18 +48,20 @@ function HtmlFrame({ data, readRelative, t }: FrameInput & { t: HtmlBodyProps['t
controller.signal.throwIfAborted()
const html = createHtmlDocument(bundle)
url = URL.createObjectURL(new Blob([html], { type: 'text/html' }))
setFrame({ data, readRelative, url })
setFrame({ data, readRelated, url })
} catch {
if (!controller.signal.aborted) setFrame({ data, readRelative, url: undefined })
if (!controller.signal.aborted) setFrame({ data, readRelated, url: undefined })
} finally {
if (!controller.signal.aborted && !signal.aborted) setResources([...resources])
}
})()
return () => {
controller.abort()
if (url !== undefined) URL.revokeObjectURL(url)
}
}, [data, readRelative])
}, [data, readRelated, resourceAddress, addResource, setResources, signal])
if (frame?.data !== data || frame.readRelative !== readRelative) {
if (frame?.data !== data || frame.readRelated !== readRelated) {
return <LoadingIndicator className={clsx(css.status, css.opening)} label={t('loading')} />
}
if (frame.url === undefined) return <p className={css.status} role="alert">{t('failed')}</p>
@@ -67,16 +73,18 @@ function HtmlFrame({ data, readRelative, t }: FrameInput & { t: HtmlBodyProps['t
* @param props - document bytes, hooks, related-file reader and locale.
* @returns an isolated HTML document, or nothing for text delivery.
*/
export function HtmlBody({ content, resourceAddress, readRelated, useTabInfo, useInteractivePreview, t }: HtmlBodyProps): ReactNode {
export function HtmlBody({
content, resourceAddress, readRelated, useTabInfo, useInteractivePreview, addResource, setResources, t,
}: HtmlBodyProps): ReactNode {
const interactivePreview = useInteractivePreview(value => value)
const { tab } = useTabInfo()
const readRelative = useMemo(
() => createReadHtmlRelative(readRelated, resourceAddress, tab.signal),
[readRelated, resourceAddress, tab.signal],
)
useEffect(() => {
if (!interactivePreview) setResources([])
}, [interactivePreview, setResources])
if (content.kind !== 'bytes') return null
if (!interactivePreview) return <BasicHtmlFrame data={content.data} t={t} />
return <HtmlFrame key={resourceAddress} data={content.data} readRelative={readRelative} t={t} />
return <HtmlFrame key={resourceAddress} data={content.data} resourceAddress={resourceAddress}
readRelated={readRelated} signal={tab.signal} addResource={addResource} setResources={setResources} t={t} />
}
/** Static preview mounts a separate browsing context so a mode change retires running scripts. */
@@ -3,6 +3,8 @@ import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client'
import type { WorkspaceFileBytes } from '@deepseek-ai/dsh-api-workspace-files/types'
import { documentFileBytes } from '../rpc.ts'
import type { ReadHtmlRelative } from './pack.ts'
import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
import { hostFileOf } from '../rpc.ts'
/**
* Read one dependency relative to the addressed HTML file through the Host.
@@ -18,9 +20,15 @@ export type ReadHtmlRelated = (address: string, relativePath: string, signal: Ab
* @param readRelated - Remote reader using the Session in the root HTML address.
* @param address - root HTML file address.
* @param lifetime - tab lifetime.
* @param addResource - subscribes to the dependency path reported by the Host.
* @returns a reader that strips URL query/fragment, decodes one path, and preserves Host failures.
*/
export function createReadHtmlRelative(readRelated: ReadHtmlRelated, address: string, lifetime: AbortSignal): ReadHtmlRelative {
export function createReadHtmlRelative(
readRelated: ReadHtmlRelated,
address: string,
lifetime: AbortSignal,
addResource: (address: string) => void,
): ReadHtmlRelative {
return async (reference, signal) => {
const suffix = reference.search(/[?#]/u)
const path = decodeURIComponent(suffix === -1 ? reference : reference.slice(0, suffix))
@@ -29,9 +37,16 @@ export function createReadHtmlRelative(readRelated: ReadHtmlRelated, address: st
}
const combined = AbortSignal.any([lifetime, signal])
combined.throwIfAborted()
const file = hostFileOf(address)
const result = await readRelated(address, path, combined)
combined.throwIfAborted()
if (!result.ok) throw new Error(result.error.message)
if (!result.ok) {
if ('path' in result.error.details && typeof result.error.details.path === 'string') {
addResource(sessionFileAddress(file.sessionId, result.error.details.path))
}
throw new Error(result.error.message)
}
addResource(sessionFileAddress(file.sessionId, result.value.absolutePath))
return documentFileBytes(result.value)
}
}
@@ -78,7 +78,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* Required browser services: the tab registry, the slot registry, copy, and the
* Remote carrier with its `workspaceFiles` namespace.
*/
export const inject = ['slots', 'locale', 'sidebarRightTabs', 'remote', 'remote.workspaceFiles', 'settingsScope']
export const inject = ['slots', 'locale', 'sidebarRightTabs', 'remote', 'remote.workspaceFiles', 'settingsScope', 'resources']
/**
* Client plugin body: register the type, its dictionaries, its body, and its chip title.
@@ -99,6 +99,7 @@ export function apply(ctx: ClientContext): void {
const result = await ctx.remote.workspaceFiles.readAll(file.sessionId, file.path, signal)
return result.ok ? { ok: true, value: documentFileBytes(result.value) } : result
},
ctx.resources,
)
const source = { getSnapshot: previews.getSnapshot, subscribe: previews.subscribe }
ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register(
@@ -13,6 +13,9 @@ export const zh = {
changed: '文件已更新,当前显示为旧内容',
reloadNow: '重新载入',
reload: '重新读取文件',
autoRefresh: '自动刷新',
'autoRefresh.enable': '开启自动刷新',
'autoRefresh.disable': '关闭自动刷新',
'wrap.enable': '自动换行',
'wrap.disable': '取消换行',
'wrap.aria': '自动换行',
@@ -39,6 +42,9 @@ export const en = {
changed: 'The file has changed, showing the previous content.',
reloadNow: 'Reload',
reload: 'Read the file again',
autoRefresh: 'Auto refresh',
'autoRefresh.enable': 'Enable auto refresh',
'autoRefresh.disable': 'Disable auto refresh',
'wrap.enable': 'Turn on line wrap',
'wrap.disable': 'Turn off line wrap',
'wrap.aria': 'Line wrap',
@@ -71,7 +71,11 @@ export function OfficeBody(props: OfficeBodyProps): ReactNode {
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)
@@ -86,6 +90,7 @@ export function OfficeBody(props: OfficeBodyProps): ReactNode {
<div className={css.scrollport} ref={props.scrollportRef}>
{props.renderSlot('sidebar.right.tab.document.office.pdf', {
resourceAddress, content: { kind: 'bytes', data: file.data }, wrap: props.wrap, scrollportRef: props.scrollportRef,
addResource: props.addResource, setResources: props.setResources,
}, { entryKey: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office', hookContext: props.useTabInfo })}
</div>
</div>
@@ -31,6 +31,8 @@ export interface TextPage {
/** One tab's pages and view. */
export interface TextTabState {
autoRefresh: boolean
resourcesDirty: boolean
/** Explicit viewer choice for this tab; absence follows automatic matching. */
rendererId?: string
/** Current display-loading mode; absent before the first read. */
@@ -72,6 +74,8 @@ export interface TextState {
*/
export function fresh(): TextTabState {
return {
autoRefresh: true,
resourcesDirty: false,
loadRevision: 0,
version: undefined,
observedVersion: undefined,
@@ -92,9 +96,12 @@ function bucket(state: TextState, tabId: TabId): TextTabState {
/** The preview store's write set; every action names the tab it writes. */
type TextActions = {
toggledAutoRefresh: (draft: TextState, tabId: TabId) => void
resourceChanged: (draft: TextState, tabId: TabId) => void
selected: (draft: TextState, tabId: TabId, rendererId: string | undefined) => void
loading: (draft: TextState, tabId: TabId, mode?: DocumentLoadMode, observedVersion?: string, contentRendererId?: string) => void
rendered: (draft: TextState, tabId: TabId, revision: number, version: string) => void
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
@@ -116,6 +123,11 @@ export function createTextStore(): EngineStoreHandle<TextState, TextActions> {
return defineStore({
init: (): TextState => ({ byTab: {} }),
actions: {
toggledAutoRefresh: (d, tabId: TabId) => {
const state = bucket(d, tabId)
state.autoRefresh = !state.autoRefresh
},
resourceChanged: (d, tabId: TabId) => { bucket(d, tabId).resourcesDirty = true },
/** @param d - draft. @param tabId - owning tab. @param rendererId - manual choice, or automatic selection. */
selected: (d, tabId: TabId, rendererId: string | undefined) => {
if (rendererId === undefined) delete bucket(d, tabId).rendererId
@@ -148,6 +160,12 @@ export function createTextStore(): EngineStoreHandle<TextState, TextActions> {
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)
@@ -191,6 +209,7 @@ export function createTextStore(): EngineStoreHandle<TextState, TextActions> {
*/
reset: (d, tabId: TabId) => {
const state = bucket(d, tabId)
state.resourcesDirty = false
state.loadRevision++
state.pages = {}
delete state.complete
@@ -32,7 +32,7 @@ import { en, zh } from '../src/client/locales.ts'
import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
import type { textFace } from '../src/client/face.ts'
import type { TextStore } from '../src/client/store.ts'
import { FILE, SESSION, TAB_ID, page } from './fixtures.client.ts'
import { FILE, SESSION, TAB_ID, createResources, page } from './fixtures.client.ts'
interface Recorded {
name: string
@@ -45,6 +45,7 @@ interface Recorded {
async function boot() {
const ctx = new Context()
ctx.provide('resources', createResources())
ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) } } as never)
const tabs = new SidebarRightTabRegistry(ctx)
const registered: Recorded[] = []
@@ -114,7 +115,7 @@ describe('ui-sidebar-documentpreview apply', () => {
expect(dictionaries.size).toBe(0)
})
it('injects ordinary Remote reads without requiring a Resource service', async () => {
it('injects ordinary Remote reads independently of resource metadata', async () => {
const { registered, workspaceFiles } = await boot()
const registration = registered.find(entry => entry.component === TextPreview)
if (registration === undefined) throw new Error('missing preview registration')
@@ -102,7 +102,9 @@ describe('document extension seat', () => {
const h = await boot()
act(() => { h.rt.ctx.sidebarRight.openResource(sessionFileAddress('address-session', path)) })
await waitFor(() => { expect(h.view.container.querySelectorAll('[data-textpreview-line]')).toHaveLength(2) })
expect(h.read).toHaveBeenCalledExactlyOnceWith('address-session', path, { offset: 1 }, expect.any(AbortSignal))
expect(h.read.mock.calls).toEqual([
['address-session', path, { offset: 1 }, expect.any(AbortSignal)],
])
expect(h.bytes).not.toHaveBeenCalled()
},
)
@@ -149,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(1)
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') })
@@ -171,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(1)
expect(h.read).toHaveBeenCalledOnce()
})
})
@@ -14,7 +14,7 @@ import { textFace } from '../src/client/face.ts'
import type { DocumentFileBytes, ReadDocumentBytes, ReadWorkspaceFilePage } from '../src/client/rpc.ts'
import { hostFileOf } from '../src/client/rpc.ts'
import { createTextStore } from '../src/client/store.ts'
import { ABSOLUTE_PATH, FILE, PATH, SESSION, failure, page } from './fixtures.client.ts'
import { ABSOLUTE_PATH, FILE, PATH, SESSION, createResources, failure, page } from './fixtures.client.ts'
import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
const TAB_1 = 'tab-1' as TabId
@@ -94,7 +94,8 @@ function bench(sessionId = 'other-session' as SessionId) {
// The store's own `forget`, counted: the record's end must forget a tab exactly once.
const forget = vi.fn(instance.actions.forget)
// Injected for another session on purpose: the address's session must win.
const face = textFace(read, bytes)(sessionId, { ...instance.actions, forget })
const resources = createResources()
const face = textFace(read, bytes, resources)(sessionId, { ...instance.actions, forget })
/** Settle the oldest outstanding read, or the oldest one for `offset`. */
const settle = async (result: RemoteResult<WorkspaceFileText>, offset?: number): Promise<void> => {
const at = offset === undefined ? 0 : pending.findIndex(call => call.offset === offset)
@@ -104,7 +105,7 @@ function bench(sessionId = 'other-session' as SessionId) {
await call.promise
}
return {
instance, read, face, forget, settle, bytes, controller,
instance, read, face, forget, settle, bytes, controller, resources,
settleAll: whole.settle,
outstandingAll: whole.outstanding,
outstanding: () => pending.map(call => call.offset),
@@ -120,6 +121,19 @@ const settlements = [
] as const
describe('textFace', () => {
it('subscribes to a dependency once and stops accepting members after the tab ends', () => {
const { face, resources, controller, forget } = bench()
const source = vi.spyOn(resources, 'source')
const address = sessionFileAddress(SESSION, 'style.css')
face.addResource(TAB_1, address, controller.signal)
face.addResource(TAB_1, address, controller.signal)
expect(source).toHaveBeenCalledExactlyOnceWith(address)
controller.abort()
expect(forget).toHaveBeenCalledExactlyOnceWith(TAB_1)
face.addResource(TAB_1, sessionFileAddress(SESSION, 'other.css'), controller.signal)
expect(source).toHaveBeenCalledTimes(1)
})
it('marks the read in flight, then keeps the page', async () => {
const { read, face, settle, tab } = bench()
const controller = new AbortController()
@@ -403,7 +417,7 @@ it.each([new Error('invalid bytes'), 'invalid bytes'])('reports an active comple
const controller = new AbortController()
const read = vi.fn<ReadDocumentBytes>().mockRejectedValue(failure)
try {
textFace(vi.fn(), read)(SESSION, instance.actions).loadAll(TAB_1, FILE, controller.signal)
textFace(vi.fn(), read, createResources())(SESSION, instance.actions).loadAll(TAB_1, FILE, controller.signal)
await Promise.resolve()
expect(instance.getSnapshot().byTab[TAB_1]?.failure).toMatchObject({ code: 'gateway/internal', message: 'invalid bytes' })
} finally { controller.abort() }
@@ -413,7 +427,7 @@ it('ignores a complete-read rejection after renderer-owned loading takes over',
const instance = createTextStore().create()
const controller = new AbortController()
const pending = Promise.withResolvers<Awaited<ReturnType<ReadDocumentBytes>>>()
const face = textFace(vi.fn(), () => pending.promise)(SESSION, instance.actions)
const face = textFace(vi.fn(), () => pending.promise, createResources())(SESSION, instance.actions)
try {
face.loadAll(TAB_1, FILE, controller.signal)
face.prepareRenderer(TAB_1, controller.signal, 'office')
@@ -8,6 +8,8 @@
* not the slot runtime.
*/
import { onTestFinished, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { ResourceRegistry } from '../../resources/src/client/resources.ts'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import type { Mock } from 'vitest'
import { act } from '@testing-library/react'
@@ -29,6 +31,12 @@ import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
type BodySlot = PropsRenderSlots<'sidebar.right.tab.document'>['renderSlot']
export function createResources(): ResourceRegistry {
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
return new ResourceRegistry(ctx)
}
/** Preserve the body-slot callback used by component fixtures. */
export function documentSlots(body: BodySlot): TextPreviewProps['renderSlot'] {
return (name: string, owner: unknown, options?: unknown) => name === 'sidebar.right.tab.document'
@@ -124,7 +132,7 @@ export function harness(script: Record<number, RemoteResult<WorkspaceFileText>>
const read = vi.fn<ReadWorkspaceFilePage>((_session, _path, offset) =>
Promise.resolve(pages[offset] ?? failure('workspace-file/not-found', { path: PATH })))
const bytes = vi.fn<ReadDocumentBytes>()
const face = textFace(read, bytes)(SESSION, instance.actions)
const face = textFace(read, bytes, createResources())(SESSION, instance.actions)
const current = { version: 'v1' as string | undefined, failure: undefined as RemoteFailure | undefined, snapshot: meta('v1', undefined) }
const refresh = (): void => { current.snapshot = meta(current.version, current.failure) }
const useResource = vi.fn<() => ResourceSnapshot<WorkspaceFileStat>>(() => current.snapshot)
@@ -162,6 +170,7 @@ export function harness(script: Record<number, RemoteResult<WorkspaceFileText>>
reloadPages: face.reloadPages,
prepareRenderer: face.prepareRenderer, loadAll: face.loadAll,
reloadAll: face.reloadAll,
addResource: face.addResource, setResources: face.setResources,
useDocumentPreviews: () => definitions,
renderSlot,
t,
@@ -1,11 +1,19 @@
// @vitest-environment jsdom
/** HTML iframe ownership follows file identity and bytes, not locale or wrapping changes. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render, screen } from '@testing-library/react'
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { Resources, ResourceSnapshot } from '@deepseek-ai/dsh-client-resources/client'
import type { WorkspaceFileStat } from '@deepseek-ai/dsh-api-workspace-files/types'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { TextPreview } from '../src/client/TextPreview.tsx'
import type { DocumentBodyOwner } from '../src/client/document/contract.ts'
import { textFace } from '../src/client/face.ts'
import { HtmlBody } from '../src/client/html/HtmlBody.tsx'
import type { HtmlBodyProps } from '../src/client/html/HtmlBody.tsx'
import { htmlBodyDefinition } from '../src/client/html/index.ts'
import { en } from '../src/client/html/locales.ts'
import { ADDRESS, ABSOLUTE_PATH, SESSION, TAB_ID, documentSlots, harness } from './fixtures.client.ts'
const translations: ReadonlyMap<string, string> = new Map(Object.entries(en))
let createDescriptor: PropertyDescriptor | undefined
@@ -41,6 +49,8 @@ function props(text = '<p>hello</p>'): HtmlBodyProps {
sessionId: 'html' as SessionId,
useTabInfo: () => ({ tab: { signal } }),
readRelated: vi.fn(),
addResource: vi.fn(),
setResources: vi.fn(),
useResource: () => ({ value: undefined }),
t: key => translations.get(key) ?? key,
} as HtmlBodyProps
@@ -49,6 +59,78 @@ function props(text = '<p>hello</p>'): HtmlBodyProps {
const utf8 = (text: string): Uint8Array<ArrayBuffer> => new TextEncoder().encode(text)
describe('HtmlBody', () => {
it.each(['css', 'js'] as const)('reloads the HTML when only its %s resource changes', async (extension) => {
const h = harness()
const previewProps = h.props()
const htmlProps = props()
const metadata = (version: string): ResourceSnapshot<WorkspaceFileStat> => ({
status: 'live', value: { absolutePath: ABSOLUTE_PATH, version }, failure: undefined,
})
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 : dependencySource,
register: () => () => {}, pin: () => {},
}
const face = textFace(h.read, h.bytes, resources)(SESSION, h.instance.actions)
const data = utf8(extension === 'css'
? '<link rel="stylesheet" href="./asset.css"><p>HTML content</p>'
: '<p>HTML content</p><script src="./asset.js"></script>')
h.bytes.mockResolvedValue({ ok: true, value: { absolutePath: ABSOLUTE_PATH, version: 'root-v1', data, offset: 0, eof: true } })
const readRelated = vi.fn<HtmlBodyProps['readRelated']>().mockResolvedValue({ ok: true, value: {
absolutePath: `/workspace/asset.${extension}`, version: 'asset-v1', offset: 0, eof: true,
data: btoa(extension === 'css' ? 'body { color: red }' : 'window.loaded = true'),
} })
const definition = { ...htmlBodyDefinition(() => 'HTML'), extensions: ['md'] }
let interactive = true
const preview = () => <TextPreview {...previewProps} {...face}
useDocumentPreviews={select => select([definition])}
renderSlot={documentSlots((_key, owner) => <HtmlBody {...htmlProps} {...owner as unknown as DocumentBodyOwner}
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 })
})
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'),
} })
act(() => { dependency.set(metadata('asset-v2')) })
await waitFor(() => { expect(screen.getByTitle(en.frame)).not.toBe(previous) })
expect(h.bytes).toHaveBeenCalledTimes(reads + 1)
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()
})
it('renders static HTML without reading related files, running scripts or retaining an advanced frame', async () => {
const initial = props('<h1>Preview</h1><script src="./script.js"></script><p>Static content</p>')
const basic = { ...initial, useInteractivePreview: ((select: (enabled: boolean) => unknown) => select(false)) as HtmlBodyProps['useInteractivePreview'] }
@@ -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,21 +13,54 @@ describe('HTML relative file reader', () => {
const readRelated = vi.fn<ReadHtmlRelated>().mockResolvedValue({ ok: true, value })
const tab = new AbortController()
const loading = new AbortController()
const read = createReadHtmlRelative(readRelated, ADDRESS, tab.signal)
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<ReadHtmlRelated>().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<ReadHtmlRelated>().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<ReadHtmlRelated>().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<ReadHtmlRelated>().mockResolvedValue({
ok: false, error: new RemoteError('workspace-file/outside-workspace', 'outside workspace', { path: '../x.js' }),
})
const signal = new AbortController().signal
const read = createReadHtmlRelative(readRelated, ADDRESS, signal)
const read = createReadHtmlRelative(readRelated, ADDRESS, signal, vi.fn())
for (const path of ['', '/x.js', 'file:///x.js', '%2Fx.js', 'C:/x.js', '..\\x.js', '%00.js', '%ZZ.js']) {
await expect(read(path, signal)).rejects.toThrow()
}
@@ -38,7 +72,7 @@ describe('HTML relative file reader', () => {
const controller = new AbortController()
controller.abort()
const readRelated = vi.fn<ReadHtmlRelated>()
const read = createReadHtmlRelative(readRelated, ADDRESS, controller.signal)
const read = createReadHtmlRelative(readRelated, ADDRESS, controller.signal, vi.fn())
await expect(read('x.js', new AbortController().signal)).rejects.toMatchObject({ name: 'AbortError' })
expect(readRelated).not.toHaveBeenCalled()
})
@@ -47,7 +81,7 @@ describe('HTML relative file reader', () => {
const pending = Promise.withResolvers<Awaited<ReturnType<ReadHtmlRelated>>>()
const readRelated = vi.fn<ReadHtmlRelated>().mockReturnValue(pending.promise)
const loading = new AbortController()
const read = createReadHtmlRelative(readRelated, ADDRESS, new AbortController().signal)
const read = createReadHtmlRelative(readRelated, ADDRESS, new AbortController().signal, vi.fn())
const result = read('./late.js', loading.signal)
const rejected = expect(result).rejects.toMatchObject({ name: 'AbortError' })
loading.abort()
@@ -61,6 +61,7 @@ describe('Markdown implementation registration', () => {
content: { kind: 'text', text: '# Notes\n\n```ts\nconst value = 1\n```', pages: [], eof: true },
wrap: false,
scrollportRef: vi.fn(),
addResource: vi.fn(), setResources: vi.fn(),
}, {
entryKey: MARKDOWN_BODY_ID,
hookContext: useTabInfo,
@@ -56,6 +56,7 @@ it('lets a non-Office renderer load content, report its version, and reload thro
expect(read).toHaveBeenCalledTimes(1)
expect(await screen.findByText('Custom content v1')).toBeTruthy()
expect(h.instance.getSnapshot().byTab[TAB_ID]?.version).toBe('v1')
act(() => { h.instance.actions.toggledAutoRefresh(TAB_ID) })
h.setVersion('v2')
view.rerender(<TextPreview {...h.props()} renderSlot={renderSlot} useDocumentPreviews={useDocumentPreviews} />)
expect(screen.getByText('changed')).toBeTruthy()
@@ -131,7 +132,7 @@ function setup() {
return { h, office, pending, read, View, request: () => request! }
}
it('loads without reading raw bytes, retains content across remounts, and reloads only after a source-change action', async () => {
it('retains renderer content across remounts and waits for reload when automatic refresh is paused', async () => {
const h = setup()
let mounted = render(<h.View />)
expect(screen.getByRole('status').getAttribute('aria-label')).toBe(en.loading)
@@ -144,6 +145,7 @@ it('loads without reading raw bytes, retains content across remounts, and reload
mounted = render(<h.View />)
expect(screen.getByText('PDF v1')).toBeTruthy()
expect(h.read).toHaveBeenCalledTimes(1)
act(() => { h.h.instance.actions.toggledAutoRefresh(TAB_ID) })
h.h.setVersion('v2')
mounted.rerender(<h.View />)
expect(screen.getByText('changed')).toBeTruthy()
@@ -161,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(<h.View />)
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(<h.View />)
const previous = h.request()
if (transition === 'replace') mounted.rerender(<h.View renderer={false} />)
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') {
@@ -0,0 +1,90 @@
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
import type { Resources, ResourceSnapshot } from '@deepseek-ai/dsh-client-resources/client'
import type { WorkspaceFileStat } from '@deepseek-ai/dsh-api-workspace-files/types'
import { ResourceGroup } from '../src/client/document/resource-group.ts'
function harness() {
const state = createSnapshotStore<ResourceSnapshot<WorkspaceFileStat>>({ status: 'loading', value: undefined, failure: undefined })
const release = vi.fn()
const source = {
getSnapshot: () => state.getSnapshot(),
subscribe: (listener: () => void) => {
const off = state.subscribe(listener)
return () => { off(); release() }
},
}
const resources: Resources = { source: () => source, register: () => () => {}, pin: () => {} }
const changed = vi.fn()
const group = new ResourceGroup(resources, changed)
onTestFinished(() => { group.close() })
const version = (version: string): void => {
state.set({ status: 'live', value: { absolutePath: '/style.css', version }, failure: undefined })
}
return { group, changed, version, release, state }
}
describe('ResourceGroup', () => {
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()
})
it('subscribes to an existing member only once', () => {
const h = harness()
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()
})
it('ignores repeated metadata and stops observing after close', () => {
const h = harness()
h.version('v1')
h.group.add('style.css')
h.version('v1')
expect(h.changed).not.toHaveBeenCalled()
h.version('v2')
expect(h.changed).toHaveBeenCalledOnce()
h.group.close()
h.version('v3')
expect(h.changed).toHaveBeenCalledOnce()
})
it('shares membership and releases members absent from the next document', () => {
const h = harness()
h.group.add('style.css')
h.group.add('style.css')
h.group.set(['style.css', 'script.js'])
h.group.set(['script.js'])
expect(h.release).toHaveBeenCalledOnce()
h.group.close()
h.group.close()
expect(h.release).toHaveBeenCalledTimes(2)
})
it('invalidates on failure and recovery without repeating an unchanged failure', () => {
const h = harness()
h.group.add('style.css')
h.version('v1')
const failed = {
...h.state.getSnapshot(), status: 'failed' as const,
failure: new RemoteError('workspace-file/not-found', 'File missing', { path: 'style.css' }),
}
h.state.set(failed)
expect(h.changed).toHaveBeenCalledOnce()
h.state.set({ ...failed })
expect(h.changed).toHaveBeenCalledOnce()
h.version('v1')
expect(h.changed).toHaveBeenCalledTimes(2)
})
})
@@ -326,11 +326,12 @@ describe('TextPreview — pages', () => {
expect(view.container.querySelector('[data-textpreview-failed]')).toBeNull()
})
it('announces a change and, on request, re-reads the pages keeping the reader\'s place', async () => {
it('announces a change while automatic refresh is paused and reloads on request, keeping the reader\'s place', async () => {
const h = harness({ 1: page(1, ['a', 'b'], true) })
const view = render(<TextPreview {...h.props()} />)
await settle()
fireEvent.scroll(body(view.container), { target: { scrollTop: 50 } })
click(view.container, '[data-textpreview-tool="auto-refresh"]')
h.setVersion('v2')
view.rerender(<TextPreview {...h.props()} />)
expect(view.container.querySelector('[data-textpreview-changed]')?.textContent).toContain('changed')
@@ -346,10 +347,40 @@ describe('TextPreview — pages', () => {
})
describe('TextPreview — the file\'s metadata', () => {
it('refreshes by default with the toggle hidden and catches up when automatic refresh resumes', async () => {
const h = harness({ 1: page(1, ['old'], true) })
const view = render(<TextPreview {...h.props()} />)
await settle()
const toggle = view.container.querySelector('[data-textpreview-tool="auto-refresh"]')!
expect(toggle.closest('[hidden]')).not.toBeNull()
expect(toggle.getAttribute('aria-pressed')).toBe('true')
expect(view.queryByRole('button', { name: 'autoRefresh' })).toBeNull()
expect(view.getByRole('button', { name: 'reload' })).toBeTruthy()
fireEvent.scroll(body(view.container), { target: { scrollTop: 40 } })
h.script(1, page(1, ['updated'], true, 'v2'))
h.setVersion('v2')
view.rerender(<TextPreview {...h.props()} />)
await settle()
expect(lines(view.container)).toEqual(['updated\n'])
expect(body(view.container).scrollTop).toBe(40)
expect(h.read).toHaveBeenCalledTimes(2)
fireEvent.click(toggle)
h.script(1, page(1, ['latest'], true, 'v3'))
h.setVersion('v3')
view.rerender(<TextPreview {...h.props()} />)
expect(lines(view.container)).toEqual(['updated\n'])
expect(h.read).toHaveBeenCalledTimes(2)
fireEvent.click(toggle)
await settle()
expect(lines(view.container)).toEqual(['latest\n'])
expect(h.read).toHaveBeenCalledTimes(3)
})
it('does not treat the observation present at read start as a later file change', async () => {
const h = harness({ 1: page(1, ['newer read'], true, 'v2') })
const view = render(<TextPreview {...h.props()} />)
await settle()
click(view.container, '[data-textpreview-tool="auto-refresh"]')
expect(h.instance.getSnapshot().byTab[TAB_ID]).toMatchObject({ version: 'v2', observedVersion: 'v1' })
expect(view.container.querySelector('[data-textpreview-changed]')).toBeNull()
h.setVersion('v3')
@@ -365,11 +396,12 @@ describe('TextPreview — the file\'s metadata', () => {
expect(view.container.querySelector('[data-textpreview-changed]')).not.toBeNull()
})
it('announces metadata that changes while the first content read is still pending', async () => {
it('announces metadata that changes during the first read while automatic refresh is paused', async () => {
const h = harness()
const pending = Promise.withResolvers<ReturnType<typeof page>>()
h.read.mockReturnValueOnce(pending.promise)
const view = render(<TextPreview {...h.props()} />)
click(view.container, '[data-textpreview-tool="auto-refresh"]')
h.setVersion('v2')
view.rerender(<TextPreview {...h.props()} />)
await act(async () => { pending.resolve(page(1, ['read v1'], true)); await pending.promise })
@@ -387,6 +419,8 @@ describe('TextPreview — the file\'s metadata', () => {
const first = render(<TextPreview {...firstProps} />)
const second = render(<TextPreview {...secondProps} />)
await settle()
click(first.container, '[data-textpreview-tool="auto-refresh"]')
click(second.container, '[data-textpreview-tool="auto-refresh"]')
h.setVersion('v2')
first.rerender(<TextPreview {...firstProps} />)
second.rerender(<TextPreview {...secondProps} />)
@@ -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: 491072c1daa95dc33cb268e7c825a95d738fae7c
README.zh.md: eceffb1c3ac0f7ba0437b23ee9e1ed6f905f5369
+11 -9
View File
@@ -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).
<a id="the-tree"></a>
## 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/<sessionId>/<encoded path relative to the root>`, 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. Expansion changes during ancestor restoration update the store and pending nodes, so restored descendants follow the latest expansion preferences. Automatic refresh defaults to enabled; its separate toggle is hidden while state, labels, styles, and toggle logic remain. Changes received during a directory read or its completion remain pending for another refresh.
<a id="model-experience"></a>
## Model Experience
@@ -57,7 +59,7 @@ None; directory listings travel over the Remote and assemble no model request.
## Known Limitations and Deferred Work
<a id="known-limitations-and-deferred-work"></a>
- **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.
- **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.
<a id="dev-note"></a>
@@ -70,4 +72,4 @@ None.
</details>
**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.
+11 -9
View File
@@ -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`(接线)。
<a id="the-tree"></a>
## 树
根是会话的工作目录,读自 `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/<sessionId>/<encoded path relative to the root>`,地址由 `@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 生命周期内持有自己的目标监听;折叠关闭该节点及其不可见后代。祖先恢复期间的展开状态变化会更新存储和待恢复节点,后代恢复时遵循最新展开偏好。自动刷新默认开启;独立开关暂时隐藏,状态、文案、样式与切换逻辑保留。目录读取期间或收尾时收到的变化仍会留待下一次刷新。
<a id="model-experience"></a>
## 模型体验
@@ -57,7 +59,7 @@ kind: "package-reference"
## 已知限制与暂缓事项
<a id="known-limitations-and-deferred-work"></a>
- **只有列目录。**没有搜索、产物过滤、拖拽、重命名、右键菜单、当前文件高亮或文件系统监听;一层只会因重新读取而变化。
- **只有列目录。**没有搜索、产物过滤、拖拽、重命名、右键菜单或当前文件高亮。
- **只有一个根。**树以会话工作目录为根;没有办法浏览到它之上,而 Host 本来也拒绝工作区根之外的路径。
<a id="dev-note"></a>
@@ -70,4 +72,4 @@ kind: "package-reference"
</details>
**运行时不变量:** 不发布 companion。树唯一的运行时状态是每 tab 一份的 Slot store,由持有它的正文写入、随 tab 的中止信号忘掉;没有第二个观测源可与之比对。
**运行时不变量:** 不发布 companion。tab 持有的 face 内部管理目录读取与监听,并通过 Slot store 写入显示状态;tab 取消时释放二者。本包未暴露可用于运行时比对的独立观测源。
@@ -58,6 +58,7 @@
"@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/dsh-util-workspace-path": "workspace:^",
"@testing-library/react": "^16.1.0",
"@types/react": "~18.3.1",
@@ -201,6 +201,11 @@
background: var(--dsw-alias-interactive-bg-hover);
}
.tool[aria-pressed='true'] {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-hover);
}
/* The folder sheet before the chip's text; the chip's title row centres it
on the text's line and spaces it. */
.titleIcon {
@@ -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'
@@ -17,6 +16,7 @@ import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client'
import type { PropsLocale, PropsRuntime, PropsStore, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import {
FileTypeIcon, IconFolderCloseRegular, IconFolderOpenRegular, IconRefreshOutlineRegular, classifyFileType,
IconPauseOutlineRegular, IconPlayOutlineRegular,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { fileAddressFor, pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path'
import type { WorkspaceDirectoryEntry } from '@deepseek-ai/dsh-api-workspace-files/types'
@@ -103,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'>
}
@@ -115,7 +115,7 @@ function Entry({ parent, entry, tree }: { parent: string; entry: WorkspaceDirect
const expanded = tree.state.expanded.includes(path)
return (
<li className={css.item} data-files-entry="directory" data-files-path={path}>
<button type="button" className={css.row} aria-expanded={expanded} onClick={() => { tree.onToggle(path) }}>
<button type="button" className={css.row} aria-expanded={expanded} onClick={() => { tree.onToggle(parent, path) }}>
{expanded ? <IconFolderOpenRegular className={css.icon} /> : <IconFolderCloseRegular className={css.icon} />}
<span className={css.name}>{entry.name}</span>
</button>
@@ -159,6 +159,7 @@ function Level({ path, tree }: { path: string; tree: TreeContext }): ReactNode {
const entries = orderEntries(level.level.entries)
return (
<>
{level.failure !== undefined && <li className={css.note} data-files-row="failed">{failureLine(t, level.failure)}</li>}
{entries.length === 0 && <li className={css.note} data-files-row="empty">{t('empty')}</li>}
{entries.map(entry => <Entry key={entry.name} parent={path} entry={entry} tree={tree} />)}
{level.level.truncated && <li className={css.note} data-files-row="truncated">{t('truncated')}</li>}
@@ -168,7 +169,7 @@ function Level({ path, tree }: { path: string; tree: TreeContext }): ReactNode {
/** The file tree's body: the workspace root and whatever the reader has opened under it. */
export function FilesBody({
useTabInfo, sessionId, useSessions, useStore, actions, start, load, toggle, t,
useTabInfo, sessionId, useSessions, useStore, actions, start, refresh, setAutoRefresh, toggle, t,
}: FilesBodyProps): ReactNode {
const { tab } = useTabInfo()
const { signal, actions: tabActions } = tab
@@ -213,16 +214,13 @@ export function FilesBody({
if (state === undefined) return null
const tree: TreeContext = {
state,
onToggle: (path) => { toggle(tab.id, path, state.levels[path] !== undefined, 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 => {
actions.reset(tab.id)
for (const path of state.expanded) load(tab.id, path, signal)
refresh(tab.id)
}
const { directory, name } = pathPartsOf(state.root)
return (
@@ -235,6 +233,14 @@ export function FilesBody({
<span className={css.pathName}>{name}</span>
</span>
</div>
<span hidden>
<button type="button" className={css.tool} aria-label={t('autoRefresh')}
aria-pressed={state.autoRefresh} data-files-auto-refresh
title={t(state.autoRefresh ? 'autoRefresh.disable' : 'autoRefresh.enable')}
onClick={() => { setAutoRefresh(tab.id, !state.autoRefresh) }}>
{state.autoRefresh ? <IconPauseOutlineRegular /> : <IconPlayOutlineRegular />}
</button>
</span>
<button
type="button"
className={css.tool}
@@ -0,0 +1,156 @@
/** Watch and read ownership for the Files panel's expanded directory tree. */
import type { DirLevel } from './store.ts'
/** One open directory and its active child nodes; cached view preferences live in the store. */
export class DirectoryNode {
/** Open direct-child directories, keyed by absolute path. */
readonly children = new Map<string, DirectoryNode>()
private readonly controller = new AbortController()
private readonly signal: AbortSignal
private task: Promise<void> | undefined
private reading: Promise<void> | undefined
private dirty = false
private initialized = false
private automatic = true
constructor(
readonly path: string,
private readonly load: (path: string, signal: AbortSignal) => Promise<DirLevel | undefined>,
private readonly watch: (path: string, signal: AbortSignal) => AsyncIterable<'ready' | 'change'>,
private readonly failed: (path: string, error: unknown) => void,
lifetime: AbortSignal,
private restore: readonly string[] = [],
) {
this.signal = AbortSignal.any([lifetime, this.controller.signal])
}
/**
* Start observation once; readiness triggers the initial listing.
* @returns this node.
*/
open(): this {
this.task ??= this.follow()
return this
}
/**
* Find an active node in this subtree.
* @param path - absolute directory path.
* @returns the matching node, or undefined when that directory is closed.
*/
find(path: string): DirectoryNode | undefined {
if (path === this.path) return this
for (const child of this.children.values()) {
const found = child.find(path)
if (found !== undefined) return found
}
return undefined
}
/**
* Update the expansion preferences used by pending directory listings.
* @param expanded - latest expansion preferences from the store.
*/
setExpanded(expanded: readonly string[]): void {
this.restore = expanded
for (const child of this.children.values()) child.setExpanded(expanded)
}
/**
* Open a direct child using this node's lifetime and automatic-refresh setting.
* @param path - absolute direct-child directory path.
* @param restore - descendant expansion preferences to restore after listing.
* @returns the active child, or undefined after cancellation.
*/
expand(path: string, restore: readonly string[] = []): DirectoryNode | undefined {
if (this.signal.aborted) return undefined
let child = this.children.get(path)
if (child === undefined) {
child = new DirectoryNode(path, this.load, this.watch, this.failed, this.signal, restore)
child.automatic = this.automatic
this.children.set(path, child)
}
return child.open()
}
/**
* Remove a child and its pending restoration preferences.
* @param path - absolute direct-child directory path.
* @returns once the child subtree's reads and watches have ended.
*/
async collapse(path: string): Promise<void> {
this.restore = this.restore.filter(value => value !== path && !value.startsWith(`${path}/`))
const child = this.children.get(path)
this.children.delete(path)
await child?.close()
}
/**
* Control subtree rereads without ending subscriptions.
* @param enabled - refresh dirty nodes automatically.
*/
setAutomatic(enabled: boolean): void {
this.automatic = enabled
if (enabled && this.dirty) void this.refresh()
for (const child of this.children.values()) child.setAutomatic(enabled)
}
/** Queue a reread, coalescing with active work. @returns completion of the active read and its coalesced rereads. */
refresh(): Promise<void> {
this.dirty = true
this.reading ??= this.read()
return this.reading
}
/** Refresh this node and its open descendants. @returns once their listings settle. */
async refreshTree(): Promise<void> {
await this.refresh()
await Promise.all([...this.children.values()].map(child => child.refreshTree()))
}
/** Cancel the active subtree. @returns once all owned reads and watches have ended. */
async close(): Promise<void> {
this.controller.abort()
await Promise.all([this.task, this.reading, ...[...this.children.values()].map(child => child.close())])
this.children.clear()
}
private async follow(): Promise<void> {
try {
for await (const _event of this.watch(this.path, this.signal)) {
this.dirty = true
if (!this.initialized || this.automatic) void this.refresh()
}
} catch (error) {
if (!this.signal.aborted) {
if (!this.initialized) await this.refresh()
if (!(typeof error === 'object' && error !== null && 'code' in error && error.code === 'workspace-file/watch-unsupported')) {
this.failed(this.path, error)
}
}
}
}
private async read(): Promise<void> {
const readAgain = (): boolean => this.dirty && this.automatic && !this.signal.aborted
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
}
}
}
@@ -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.
@@ -23,6 +23,47 @@ import type { BoundActions } from '@deepseek-ai/dsh-client-store'
import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { DirLevel, createFilesStore } from './store.ts'
import type { WorkspaceFileWatchFrame } from '@deepseek-ai/dsh-api-workspace-files/types'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import { DirectoryNode } from './directory-node.ts'
/**
* Observe one directory without recursively watching its descendants.
* @param sessionId - Session owning the directory tree.
* @param path - absolute directory path.
* @param signal - node lifetime.
* @returns readiness and invalidation notifications.
*/
export type WatchWorkspaceDirectory = (sessionId: SessionId, path: string, signal: AbortSignal) => AsyncIterable<'ready' | 'change'>
/**
* Bind directory observation to the Remote stream supervisor.
* @param remote - Client Remote with workspace file streams.
* @returns a watcher that awaits stream disposal when its node ends.
*/
export function createWatch(remote: ClientRemote): WatchWorkspaceDirectory {
return async function* (sessionId, path, signal) {
const aborted = (): boolean => signal.aborted
if (aborted()) return
const stream = remote.$stream<WorkspaceFileWatchFrame>({
name: `directory ${path}`,
open: lifetime => remote.workspaceFiles.changes(sessionId, path, lifetime),
ended: () => new Error(`Directory watch ended: ${path}`),
})
const abort = (): void => { void stream.dispose() }
signal.addEventListener('abort', abort, { once: true })
try {
for await (const item of stream) {
if (aborted()) return
if (item.value.kind === 'ready') item.accept()
yield item.value.kind
}
} finally {
signal.removeEventListener('abort', abort)
await stream.dispose()
}
}
}
/**
* One directory listing, bound to a Remote face.
@@ -73,6 +114,10 @@ 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.
* @param tabId - the tab being drawn.
@@ -88,22 +133,25 @@ export interface FilesInjected {
*/
readonly load: (tabId: TabId, path: string, signal: AbortSignal) => void
/**
* Open or collapse one directory, listing it the first time it opens.
* Open or collapse one directory, retaining intent during ancestor restoration.
* @param tabId - the tab being drawn.
* @param parentPath - the listed parent directory's exact tree key.
* @param path - absolute directory path.
* @param loaded - whether this level already has state.
* @param expanded - current expansion preferences, including descendants to restore.
* @param signal - the tab record's lifetime.
*/
readonly toggle: (tabId: TabId, path: string, loaded: boolean, signal: AbortSignal) => void
readonly toggle: (tabId: TabId, parentPath: string, path: string, expanded: readonly string[], signal: AbortSignal) => void
}
/**
* Bind the tree's face to one directory listing.
* @param list - the bound `workspaceFiles.list` call.
* @param watch - target-scoped directory observation.
* @returns the Slot `inject` factory: session and bound actions in, face out.
*/
export function filesFace(
list: ListWorkspaceDirectory,
watch: WatchWorkspaceDirectory,
): (sessionId: SessionId, actions: BoundActions<ReturnType<typeof createFilesStore>>) => FilesInjected {
return (
sessionId: SessionId,
@@ -111,6 +159,7 @@ export function filesFace(
): FilesInjected => {
/** Per tab, per absolute path: the listing generation a settlement must match; the latest request wins. */
const generations = new Map<TabId, Map<string, number>>()
const roots = new Map<TabId, DirectoryNode>()
const nextGeneration = (tabId: TabId, path: string): number => {
const byPath = generations.get(tabId) ?? new Map<string, number>()
generations.set(tabId, byPath)
@@ -118,31 +167,54 @@ export function filesFace(
byPath.set(path, generation)
return generation
}
const load = (tabId: TabId, path: string, signal: AbortSignal): void => {
const load = async (tabId: TabId, path: string, signal: AbortSignal): Promise<DirLevel | undefined> => {
if (signal.aborted) return
const generation = nextGeneration(tabId, path)
actions.loading(tabId, path)
void list(sessionId, path, signal).then((result) => {
return list(sessionId, path, signal).then((result) => {
// A newer listing of this level was asked for since, or the record is
// gone and its bookkeeping with it: nothing left for this one to write.
if (generations.get(tabId)?.get(path) !== generation) return
if (signal.aborted || generations.get(tabId)?.get(path) !== generation) return
if (result.ok) actions.loaded(tabId, path, result.value)
else actions.failed(tabId, path, result.error)
return result.ok ? result.value : undefined
})
}
return {
refresh: (tabId) => { void roots.get(tabId)?.refreshTree() },
setAutoRefresh: (tabId, enabled) => {
actions.autoRefresh(tabId, enabled)
roots.get(tabId)?.setAutomatic(enabled)
},
start(tabId, root, signal) {
actions.start(tabId, root)
signal.addEventListener('abort', () => {
void roots.get(tabId)?.close()
roots.delete(tabId)
generations.delete(tabId)
actions.forget(tabId)
}, { once: true })
load(tabId, root, signal)
roots.set(tabId, new DirectoryNode(root,
(path, lifetime) => load(tabId, path, lifetime),
(path, lifetime) => watch(sessionId, path, lifetime),
(path, error) => {
if (!signal.aborted) actions.failed(tabId, path, new RemoteError('gateway/internal', error instanceof Error ? error.message : String(error), {}))
}, signal,
).open())
},
load,
toggle(tabId, path, loaded, signal) {
load: (tabId, path, signal) => { void load(tabId, path, signal) },
toggle(tabId, parentPath, path, expanded, signal) {
if (signal.aborted) return
const root = roots.get(tabId)
if (root === undefined) return
const parent = root.find(parentPath)
if (parent === undefined && !expanded.includes(parentPath)) return
const collapsing = expanded.includes(path)
const next = collapsing ? expanded.filter(value => value !== path) : [...expanded, path]
root.setExpanded(next)
if (collapsing) void parent?.collapse(path)
else parent?.expand(path, next)
actions.toggled(tabId, path)
if (!loaded) load(tabId, path, signal)
},
}
}
@@ -16,7 +16,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
import { FILES_ID, filesDefinition } from './definition.tsx'
import { createList, filesFace } from './face.ts'
import { createList, createWatch, filesFace } from './face.ts'
import { FilesBody } from './FilesBody.tsx'
import { FilesTitle } from './FilesTitle.tsx'
import { en, zh } from './locales.ts'
@@ -46,7 +46,7 @@ export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-sidebar-files: dictionaries')
const store = createFilesStore()
const inject = filesFace(createList(ctx.remote))
const inject = filesFace(createList(ctx.remote), createWatch(ctx.remote))
ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register(
{ name: 'sidebar.right.pane.tab', key: FILES_ID, locale: NS, store, inject },
FilesBody,
@@ -28,6 +28,9 @@ export const zh = {
truncated: '条目太多,只显示了一部分。',
noWorkspace: '这个会话没有工作区目录。',
reload: '重新读取',
autoRefresh: '自动刷新',
'autoRefresh.enable': '开启自动刷新',
'autoRefresh.disable': '关闭自动刷新',
'entry.other': '这不是文件或目录,没法打开。',
'error.notFound': '这个目录不在了。可能已被移动或删除。',
'error.outsideWorkspace': '这个目录在工作区之外,侧栏不会读取它。',
@@ -48,6 +51,9 @@ export const en = {
truncated: 'Too many entries, showing only some of them.',
noWorkspace: 'This session has no workspace directory.',
reload: 'Reload',
autoRefresh: 'Auto refresh',
'autoRefresh.enable': 'Enable auto refresh',
'autoRefresh.disable': 'Disable auto refresh',
'entry.other': 'Not a file or a directory, so it cannot be opened.',
'error.notFound': 'That directory is gone. It may have been moved or deleted.',
'error.outsideWorkspace': 'That directory is outside the workspace, so the sidebar will not read it.',
@@ -31,7 +31,7 @@ export interface DirLevel {
/** What one directory level is doing right now. */
export type LevelState =
| { readonly kind: 'loading' }
| { readonly kind: 'ready'; readonly level: DirLevel }
| { readonly kind: 'ready'; readonly level: DirLevel; readonly failure?: RemoteFailure }
| { readonly kind: 'failed'; readonly failure: RemoteFailure }
/**
@@ -41,6 +41,7 @@ export type LevelState =
* the Host reports it, and a child is the parent joined with the entry name.
*/
export interface FilesTabState {
autoRefresh: boolean
/** Absolute path of the workspace root this tree is rooted at. */
root: string
/** Level state by absolute directory path; a path absent here was never asked for. */
@@ -71,6 +72,7 @@ function bucket(state: FilesState, tabId: TabId): FilesTabState {
/** The tree store's write set; every action names the tab it writes. */
type FilesActions = {
autoRefresh: (draft: FilesState, tabId: TabId, enabled: boolean) => void
start: (draft: FilesState, tabId: TabId, root: string) => void
loading: (draft: FilesState, tabId: TabId, path: string) => void
loaded: (draft: FilesState, tabId: TabId, path: string, level: DirLevel) => void
@@ -92,6 +94,7 @@ export function createFilesStore(): EngineStoreHandle<FilesState, FilesActions>
return defineStore({
init: (): FilesState => ({ byTab: {} }),
actions: {
autoRefresh: (d, tabId: TabId, enabled: boolean) => { bucket(d, tabId).autoRefresh = enabled },
/**
* Seed one tab's tree at its workspace root, with the root expanded.
* @param d - draft state.
@@ -99,7 +102,7 @@ export function createFilesStore(): EngineStoreHandle<FilesState, FilesActions>
* @param root - absolute path of the workspace root.
*/
start: (d, tabId: TabId, root: string) => {
d.byTab[tabId] = { root, levels: {}, expanded: [root], scrollTop: 0 }
d.byTab[tabId] = { root, levels: {}, expanded: [root], scrollTop: 0, autoRefresh: true }
},
/**
* Mark one directory as being listed.
@@ -108,7 +111,8 @@ export function createFilesStore(): EngineStoreHandle<FilesState, FilesActions>
* @param path - absolute directory path.
*/
loading: (d, tabId: TabId, path: string) => {
bucket(d, tabId).levels[path] = { kind: 'loading' }
const state = bucket(d, tabId)
if (state.levels[path]?.kind !== 'ready') state.levels[path] = { kind: 'loading' }
},
/**
* Record one directory's contents.
@@ -118,7 +122,19 @@ export function createFilesStore(): EngineStoreHandle<FilesState, FilesActions>
* @param level - the listing to show under it.
*/
loaded: (d, tabId: TabId, path: string, level: DirLevel) => {
bucket(d, tabId).levels[path] = { kind: 'ready', level }
const state = bucket(d, tabId)
const previous = state.levels[path]
if (previous?.kind === 'ready') {
const directories = new Set(level.entries.filter(entry => entry.type === 'directory').map(entry => entry.name))
for (const entry of previous.level.entries) {
if (entry.type !== 'directory' || directories.has(entry.name)) continue
const removed = `${path.replace(/[/\\]+$/, '')}/${entry.name}`
state.expanded = state.expanded.filter(value => value !== removed && !value.startsWith(`${removed}/`))
state.levels = Object.fromEntries(Object.entries(state.levels)
.filter(([key]) => key !== removed && !key.startsWith(`${removed}/`)))
}
}
state.levels[path] = { kind: 'ready', level }
},
/**
* Record why one directory could not be listed.
@@ -128,7 +144,10 @@ export function createFilesStore(): EngineStoreHandle<FilesState, FilesActions>
* @param failure - the settled Remote failure.
*/
failed: (d, tabId: TabId, path: string, failure: RemoteFailure) => {
bucket(d, tabId).levels[path] = { kind: 'failed', failure }
const level = bucket(d, tabId).levels[path]
bucket(d, tabId).levels[path] = level?.kind === 'ready'
? { ...level, failure }
: { kind: 'failed', failure }
},
/**
* Open a collapsed directory, or collapse an open one.
@@ -0,0 +1,304 @@
/** Directory-node lifecycle tests with controlled stream delivery and deferred reads. */
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import type { MockInstance } from 'vitest'
import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { DirectoryNode } from '../src/client/directory-node.ts'
import type { DirLevel } from '../src/client/store.ts'
import { scriptedList } from './scripted-list.client.ts'
import type { ScriptedList } from './scripted-list.client.ts'
import type { DirectoryWatch } from './scripted-watch.client.ts'
const SESSION = 'directory-node-session' as SessionId
const ROOT = '/work/app'
const SRC = `${ROOT}/src`
const COMPONENTS = `${SRC}/components`
const EMPTY: DirLevel = { entries: [], truncated: false }
const ROOT_LEVEL: DirLevel = { entries: [{ name: 'src', type: 'directory' }], truncated: false }
const SRC_LEVEL: DirLevel = { entries: [{ name: 'components', type: 'directory' }], truncated: false }
function mount(path = ROOT, restore: readonly string[] = []) {
const script = scriptedList()
const lifetime = new AbortController()
const reported = Promise.withResolvers<readonly [string, unknown]>()
const failed = vi.fn((path: string, error: unknown) => { reported.resolve([path, error]) })
const node = new DirectoryNode(path, async (path, signal) => {
const result = await script.list(SESSION, path, signal)
return result.ok ? result.value : undefined
}, (path, signal) => script.watch(SESSION, path, signal), failed, lifetime.signal, restore)
onTestFinished(async () => {
const closing = node.close()
lifetime.abort()
await script.dispose()
await closing
})
node.open()
return { node, script, failed, reported, lifetime }
}
function readCompletion(refresh: MockInstance<DirectoryNode['refresh']>, index = refresh.mock.results.length - 1): Promise<void> {
const result = refresh.mock.results[index]
if (result?.type !== 'return') throw new Error('expected the event to start a directory refresh')
return result.value
}
/** The refresh promise includes child reconciliation, which follows the deferred list response. */
async function watchedRead(node: DirectoryNode, script: ScriptedList, event: 'ready' | 'change' = 'ready', occurrence = 0) {
const refresh = vi.spyOn(node, 'refresh')
const index = refresh.mock.calls.length
const stream = await script.watches.forPath(node.path, occurrence)
await stream.deliver(event)
return { stream, finished: readCompletion(refresh, index) }
}
async function listReady(node: DirectoryNode, script: ScriptedList, level: DirLevel): Promise<DirectoryWatch> {
const read = await watchedRead(node, script)
await script.settle({ ok: true, value: level })
await read.finished
return read.stream
}
describe('DirectoryNode', () => {
it('rereads an invalidation arriving between read completion and refresh cleanup', async () => {
const pending = Promise.withResolvers<DirLevel>()
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)
expect(script.watches.opened.map(stream => stream.path)).toEqual([ROOT])
expect(script.list).not.toHaveBeenCalled()
await listReady(node, script, ROOT_LEVEL)
expect(node.children.size).toBe(0)
const child = node.expand(SRC)!
expect(node.expand(SRC)).toBe(child)
expect(node.find(SRC)).toBe(child)
expect(node.find(COMPONENTS)).toBeUndefined()
expect(script.list).toHaveBeenCalledTimes(1)
const stream = await listReady(child, script, SRC_LEVEL)
expect(script.list).toHaveBeenLastCalledWith(SESSION, SRC, stream.signal)
expect(script.watches.opened.map(stream => stream.path)).toEqual([ROOT, SRC])
expect(child.children.size).toBe(0)
})
it('collapsing a directory aborts its subtree and awaits every watch release', async () => {
const { node, script } = mount()
const rootStream = await listReady(node, script, ROOT_LEVEL)
const child = node.expand(SRC)!
const childStream = await listReady(child, script, SRC_LEVEL)
const grandchild = child.expand(COMPONENTS)!
const grandchildStream = await listReady(grandchild, script, EMPTY)
const childRelease = childStream.holdRelease()
const grandchildRelease = grandchildStream.holdRelease()
let collapsed = false
const closing = node.collapse(SRC).then(() => { collapsed = true })
expect(node.find(SRC)).toBeUndefined()
expect(childStream.signal.aborted).toBe(true)
expect(grandchildStream.signal.aborted).toBe(true)
await Promise.all([childStream.releasing.promise, grandchildStream.releasing.promise])
expect(collapsed).toBe(false)
childRelease.resolve(undefined)
await childStream.released.promise
expect(collapsed).toBe(false)
grandchildRelease.resolve(undefined)
await closing
expect(rootStream.signal.aborted).toBe(false)
expect(child.children.size).toBe(0)
expect(script.list).toHaveBeenCalledTimes(3)
})
it('does not restore a child collapsed while the reopened parent is still listing', async () => {
const sibling = `${SRC}/components-other`
const descendant = `${COMPONENTS}/nested`
const { node, script } = mount(SRC, [COMPONENTS, descendant, sibling])
const read = await watchedRead(node, script)
expect(script.outstanding()).toEqual([SRC])
expect(node.children.size).toBe(0)
await node.collapse(COMPONENTS)
await script.settle({ ok: true, value: {
entries: [{ name: 'components', type: 'directory' }, { name: 'components-other', type: 'directory' }],
truncated: false,
} })
await read.finished
expect([...node.children.keys()]).toEqual([sibling])
expect(script.watches.opened.map(stream => stream.path)).toEqual([SRC, sibling])
const reopened = node.expand(COMPONENTS)!
await listReady(reopened, script, { entries: [{ name: 'nested', type: 'directory' }], truncated: false })
expect(reopened.children.size).toBe(0)
expect(script.watches.opened.some(stream => stream.path === descendant)).toBe(false)
})
it.each(['removed', 'file'] as const)('closes a child subtree when its parent lists it as %s', async (replacement) => {
const { node, script } = mount()
const rootStream = await listReady(node, script, ROOT_LEVEL)
const child = node.expand(SRC)!
const childStream = await listReady(child, script, SRC_LEVEL)
const grandchildStream = await listReady(child.expand(COMPONENTS)!, script, EMPTY)
const read = await watchedRead(node, script, 'change')
await script.settle({ ok: true, value: {
entries: replacement === 'file' ? [{ name: 'src', type: 'file' }] : [],
truncated: false,
} })
await read.finished
expect(node.children.size).toBe(0)
expect(childStream.signal.aborted).toBe(true)
expect(grandchildStream.signal.aborted).toBe(true)
await Promise.all([childStream.released.promise, grandchildStream.released.promise])
expect(rootStream.signal.aborted).toBe(false)
})
it('coalesces notifications during a list into one subsequent read', async () => {
const { node, script } = mount()
const read = await watchedRead(node, script)
await Promise.all([read.stream.deliver('change'), read.stream.deliver('change')])
expect(script.list).toHaveBeenCalledTimes(1)
expect(script.outstanding()).toEqual([ROOT])
await script.settle({ ok: true, value: ROOT_LEVEL })
expect((await script.waitForList(1)).path).toBe(ROOT)
expect(script.outstanding()).toEqual([ROOT])
await script.settle({ ok: true, value: EMPTY })
await read.finished
expect(script.list).toHaveBeenCalledTimes(2)
expect(script.watches.opened).toHaveLength(1)
})
it('still initializes newly expanded nodes while automatic refresh is off and refreshes dirty nodes when reenabled', async () => {
const { node, script } = mount()
const rootStream = await listReady(node, script, ROOT_LEVEL)
node.setAutomatic(false)
const child = node.expand(SRC)!
const childStream = await listReady(child, script, SRC_LEVEL)
await rootStream.deliver('change')
await childStream.deliver('change')
await childStream.deliver('change')
expect(script.list).toHaveBeenCalledTimes(2)
expect(script.outstanding()).toEqual([])
const rootRefresh = vi.spyOn(node, 'refresh')
const childRefresh = vi.spyOn(child, 'refresh')
node.setAutomatic(true)
await script.waitForList(3)
const finished = Promise.all([readCompletion(rootRefresh), readCompletion(childRefresh)])
expect(script.outstanding()).toEqual(expect.arrayContaining([ROOT, SRC]))
expect(script.outstanding()).toHaveLength(2)
await script.settle({ ok: true, value: ROOT_LEVEL })
await script.settle({ ok: true, value: EMPTY })
await finished
node.setAutomatic(true)
expect(script.list).toHaveBeenCalledTimes(4)
expect(script.watches.opened.map(stream => stream.path)).toEqual([ROOT, SRC])
})
it('manual refresh consumes pending changes without enabling automatic refresh', async () => {
const { node, script } = mount()
const stream = await listReady(node, script, ROOT_LEVEL)
node.setAutomatic(false)
await stream.deliver('change')
const manual = node.refresh()
expect((await script.waitForList(1)).path).toBe(ROOT)
await script.settle({ ok: true, value: EMPTY })
await manual
await stream.deliver('change')
expect(script.list).toHaveBeenCalledTimes(2)
const refresh = vi.spyOn(node, 'refresh')
node.setAutomatic(true)
const finished = readCompletion(refresh)
await script.settle({ ok: true, value: EMPTY })
await finished
expect(script.list).toHaveBeenCalledTimes(3)
})
it('close awaits an in-flight read as well as watch release and ignores late restoration data', async () => {
const { node, script } = mount(ROOT, [SRC])
const read = await watchedRead(node, script)
const release = read.stream.holdRelease()
let closed = false
const closing = node.close().then(() => { closed = true })
expect(read.stream.signal.aborted).toBe(true)
expect((await script.waitForList(0)).signal.aborted).toBe(true)
await read.stream.releasing.promise
expect(closed).toBe(false)
release.resolve(undefined)
await read.stream.released.promise
expect(closed).toBe(false)
await script.settle({ ok: true, value: ROOT_LEVEL })
await closing
expect(node.children.size).toBe(0)
expect(node.expand(SRC)).toBeUndefined()
expect(script.watches.opened.map(stream => stream.path)).toEqual([ROOT])
})
it('closes a subscription cancelled before ready without listing', async () => {
const { node, script } = mount()
const stream = await script.watches.forPath(ROOT)
await node.close()
expect(stream.signal.aborted).toBe(true)
expect(script.list).not.toHaveBeenCalled()
await stream.released.promise
})
it('does not report a stream error whose release completes after node cancellation', async () => {
const { node, script, failed } = mount()
const stream = await script.watches.forPath(ROOT)
const release = stream.holdRelease()
stream.fail(new Error('watch stopped during cancellation'))
await stream.releasing.promise
const closing = node.close()
expect(stream.signal.aborted).toBe(true)
release.resolve(undefined)
await closing
expect(failed).not.toHaveBeenCalled()
expect(script.list).not.toHaveBeenCalled()
})
it('lists after watch-unsupported and keeps manual refresh available without reporting a failed directory', async () => {
const { node, script, failed } = mount()
const refresh = vi.spyOn(node, 'refresh')
const stream = await script.watches.forPath(ROOT)
stream.fail(new RemoteError('workspace-file/watch-unsupported', 'watching is unavailable', { path: ROOT }))
expect((await script.waitForList(0)).path).toBe(ROOT)
const initial = readCompletion(refresh)
await script.settle({ ok: true, value: ROOT_LEVEL })
await initial
const manual = node.refresh()
expect((await script.waitForList(1)).path).toBe(ROOT)
await script.settle({ ok: true, value: EMPTY })
await manual
await node.close()
expect(failed).not.toHaveBeenCalled()
expect(script.list).toHaveBeenCalledTimes(2)
expect(script.watches.opened).toHaveLength(1)
})
it('falls back to an initial list and reports a stream failure before ready', async () => {
const { script, failed, reported } = mount()
const stream = await script.watches.forPath(ROOT)
const error = new Error('directory stream unavailable')
stream.fail(error)
expect((await script.waitForList(0)).path).toBe(ROOT)
expect(failed).not.toHaveBeenCalled()
await script.settle({ ok: true, value: ROOT_LEVEL })
expect(await reported.promise).toEqual([ROOT, error])
await stream.released.promise
})
it('reports a later stream failure without replacing an already loaded directory', async () => {
const { node, script, reported } = mount()
const stream = await listReady(node, script, ROOT_LEVEL)
const error = new Error('directory stream disconnected')
stream.fail(error)
expect(await reported.promise).toEqual([ROOT, error])
expect(script.list).toHaveBeenCalledTimes(1)
await stream.released.promise
})
})
@@ -1,16 +1,5 @@
/**
* The tree's asynchronous half against a scripted listing, and the Remote
* adapter under it.
*
* The face's contract is what reaches the store and when: a level is `loading`
* before the listing settles, `ready` or `failed` after, never written once the
* owner's signal aborted or a newer listing of the level was asked for, and a
* tab whose record is gone leaves no bucket behind. The adapter's is what it
* keeps and what it drops: entries and the
* truncation flag reach the store, the endpoint's workspace-relative path does
* not, and a failure passes through untouched.
*/
import { describe, expect, it, vi } from 'vitest'
/** Directory subscriptions and read settlements through the face's real store actions. */
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { WorkspaceDirectoryListing } from '@deepseek-ai/dsh-api-workspace-files/types'
@@ -18,6 +7,7 @@ import { childPath, createList, filesFace } from '../src/client/face.ts'
import type { WorkspaceFilesListRemote } from '../src/client/face.ts'
import { createFilesStore } from '../src/client/store.ts'
import type { DirLevel } from '../src/client/store.ts'
import { DirectoryNode } from '../src/client/directory-node.ts'
import { scriptedList } from './scripted-list.client.ts'
import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
@@ -30,83 +20,265 @@ const LEVEL: DirLevel = { entries: [{ name: 'src', type: 'directory' }], truncat
function mount() {
const instance = createFilesStore().create()
const script = scriptedList()
const face = filesFace(script.list)(SESSION, instance.actions)
return { ...script, face, snapshot: () => instance.getSnapshot().byTab[TAB] }
const face = filesFace(script.list, script.watch)(SESSION, instance.actions)
const controller = new AbortController()
onTestFinished(async () => {
controller.abort()
await script.dispose()
})
return { ...script, face, controller, instance, actions: instance.actions, snapshot: () => instance.getSnapshot().byTab[TAB] }
}
describe('filesFace', () => {
it('start seeds the tab and lists the root with the session and the absolute root path', async () => {
const { face, list, settle, snapshot } = mount()
const controller = new AbortController()
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)
expect(list).toHaveBeenCalledWith(SESSION, ROOT, controller.signal)
const stream = await watches.forPath(ROOT)
expect(stream.sessionId).toBe(SESSION)
expect(stream.signal.aborted).toBe(false)
expect(list).not.toHaveBeenCalled()
expect(snapshot()).toEqual({ root: ROOT, expanded: [ROOT], levels: {}, scrollTop: 0, autoRefresh: true })
await stream.deliver('ready')
expect(list).toHaveBeenCalledWith(SESSION, ROOT, stream.signal)
expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'loading' })
await settle({ ok: true, value: LEVEL })
expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL })
})
it('records a failed listing under its level', async () => {
const { face, settle, snapshot } = mount()
face.start(TAB, ROOT, new AbortController().signal)
const { face, watches, settle, snapshot, controller } = mount()
face.start(TAB, ROOT, controller.signal)
await watches.ready(ROOT)
const error = new RemoteError('workspace-file/not-directory', 'not a directory', { path: ROOT, kind: 'file' })
await settle({ ok: false, error })
expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'failed', failure: error })
})
it('toggle expands and lists a directory the first time, and only toggles afterwards', async () => {
const { face, list, settle, snapshot } = mount()
const signal = new AbortController().signal
it('subscribes on expansion, releases on collapse, and rereads a reopened directory after ready', async () => {
const { face, list, watches, settle, snapshot, controller } = mount()
const { signal } = controller
const child = `${ROOT}/src`
face.start(TAB, ROOT, signal)
const rootStream = await watches.ready(ROOT)
await settle({ ok: true, value: LEVEL })
face.toggle(TAB, child, false, signal)
expect(list).toHaveBeenLastCalledWith(SESSION, child, 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, true, 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, ROOT, child, [ROOT], signal)
expect(snapshot()!.levels[child]).toEqual({ kind: 'ready', level: LEVEL })
expect(list).toHaveBeenCalledTimes(2)
const reopened = await watches.ready(child, 1)
expect(reopened.signal).not.toBe(childStream.signal)
expect(reopened.signal.aborted).toBe(false)
expect(list).toHaveBeenLastCalledWith(SESSION, child, reopened.signal)
const current: DirLevel = { entries: [{ name: 'new.ts', type: 'file' }], truncated: false }
await settle({ ok: true, value: current })
expect(snapshot()!.levels[child]).toEqual({ kind: 'ready', level: current })
})
it.each([false, true])('keeps deep expansion intent while restoring an ancestor (reopen: %s)', async (reopen) => {
const h = mount()
const refresh = vi.spyOn(DirectoryNode.prototype, 'refresh')
onTestFinished(() => { refresh.mockRestore() })
const src = `${ROOT}/src`
const components = `${src}/components`
const nested = `${components}/nested`
const srcLevel: DirLevel = { entries: [{ name: 'components', type: 'directory' }], truncated: false }
const componentsLevel: DirLevel = { entries: [{ name: 'nested', type: 'directory' }], truncated: false }
const empty: DirLevel = { entries: [], truncated: false }
const toggle = (parent: string, path: string): void => {
h.face.toggle(TAB, parent, path, h.snapshot()!.expanded, h.controller.signal)
}
h.face.start(TAB, ROOT, h.controller.signal)
await h.watches.ready(ROOT)
await h.settle({ ok: true, value: LEVEL })
for (const [parent, path, level] of [[ROOT, src, srcLevel], [src, components, componentsLevel], [components, nested, empty]] as const) {
toggle(parent, path)
await h.watches.ready(path)
await h.settle({ ok: true, value: level })
}
toggle(ROOT, src)
await Promise.all(h.watches.opened.slice(1).map(stream => stream.released.promise))
toggle(ROOT, src)
await h.watches.ready(src, 1)
expect(h.outstanding()).toEqual([src])
expect(h.watches.opened.filter(stream => !stream.signal.aborted).map(stream => stream.path)).toEqual([ROOT, src])
toggle(components, nested)
expect(h.snapshot()!.expanded).not.toContain(nested)
if (reopen) {
toggle(components, nested)
expect(h.snapshot()!.expanded).toContain(nested)
}
await h.settle({ ok: true, value: srcLevel })
await h.watches.ready(components, 1)
const reading = refresh.mock.results.at(-1)
if (reading?.type !== 'return') throw new Error('Expected the restored directory to start listing')
await h.settle({ ok: true, value: componentsLevel })
await reading.value
expect(h.watches.opened.filter(stream => stream.path === nested)).toHaveLength(reopen ? 2 : 1)
if (reopen) {
await h.watches.ready(nested, 1)
await h.settle({ ok: true, value: empty })
}
})
it('abort forgets the bucket and a late settlement writes nothing', async () => {
const { face, settle, snapshot } = mount()
const controller = new AbortController()
const { face, watches, settle, snapshot, controller } = mount()
face.start(TAB, ROOT, controller.signal)
const stream = await watches.ready(ROOT)
controller.abort()
expect(stream.signal.aborted).toBe(true)
expect(snapshot()).toBeUndefined()
await settle({ ok: true, value: LEVEL })
await stream.released.promise
expect(snapshot()).toBeUndefined()
})
it('abort before ready closes the subscription without starting a listing', async () => {
const { face, list, watches, snapshot, controller } = mount()
face.start(TAB, ROOT, controller.signal)
const stream = await watches.forPath(ROOT)
controller.abort()
await stream.released.promise
expect(stream.signal.aborted).toBe(true)
expect(list).not.toHaveBeenCalled()
expect(snapshot()).toBeUndefined()
})
it('makes no request for a record that already ended', () => {
const { face, list } = mount()
const controller = new AbortController()
const { face, list, controller } = mount()
controller.abort()
face.load(TAB, ROOT, controller.signal)
expect(list).not.toHaveBeenCalled()
})
it('ignores expansion after cancellation or when its parent is not active', async () => {
const h = mount()
h.face.toggle(TAB, ROOT, `${ROOT}/src`, [ROOT], h.controller.signal)
expect(h.snapshot()).toBeUndefined()
h.face.start(TAB, ROOT, h.controller.signal)
const stream = await h.watches.ready(ROOT)
await h.settle({ ok: true, value: LEVEL })
const before = h.snapshot()
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, `${ROOT}/src`, [ROOT], h.controller.signal)
expect(h.snapshot()).toBeUndefined()
expect(h.list).toHaveBeenCalledTimes(1)
expect(h.watches.opened).toHaveLength(1)
})
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<undefined>()
const unsubscribe = h.instance.subscribe(() => {
const level = h.snapshot()?.levels[ROOT]
if (level?.kind === 'ready' && level.failure !== undefined) reported.resolve(undefined)
})
onTestFinished(unsubscribe)
h.face.start(TAB, ROOT, h.controller.signal)
const stream = await h.watches.forPath(ROOT)
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: 'watch disconnected' },
})
await stream.released.promise
})
it('does not publish a watch failure when the tab closes during its fallback list', async () => {
const close = vi.spyOn(DirectoryNode.prototype, 'close')
onTestFinished(() => { close.mockRestore() })
const h = mount()
h.face.start(TAB, ROOT, h.controller.signal)
const stream = await h.watches.forPath(ROOT)
stream.fail(new Error('watch disconnected'))
await h.waitForList(0)
h.controller.abort()
const closing = close.mock.results[0]
if (closing?.type !== 'return') throw new Error('expected the tab to close its directory tree')
await h.settle({ ok: true, value: LEVEL })
await expect(closing.value).resolves.toBeUndefined()
expect(h.snapshot()).toBeUndefined()
expect(h.watches.opened).toHaveLength(1)
})
it('lets the latest listing of a level win, whichever settles first', async () => {
const { face, list, settle, settleLatest, snapshot, outstanding } = mount()
const signal = new AbortController().signal
const { face, list, watches, settle, settleLatest, snapshot, outstanding, controller } = mount()
const { signal } = controller
const older: DirLevel = { entries: [{ name: 'old.txt', type: 'file' }], truncated: false }
face.start(TAB, ROOT, signal)
// The reload gesture asks for the root again while the first listing is still out.
await watches.ready(ROOT)
face.load(TAB, ROOT, signal)
expect(list).toHaveBeenCalledTimes(2)
expect(outstanding()).toEqual([ROOT, ROOT])
await settleLatest({ ok: true, value: LEVEL })
expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL })
// The retired listing lands afterwards and changes nothing.
await settle({ ok: true, value: older })
expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL })
// A retired failure is dropped the same way.
face.load(TAB, ROOT, signal)
face.load(TAB, ROOT, signal)
await settleLatest({ ok: true, value: LEVEL })
await settle({ ok: false, error: new RemoteError('workspace-file/not-found', 'gone', { path: ROOT }) })
expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL })
})
it('manually refreshes expanded nodes while preserving cached levels, scroll, and the automatic setting', async () => {
const { face, watches, settle, waitForList, snapshot, controller, actions } = mount()
const child = `${ROOT}/src`
const collapsed = `${ROOT}/docs`
face.start(TAB, ROOT, controller.signal)
await watches.ready(ROOT)
await settle({ ok: true, value: LEVEL })
face.toggle(TAB, ROOT, child, snapshot()!.expanded, controller.signal)
await watches.ready(child)
await settle({ ok: true, value: LEVEL })
actions.loaded(TAB, collapsed, LEVEL)
actions.scrolled(TAB, 120)
face.setAutoRefresh(TAB, false)
const cached = snapshot()!
face.refresh(TAB)
expect((await waitForList(2)).path).toBe(ROOT)
expect(snapshot()!.levels).toEqual(cached.levels)
await settle({ ok: true, value: LEVEL })
expect((await waitForList(3)).path).toBe(child)
expect(snapshot()!.levels[child]).toEqual(cached.levels[child])
await settle({ ok: true, value: { entries: [], truncated: false } })
expect(snapshot()!.levels[collapsed]).toEqual(cached.levels[collapsed])
expect(snapshot()).toMatchObject({ expanded: [ROOT, child], scrollTop: 120, autoRefresh: false })
expect(watches.opened.map(stream => stream.path)).toEqual([ROOT, child])
expect(watches.opened.every(stream => !stream.signal.aborted)).toBe(true)
})
})
describe('createList', () => {
@@ -1,14 +1,5 @@
// @vitest-environment jsdom
/**
* The body against a scripted listing.
*
* What is asserted is the reader's contract: the root lists itself on mount,
* rows come out directories-first, a directory click asks for exactly that
* level, a file click opens exactly that session-scoped `file:` address through
* the owner, an `other` entry is shown but not clickable, the tree says when it
* was cut or could not be read, and reload asks again for the expanded levels
* only. The two pure helpers the rows are built from are checked on their own.
*/
/** File-tree presentation over controlled directory watches and deferred listings. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent } from '@testing-library/react'
import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
@@ -45,6 +36,8 @@ describe('FilesBody', () => {
it('lists the root on mount, heads it with its path split at the last segment, and draws directories first with dotfiles kept', async () => {
const { view, script } = mountBody()
expect(script.list).not.toHaveBeenCalled()
await act(() => script.watches.ready(ROOT))
expect(script.list).toHaveBeenCalledWith(SESSION, ROOT, expect.any(AbortSignal))
expect(view.container.querySelector('[data-files-row="loading"]')).not.toBeNull()
await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
@@ -60,6 +53,7 @@ describe('FilesBody', () => {
it('heads a separator-only root by the root itself, since it has no final segment', async () => {
const { view, script } = mountBody('/')
await act(() => script.watches.ready('/'))
await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
const path = view.container.querySelector('[data-files-path]')
expect([...path?.querySelectorAll('span > span') ?? []].map(span => span.textContent)).toEqual(['/'])
@@ -88,6 +82,7 @@ describe('FilesBody', () => {
Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => boxWidth })
try {
const { view, script } = mountBody()
await act(() => script.watches.ready(ROOT))
await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
const path = view.container.querySelector<HTMLElement>('[data-files-path]')
const text = path?.firstElementChild
@@ -115,25 +110,37 @@ describe('FilesBody', () => {
}
})
it('a directory click lists that level once and marks it expanded; a second click collapses without asking again', async () => {
it('reopening a directory shows its cache and replaces it after the new subscription is ready', async () => {
const { view, script } = mountBody()
await act(() => script.watches.ready(ROOT))
await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
const dir = view.container.querySelector(`[data-files-path="${ROOT}/src"] > button`)!
act(() => { fireEvent.click(dir) })
expect(script.list).toHaveBeenCalledTimes(1)
const childStream = await act(() => script.watches.ready(`${ROOT}/src`))
expect(script.list).toHaveBeenLastCalledWith(SESSION, `${ROOT}/src`, expect.any(AbortSignal))
expect(dir.getAttribute('aria-expanded')).toBe('true')
await act(() => script.settle({ ok: true, value: { entries: [{ name: 'a.ts', type: 'file' }], truncated: false } }))
expect(names(view.container)).toContain(`${ROOT}/src/a.ts`)
act(() => { fireEvent.click(dir) })
expect(childStream.signal.aborted).toBe(true)
await childStream.released.promise
expect(dir.getAttribute('aria-expanded')).toBe('false')
expect(names(view.container)).not.toContain(`${ROOT}/src/a.ts`)
act(() => { fireEvent.click(dir) })
expect(names(view.container)).toContain(`${ROOT}/src/a.ts`)
expect(script.list).toHaveBeenCalledTimes(2)
await act(() => script.watches.ready(`${ROOT}/src`, 1))
expect(script.list).toHaveBeenCalledTimes(3)
expect(names(view.container)).toContain(`${ROOT}/src/a.ts`)
await act(() => script.settle({ ok: true, value: { entries: [{ name: 'b.ts', type: 'file' }], truncated: false } }))
expect(names(view.container)).toContain(`${ROOT}/src/b.ts`)
expect(names(view.container)).not.toContain(`${ROOT}/src/a.ts`)
})
it('a file click opens its session-scoped file: address through the owner; an other entry offers no button', async () => {
const { view, script, tabActions } = mountBody()
await act(() => script.watches.ready(ROOT))
await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
fireEvent.click(view.container.querySelector(`[data-files-path="${ROOT}/README.md"] > button`)!)
// Every row sits under the tree's root, so the address is the path relative to it.
@@ -146,15 +153,18 @@ describe('FilesBody', () => {
it('marks a cut listing and an empty one', async () => {
const { view, script } = mountBody()
await act(() => script.watches.ready(ROOT))
await act(() => script.settle({ ok: true, value: { entries: [{ name: 'd', type: 'directory' }], truncated: true } }))
expect(view.container.querySelector('[data-files-row="truncated"]')?.textContent).toBe(zh.truncated)
act(() => { fireEvent.click(view.container.querySelector(`[data-files-path="${ROOT}/d"] > button`)!) })
await act(() => script.watches.ready(`${ROOT}/d`))
await act(() => script.settle({ ok: true, value: { entries: [], truncated: false } }))
expect(view.container.querySelector('[data-files-row="empty"]')?.textContent).toBe(zh.empty)
})
it('shows a failed level under its directory with the failure code', async () => {
const { view, script } = mountBody()
await act(() => script.watches.ready(ROOT))
await act(() => script.settle({
ok: false,
error: new RemoteError('workspace-file/not-found', 'gone', { path: ROOT }),
@@ -164,28 +174,81 @@ describe('FilesBody', () => {
expect(failed?.textContent).toBe(zh['error.notFound'])
})
it('reload resets every level and lists the expanded ones again', async () => {
const { view, script, controller, instance } = mountBody()
it('shows a refresh failure beside the existing directory rows and clears it on retry', async () => {
const { view, script } = mountBody()
await act(() => script.watches.ready(ROOT))
await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
const rows = names(view.container)
const reload = view.getByRole('button', { name: zh.reload })
act(() => { fireEvent.click(reload) })
await act(() => script.settle({
ok: false, error: new RemoteError('workspace-file/not-found', 'gone', { path: ROOT }),
}))
expect(view.container.querySelector('[data-files-row="failed"]')?.textContent).toBe(zh['error.notFound'])
expect(names(view.container)).toEqual(rows)
act(() => { fireEvent.click(reload) })
await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
expect(view.container.querySelector('[data-files-row="failed"]')).toBeNull()
expect(names(view.container)).toEqual(rows)
})
it('reload refreshes expanded nodes without clearing cached rows or the scroll position', async () => {
const { view, script, instance } = mountBody()
const child = `${ROOT}/src`
const collapsed = `${ROOT}/docs`
const rootStream = await act(() => script.watches.ready(ROOT))
await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
act(() => { fireEvent.click(view.container.querySelector(`[data-files-path="${child}"] > button`)!) })
await act(() => script.watches.ready(child))
await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
// A level listed earlier and since collapsed is dropped, not re-fetched.
act(() => { instance.actions.loaded(TAB, collapsed, ROOT_LEVEL) })
script.list.mockClear()
const cached = instance.getSnapshot().byTab[TAB]!
const childRow = view.container.querySelector(`[data-files-path="${child}"]`)
const body = view.container.querySelector('[data-files-body]')!
fireEvent.scroll(body, { target: { scrollTop: 120 } })
act(() => { fireEvent.click(view.container.querySelector('[data-files-reload]')!) })
expect(script.list.mock.calls.map(call => call[1])).toEqual([ROOT, child])
expect(script.list).toHaveBeenCalledWith(SESSION, ROOT, controller.signal)
const state = instance.getSnapshot().byTab[TAB]!
expect(state.expanded).toEqual([ROOT, child])
expect(state.levels).toEqual({ [ROOT]: { kind: 'loading' }, [child]: { kind: 'loading' } })
expect(script.list.mock.calls.slice(2).map(call => call[1])).toEqual([ROOT])
expect(script.list).toHaveBeenLastCalledWith(SESSION, ROOT, rootStream.signal)
expect(instance.getSnapshot().byTab[TAB]!.levels).toEqual(cached.levels)
expect(view.container.querySelector('[data-files-row="loading"]')).toBeNull()
await act(async () => {
await script.settle({ ok: true, value: ROOT_LEVEL })
await script.waitForList(3)
})
expect(script.list.mock.calls.slice(2).map(call => call[1])).toEqual([ROOT, child])
expect(instance.getSnapshot().byTab[TAB]!.levels).toEqual(cached.levels)
await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
expect(view.container.querySelector(`[data-files-path="${child}"]`)).toBe(childRow)
expect(body.scrollTop).toBe(120)
expect(instance.getSnapshot().byTab[TAB]!.expanded).toEqual([ROOT, child])
expect(script.watches.opened.map(stream => stream.path)).toEqual([ROOT, child])
expect(view.container.querySelector('[data-files-reload]')?.getAttribute('aria-label')).toBe(zh.reload)
})
it('keeps the automatic control hidden and enabled by default, with manual reload independent of its setting', async () => {
const { view, script, instance } = mountBody()
const stream = await act(() => script.watches.ready(ROOT))
await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
const automatic = view.container.querySelector('[data-files-auto-refresh]')!
expect(automatic.closest('span[hidden]')).not.toBeNull()
expect(automatic.getAttribute('aria-pressed')).toBe('true')
expect(view.queryByRole('button', { name: zh.autoRefresh })).toBeNull()
expect(view.getByRole('button', { name: zh.reload })).toBeDefined()
act(() => { fireEvent.click(automatic) })
expect(automatic.getAttribute('aria-pressed')).toBe('false')
await act(() => stream.deliver('change'))
expect(script.list).toHaveBeenCalledTimes(1)
act(() => { fireEvent.click(view.getByRole('button', { name: zh.reload })) })
await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
expect(script.list).toHaveBeenCalledTimes(2)
expect(instance.getSnapshot().byTab[TAB]!.autoRefresh).toBe(false)
expect(automatic.getAttribute('aria-pressed')).toBe('false')
})
it('captures the body scroll offset on unmount and restores it when a tab switch remounts the tree', async () => {
const { view, script, instance, remount } = mountBody()
await act(() => script.watches.ready(ROOT))
await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
const body = view.container.querySelector('[data-files-body]')!
fireEvent.scroll(body, { target: { scrollTop: 120 } })
@@ -199,6 +262,7 @@ describe('FilesBody', () => {
it('a scroll before the owner aborts is not written to a forgotten bucket', async () => {
const { view, script, controller, instance } = mountBody()
await act(() => script.watches.ready(ROOT))
await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
fireEvent.scroll(view.container.querySelector('[data-files-body]')!, { target: { scrollTop: 80 } })
act(() => { controller.abort() })
@@ -208,6 +272,7 @@ describe('FilesBody', () => {
it('an aborted record is forgotten and not seeded again while the body is still mounted', async () => {
const { view, script, controller, instance } = mountBody()
await act(() => script.watches.ready(ROOT))
await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
act(() => { controller.abort() })
expect(instance.getSnapshot().byTab[TAB]).toBeUndefined()
@@ -8,7 +8,7 @@
import { useSyncExternalStore } from 'react'
import { render } from '@testing-library/react'
import type { RenderResult } from '@testing-library/react'
import { vi } from 'vitest'
import { onTestFinished, vi } from 'vitest'
import type { Mock } from 'vitest'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
@@ -61,8 +61,12 @@ export interface Mounted {
function harness(cwd: string | null) {
const instance = createFilesStore().create()
const script = scriptedList()
const face = filesFace(script.list)(SESSION, instance.actions)
const face = filesFace(script.list, script.watch)(SESSION, instance.actions)
const controller = new AbortController()
onTestFinished(async () => {
controller.abort()
await script.dispose()
})
const tabActions: MockedTabActions = {
openResource: vi.fn<SidebarRightTabActions['openResource']>(),
openTab: vi.fn<SidebarRightTabActions['openTab']>(),
@@ -1,12 +1,17 @@
/** A directory listing the spec settles by hand, one deferred result per call. */
/** Deferred directory reads paired with independently controlled watch streams. */
import { vi } from 'vitest'
import type { Mock } from 'vitest'
import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client'
import type { ListWorkspaceDirectory } from '../src/client/face.ts'
import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { ListWorkspaceDirectory, WatchWorkspaceDirectory } from '../src/client/face.ts'
import type { DirLevel } from '../src/client/store.ts'
import { DirectoryWatches } from './scripted-watch.client.ts'
/** The scripted listing: the mock the face receives, and the hand that settles it. */
export interface ScriptedList {
readonly watch: WatchWorkspaceDirectory
readonly watches: DirectoryWatches
readonly list: Mock<ListWorkspaceDirectory>
/**
* Settle the oldest outstanding call and let its store write land.
@@ -20,32 +25,62 @@ export interface ScriptedList {
readonly settleLatest: (result: RemoteResult<DirLevel>) => Promise<void>
/** Paths of calls not yet settled, oldest first. */
readonly outstanding: () => readonly string[]
/** Wait for a specific invocation, independently of its response. */
readonly waitForList: (index: number) => Promise<PendingList>
/** Settle outstanding reads and await every watch release after the owner aborts. */
readonly dispose: () => Promise<void>
}
/** One listing awaiting the spec's answer. */
export interface PendingList {
readonly sessionId: SessionId
readonly path: string
readonly signal: AbortSignal
readonly result: Promise<RemoteResult<DirLevel>>
resolve(result: RemoteResult<DirLevel>): void
}
/**
* Build a listing whose every call stays pending until the spec settles it.
* @returns the scripted listing.
* @returns the scripted listing and watch controls.
*/
/** One listing awaiting the spec's answer. */
interface PendingList {
readonly path: string
resolve(result: RemoteResult<DirLevel>): void
}
export function scriptedList(): ScriptedList {
const pending: PendingList[] = []
const list = vi.fn<ListWorkspaceDirectory>((_sessionId, path) =>
new Promise((resolve) => { pending.push({ path, resolve }) }))
const calls: PendingList[] = []
const waiters = new Map<number, (call: PendingList) => void>()
const watches = new DirectoryWatches()
const list = vi.fn<ListWorkspaceDirectory>((sessionId, path, signal) => {
const result = Promise.withResolvers<RemoteResult<DirLevel>>()
const call = { sessionId, path, signal, result: result.promise, resolve: result.resolve }
pending.push(call)
const index = calls.push(call) - 1
waiters.get(index)?.(call)
waiters.delete(index)
return result.promise
})
const land = async (call: PendingList | undefined, result: RemoteResult<DirLevel>): Promise<void> => {
if (call === undefined) throw new Error('no outstanding listing to settle')
call.resolve(result)
await Promise.resolve()
await Promise.resolve()
await call.result
}
return {
watch: watches.watch,
watches,
list,
settle: result => land(pending.shift(), result),
settleLatest: result => land(pending.pop(), result),
outstanding: () => pending.map(call => call.path),
waitForList: (index) => {
const call = calls[index]
if (call !== undefined) return Promise.resolve(call)
return new Promise((resolve) => { waiters.set(index, resolve) })
},
dispose: async () => {
for (const call of pending.splice(0)) {
call.resolve({ ok: false, error: new RemoteError('gateway/internal', 'fixture closed', {}) })
}
await Promise.all([watches.dispose(), ...calls.map(call => call.result)])
waiters.clear()
},
}
}
@@ -0,0 +1,109 @@
/** Target-scoped watch streams with explicit delivery and asynchronous release barriers. */
import { vi } from 'vitest'
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: unknown }
/** One directory subscription; delivering an event waits for the consumer's next pull. */
export class DirectoryWatch implements AsyncIterable<Event> {
readonly releasing = Promise.withResolvers<undefined>()
readonly released = Promise.withResolvers<undefined>()
private readonly controller = new AbortController()
private readonly lifetime: AbortSignal
private readonly queue: Frame[] = []
private wake: (() => void) | undefined
private releaseGate: PromiseWithResolvers<undefined> | undefined
constructor(readonly sessionId: SessionId, readonly path: string, readonly signal: AbortSignal) {
this.lifetime = AbortSignal.any([signal, this.controller.signal])
}
deliver(value: Event): Promise<void> {
return new Promise((resolve) => {
this.queue.push({ kind: 'event', value, delivered: resolve })
this.wake?.()
})
}
fail(error: unknown): void {
this.queue.push({ kind: 'error', error })
this.wake?.()
}
holdRelease(): PromiseWithResolvers<undefined> {
return this.releaseGate ??= Promise.withResolvers<undefined>()
}
stop(): void {
this.controller.abort()
this.releaseGate?.resolve(undefined)
}
async *[Symbol.asyncIterator](): AsyncIterator<Event> {
const abort = (): void => { this.wake?.() }
this.lifetime.addEventListener('abort', abort, { once: true })
try {
while (!this.lifetime.aborted) {
const next = this.queue.shift()
if (next === undefined) {
await new Promise<void>((resolve) => { this.wake = resolve })
this.wake = undefined
continue
}
if (next.kind === 'error') throw next.error
try {
yield next.value
} finally {
next.delivered()
}
}
} finally {
this.lifetime.removeEventListener('abort', abort)
this.releasing.resolve(undefined)
await this.releaseGate?.promise
this.released.resolve(undefined)
}
}
}
/** Records independent streams, including new subscriptions to a reopened directory. */
export class DirectoryWatches {
readonly opened: DirectoryWatch[] = []
private readonly waiters = new Set<() => void>()
readonly watch = vi.fn<WatchWorkspaceDirectory>((sessionId, path, signal) => {
const stream = new DirectoryWatch(sessionId, path, signal)
this.opened.push(stream)
for (const waiter of this.waiters) waiter()
return stream
})
forPath(path: string, occurrence = 0): Promise<DirectoryWatch> {
const current = (): DirectoryWatch | undefined => this.opened.filter(stream => stream.path === path)[occurrence]
const stream = current()
if (stream !== undefined) return Promise.resolve(stream)
return new Promise((resolve) => {
const wait = (): void => {
const stream = current()
if (stream === undefined) return
this.waiters.delete(wait)
resolve(stream)
}
this.waiters.add(wait)
})
}
async ready(path: string, occurrence = 0): Promise<DirectoryWatch> {
const stream = await this.forPath(path, occurrence)
await stream.deliver('ready')
return stream
}
async dispose(): Promise<void> {
for (const stream of this.opened) stream.stop()
await Promise.all(this.opened.map(stream => stream.released.promise))
this.waiters.clear()
}
}
@@ -1,11 +1,4 @@
/**
* The tree's write set, one tab at a time.
*
* Two facts here are load-bearing for the body: a collapsed level keeps what it
* loaded (reopening draws at once), and `reset` clears levels while keeping the
* expanded set, which is what lets the reload gesture know which levels to ask
* for again.
*/
/** Tab-local directory caches, expansion preferences, and refresh settings. */
import { describe, expect, it } from 'vitest'
import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
import { createFilesStore } from '../src/client/store.ts'
@@ -33,21 +26,38 @@ describe('createFilesStore', () => {
const { actions } = store
const getSnapshot = (): ReturnType<typeof store.getSnapshot> => store.getSnapshot()
actions.start(TAB, ROOT)
expect(getSnapshot().byTab[TAB]).toEqual({ root: ROOT, levels: {}, expanded: [ROOT], scrollTop: 0 })
expect(getSnapshot().byTab[TAB]).toEqual({ root: ROOT, levels: {}, expanded: [ROOT], scrollTop: 0, autoRefresh: true })
})
it('walks one level through loading, ready, and failed', () => {
it('shows a failed initial listing and replaces it with a successful retry', () => {
const store = createFilesStore().create()
const { actions } = store
const getSnapshot = (): ReturnType<typeof store.getSnapshot> => store.getSnapshot()
actions.start(TAB, ROOT)
actions.loading(TAB, ROOT)
expect(getSnapshot().byTab[TAB]!.levels[ROOT]).toEqual({ kind: 'loading' })
actions.loaded(TAB, ROOT, LEVEL)
expect(getSnapshot().byTab[TAB]!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL })
const failure = new RemoteError('workspace-file/not-found', 'gone', { path: ROOT })
actions.failed(TAB, ROOT, failure)
expect(getSnapshot().byTab[TAB]!.levels[ROOT]).toEqual({ kind: 'failed', failure })
actions.loading(TAB, ROOT)
expect(getSnapshot().byTab[TAB]!.levels[ROOT]).toEqual({ kind: 'loading' })
actions.loaded(TAB, ROOT, LEVEL)
expect(getSnapshot().byTab[TAB]!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL })
})
it('retains a loaded level during refresh and beside a refresh failure', () => {
const store = createFilesStore().create()
const { actions } = store
actions.start(TAB, ROOT)
actions.loaded(TAB, ROOT, LEVEL)
const cached = store.getSnapshot().byTab[TAB]!.levels[ROOT]
actions.loading(TAB, ROOT)
expect(store.getSnapshot().byTab[TAB]!.levels[ROOT]).toBe(cached)
const failure = new RemoteError('workspace-file/not-found', 'gone', { path: ROOT })
actions.failed(TAB, ROOT, failure)
expect(store.getSnapshot().byTab[TAB]!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL, failure })
actions.loaded(TAB, ROOT, LEVEL)
expect(store.getSnapshot().byTab[TAB]!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL })
})
it('toggles a directory in and out of the expanded set without touching its level', () => {
@@ -61,11 +71,10 @@ describe('createFilesStore', () => {
expect(getSnapshot().byTab[TAB]!.expanded).toEqual([ROOT, child])
actions.toggled(TAB, child)
expect(getSnapshot().byTab[TAB]!.expanded).toEqual([ROOT])
// Collapsing keeps the listing, so reopening draws without another fetch.
expect(getSnapshot().byTab[TAB]!.levels[child]).toEqual({ kind: 'ready', level: LEVEL })
})
it('reset drops every level and keeps the expanded set', () => {
it('reset drops levels while keeping expansion, scroll, and the automatic setting', () => {
const store = createFilesStore().create()
const { actions } = store
const getSnapshot = (): ReturnType<typeof store.getSnapshot> => store.getSnapshot()
@@ -74,8 +83,45 @@ describe('createFilesStore', () => {
actions.loaded(TAB, ROOT, LEVEL)
actions.toggled(TAB, child)
actions.loaded(TAB, child, LEVEL)
actions.scrolled(TAB, 120)
actions.autoRefresh(TAB, false)
actions.reset(TAB)
expect(getSnapshot().byTab[TAB]).toEqual({ root: ROOT, levels: {}, expanded: [ROOT, child], scrollTop: 0 })
expect(getSnapshot().byTab[TAB]).toEqual({ root: ROOT, levels: {}, expanded: [ROOT, child], scrollTop: 120, autoRefresh: false })
})
it('keeps the automatic setting independent for each tab', () => {
const store = createFilesStore().create()
const other = 'tab-2' as TabId
store.actions.start(TAB, ROOT)
store.actions.start(other, ROOT)
store.actions.autoRefresh(TAB, false)
expect(store.getSnapshot().byTab[TAB]!.autoRefresh).toBe(false)
expect(store.getSnapshot().byTab[other]!.autoRefresh).toBe(true)
store.actions.autoRefresh(TAB, true)
expect(store.getSnapshot().byTab[TAB]!.autoRefresh).toBe(true)
})
it.each(['removed', 'file'] as const)('drops cached descendants and expansion when a directory is %s', (replacement) => {
const store = createFilesStore().create()
const { actions } = store
const child = `${ROOT}/src`
const grandchild = `${child}/nested`
const sibling = `${ROOT}/src-other`
actions.start(TAB, ROOT)
actions.loaded(TAB, ROOT, { entries: [
{ name: 'src', type: 'directory' }, { name: 'src-other', type: 'directory' },
], truncated: false })
for (const path of [child, grandchild, sibling]) {
actions.toggled(TAB, path)
actions.loaded(TAB, path, LEVEL)
}
actions.loaded(TAB, ROOT, { entries: replacement === 'file'
? [{ name: 'src', type: 'file' }, { name: 'src-other', type: 'directory' }]
: [{ name: 'src-other', type: 'directory' }], truncated: false })
const state = store.getSnapshot().byTab[TAB]!
expect(state.expanded).toEqual([ROOT, sibling])
expect(Object.keys(state.levels)).toEqual([ROOT, sibling])
expect(state.levels[sibling]).toEqual({ kind: 'ready', level: LEVEL })
})
it('remembers where the body is scrolled to', () => {
@@ -0,0 +1,134 @@
/** Directory notifications and disposal through the shared scripted Remote transport. */
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { SupervisedStreamOptions } from '@deepseek-ai/dsh-api-workspace-files/src/client/remote.ts'
import { FakeRemote } from '../../../api/workspace-files/tests/fake-remote.client.ts'
import { createWatch } from '../src/client/face.ts'
const SESSION = 'directory-watch-session' as SessionId
const ROOT = '/work/app'
function mount() {
const remote = new FakeRemote()
remote.autoReady = false
const controller = new AbortController()
const supervise = vi.spyOn(remote, '$stream')
// createWatch consumes only the stream supervisor and workspaceFiles.changes.
const iterator = createWatch(remote as unknown as ClientRemote)(SESSION, ROOT, controller.signal)[Symbol.asyncIterator]()
onTestFinished(async () => {
controller.abort()
const returned = iterator.return?.()
await remote.dispose()
await returned
})
return { remote, controller, iterator, supervise }
}
describe('createWatch', () => {
it('opens the requested directory, accepts ready, and forwards changes without accepting them', async () => {
const h = mount()
const first = h.iterator.next()
const watch = await h.remote.waitForChanges(0)
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' })
expect(h.remote.calls).toEqual(['changes', 'accept'])
const next = h.iterator.next()
watch.source.push({ kind: 'change', change: { absolutePath: ROOT, version: 'changed' } })
await expect(next).resolves.toEqual({ done: false, value: 'change' })
expect(h.remote.calls).toEqual(['changes', 'accept'])
await h.iterator.return?.()
expect(watch.source.aborted).toBe(true)
})
it('does not create a stream for an already-cancelled node', async () => {
const h = mount()
h.controller.abort()
await expect(h.iterator.next()).resolves.toEqual({ done: true, value: undefined })
expect(h.supervise).not.toHaveBeenCalled()
expect(h.remote.opened).toEqual([])
})
it.each([false, true])('classifies a normal stream end after ready=%s and disposes it', async (ready) => {
const h = mount()
const first = h.iterator.next()
const watch = await h.remote.waitForChanges(0)
const options = h.supervise.mock.calls[0]![0]
const ended = vi.spyOn(options, 'ended')
let pending = first
if (ready) {
watch.source.push({ kind: 'ready' })
await expect(first).resolves.toEqual({ done: false, value: 'ready' })
pending = h.iterator.next()
}
const rejected = expect(pending).rejects.toThrow(`Directory watch ended: ${ROOT}`)
watch.source.end()
await rejected
expect(ended).toHaveBeenCalledExactlyOnceWith(ready)
expect(h.remote.disposed).toEqual([`directory ${ROOT}`])
expect(watch.source.aborted).toBe(true)
})
it('awaits disposal on consumer return and removes the node abort listener', async () => {
const h = mount()
const gate = h.remote.holdDisposal()
const first = h.iterator.next()
const watch = await h.remote.waitForChanges(0)
watch.source.push({ kind: 'ready' })
await first
const result = h.supervise.mock.results[0]
if (result?.type !== 'return') throw new Error('expected a supervised stream')
const dispose = vi.spyOn(result.value, 'dispose')
let closed = false
const closing = h.iterator.return!().then((value) => { closed = true; return value })
try {
await h.remote.waitForDispose(0)
expect(closed).toBe(false)
expect(watch.source.aborted).toBe(true)
gate.resolve(undefined)
await expect(closing).resolves.toEqual({ done: true, value: undefined })
expect(dispose).toHaveBeenCalledTimes(1)
h.controller.abort()
expect(dispose).toHaveBeenCalledTimes(1)
} finally {
gate.resolve(undefined)
await closing
}
})
it('does not yield a frame delivered after the node cancels', async () => {
const h = mount()
const supervise = FakeRemote.prototype.$stream.bind(h.remote)
const pulled = Promise.withResolvers<undefined>()
const deliver = Promise.withResolvers<undefined>()
h.supervise.mockImplementation(function<Item>(options: SupervisedStreamOptions<Item>) {
const stream = supervise(options)
return {
dispose: () => stream.dispose(),
async *[Symbol.asyncIterator]() {
for await (const item of stream) {
pulled.resolve(undefined)
await deliver.promise
yield item
}
},
}
})
const pending = h.iterator.next()
try {
const watch = await h.remote.waitForChanges(0)
watch.source.push({ kind: 'ready' })
await pulled.promise
h.controller.abort()
await h.remote.waitForDispose(0)
deliver.resolve(undefined)
await expect(pending).resolves.toEqual({ done: true, value: undefined })
expect(h.remote.calls).toEqual(['changes'])
expect(watch.source.aborted).toBe(true)
} finally {
deliver.resolve(undefined)
}
})
})
@@ -92,6 +92,7 @@ async function write(path: string, content: string): Promise<void> {
}
class RecordingFileSystem extends FileSystem {
override watch(): never { throw new Error('Fixture does not support watching') }
entries = new Map<string, { type: FsInfo['type']; content?: string; version?: FsVersion }>()
missingOnResolve = new Set<string>()
throwOnStat = new Set<string>()
@@ -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 /** 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<HTMLElement>\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<HTMLElement>\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: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:45',
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<ArrayBuffer> }\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<ArrayBuffer> }\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: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:81',
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:60',
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 /** 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<HTMLElement>\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<HTMLElement>\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:72',
source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:78',
},
{
key: 'sidebar.right.tab.guide',
@@ -975,6 +975,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
summary: 'Abstract filesystem provider.',
description: 'Abstract filesystem provider. Targets must preserve identity across aliases; reads expose regular UTF-8 text or typed errors, listings are stable and content-free, and mutations are atomic. Optional guards add stale protection without changing the unguarded provider contract.',
methods: [
{
signature: 'abstract watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise<void>>',
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; 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.'],
},
{
signature: 'abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget>',
description: 'Resolve a model/plugin-supplied path into a stable FsTarget. May perform I/O (a remote/sandboxed backend may need a round-trip to map a path to a stable identity), hence async even though the local backend only normalizes + realpaths.',
@@ -3329,10 +3336,11 @@ 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, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>',
description: 'Stream every `fs/observed` observation of a file inside the Session\'s workspace. Only instrumented filesystem operations report here; the OS is not watched.',
parameters: [{ name: 'workspaceFileScope', description: 'header-derived workspace root for the Session identity on the wire.' }, { name: 'signal', description: 'generation cancellation.' }],
returns: '`ready` once the Host observation queue is active and the workspace root is resolved, then queued and live observations in emission order.',
signature: '@Remote({ mode: \'stream\' }) changes(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>',
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: '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.'],
},
],
},
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/fs/fs-local/README.md
README.md: e781abd2032868a3768a41eb01ce5aac52f8c835
README.zh.md: bf21f92a48c7c9d804908ee8bc25b2be8daa8967
README.md: 9c005443eb064a03b3050c9ae82295c794315239
README.zh.md: 3adc249afe68b4cd8b56c68101baafec47ffb981
+6 -3
View File
@@ -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. Files use a filtered parent-directory watch, so readiness also covers creation of an initially missing file. File watches cover in-place writes, atomic replacement, deletion, and same-path recreation while the parent directory remains.
-----
@@ -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.
</details>
@@ -127,6 +129,7 @@ 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.
- **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.
+6 -3
View File
@@ -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 取消初始化;就绪后,调用方必须等待返回的关闭函数完成。
</details>
@@ -127,6 +129,7 @@ kind: "package-reference"
这些限制说明本地后端何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用文件系统对比或任务积压。
- **`config.cwd` 不是沙箱**:它是解析默认值,而非约束;绝对路径和 `..` 可以逃逸。请使用更严格的 `ctx.fs` 后端或 `tools/execute` waterfall(瀑布式事件)上的权限插件实施约束。
- **Linux 父目录重建**:父目录删除并重建后的监听恢复仍延期;父目录保持存在时,同路径文件重建仍受支持。
- **版本 token 依赖文件系统元数据**:它们组合设备、inode、大小、纳秒级 mtime 与纳秒级 ctime;如果存储层在重写时无法更新其中任何一项事实,仍可能绕过陈旧防护。
- **`editText` 会把整个文件及编辑后的副本保存在内存中**:只有读取路径支持流式处理。
- **低于上限的覆写仍会缓冲上下文基础**:`writeText` 除调用方持有的替换内容外,最多还会保留略低于 `config.diffBasisMaxBytes` 的旧文本;该上限不限制返回的 `after` 值,也不限制整文件展示回退。
+1
View File
@@ -31,6 +31,7 @@
"@deepseek-ai/cordis": "workspace:^"
},
"dependencies": {
"chokidar": "^4.0.3",
"koffi": "^3.1.0",
"@deepseek-ai/schemastery": "workspace:^"
},
+26 -1
View File
@@ -6,7 +6,9 @@
import { Context } from '@deepseek-ai/cordis'
import { constants as bufferConstants } from 'node:buffer'
import { isAbsolute, relative, resolve, sep } from 'node:path'
import { once } from 'node:events'
import { watch } from 'chokidar'
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
import z from '@deepseek-ai/schemastery'
import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs'
@@ -64,6 +66,29 @@ const MAX_DIFF_BASIS_BYTES = Math.min(
* containment with a stricter backend or a `tools/execute` permission plugin.
*/
export class LocalFileSystem extends FileSystem {
override async watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise<void>> {
signal.throwIfAborted()
const path = resolve(this.processPath(target))
const directory = (await this.stat(target, signal))?.type === 'directory'
signal.throwIfAborted()
const root = directory ? path : dirname(path)
const watcher = watch(root, {
ignoreInitial: true, depth: 0,
ignored: entry => !directory && resolve(entry) !== root && resolve(entry) !== path,
})
watcher.on('all', (_event, entry) => {
if (directory || resolve(entry) === path) changed()
})
watcher.on('error', (error) => { changed(error instanceof Error ? error : new Error(String(error))) })
try {
await once(watcher, 'ready', { signal })
return () => watcher.close()
} catch (error) {
await watcher.close()
throw error
}
}
static Config: z<Config> = z.object({
cwd: z.string().default(process.cwd()),
diffBasisMaxBytes: z.number().default(DEFAULT_DIFF_BASIS_MAX_BYTES),
+144
View File
@@ -0,0 +1,144 @@
/** Watch initialization and asynchronous release at the Chokidar adapter. */
import { Context } from '@deepseek-ai/cordis'
import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import { dirname, join, resolve } from 'node:path'
import * as chokidar from 'chokidar'
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { LocalFileSystem } from '../src/index.ts'
vi.mock('chokidar', async (importOriginal) => {
const original = await importOriginal<typeof import('chokidar')>()
return { ...original, watch: vi.fn(original.watch) }
})
afterEach(() => { vi.restoreAllMocks() })
async function setup(kind: 'file' | 'directory' | 'missing' = 'file') {
const watcher = new chokidar.FSWatcher()
onTestFinished(() => watcher.close())
const started = Promise.withResolvers<undefined>()
const watch = vi.mocked(chokidar.watch).mockReset().mockImplementation(() => {
started.resolve(undefined)
return watcher
})
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
await ctx.plugin(LocalFileSystem)
const path = resolve('/workspace/file.txt')
const target = { targetKey: FsTargetKey(path), displayPath: 'file.txt' }
const stat = vi.spyOn(ctx.fs, 'stat').mockResolvedValue(kind === 'missing'
? undefined : { type: kind, version: FsVersion('v1'), size: 1 })
const changed = vi.fn<(error?: Error) => void>()
const controller = new AbortController()
onTestFinished(() => { controller.abort() })
return { watcher, watch, fs: ctx.fs, target, changed, controller, path, stat, started: started.promise }
}
describe('local filesystem watch', () => {
it.each(['file', 'missing'] as const)('waits for the parent watch of a %s target and awaits closure', async (kind) => {
const h = await setup(kind)
const pending = h.fs.watch(h.target, h.changed, h.controller.signal)
await h.started
const ignored = h.watch.mock.calls[0]![1]!.ignored
if (typeof ignored !== 'function') throw new Error('Expected a target filter')
expect(h.watch).toHaveBeenCalledExactlyOnceWith(dirname(h.path), { ignoreInitial: true, depth: 0, ignored })
expect(ignored(dirname(h.path))).toBe(false)
expect(ignored(h.path)).toBe(false)
expect(ignored(join(dirname(h.path), 'unrelated.txt'))).toBe(true)
let ready = false
void pending.then(() => { ready = true })
await Promise.resolve(undefined)
expect(ready).toBe(false)
h.watcher.emit('ready')
const close = await pending
h.watcher.emit('all', 'addDir', dirname(h.path))
h.watcher.emit('all', 'change', join(dirname(h.path), 'unrelated.txt'))
expect(h.changed).not.toHaveBeenCalled()
h.watcher.emit('all', 'change', h.path)
expect(h.changed).toHaveBeenCalledExactlyOnceWith()
const closed = Promise.withResolvers<undefined>()
onTestFinished(() => { closed.resolve(undefined) })
const nativeClose = h.watcher.close.bind(h.watcher)
vi.spyOn(h.watcher, 'close').mockImplementationOnce(async () => { await closed.promise; await nativeClose() })
const closing = close()
try {
expect(h.watcher.closed).toBe(false)
} finally {
closed.resolve(undefined)
await closing
}
expect(h.watcher.closed).toBe(true)
})
it('watches a directory itself without filtering its direct entries', async () => {
const h = await setup('directory')
const pending = h.fs.watch(h.target, h.changed, h.controller.signal)
await h.started
expect(h.watch.mock.calls[0]![0]).toBe(h.path)
const ignored = h.watch.mock.calls[0]![1]!.ignored
if (typeof ignored !== 'function') throw new Error('Expected a target filter')
expect(ignored(join(h.path, 'child.txt'))).toBe(false)
h.watcher.emit('ready')
const close = await pending
h.watcher.emit('all', 'change', join(h.path, 'child.txt'))
expect(h.changed).toHaveBeenCalledExactlyOnceWith()
await close()
})
it('does not acquire a watcher when cancelled during metadata lookup', async () => {
const h = await setup()
const metadata = Promise.withResolvers<undefined>()
h.stat.mockReturnValueOnce(metadata.promise)
const pending = h.fs.watch(h.target, h.changed, h.controller.signal)
const rejected = expect(pending).rejects.toThrow()
h.controller.abort()
metadata.resolve(undefined)
await rejected
expect(h.watch).not.toHaveBeenCalled()
})
it('rejects an already-cancelled initialization without acquiring a watcher', async () => {
const h = await setup()
h.controller.abort()
await expect(h.fs.watch(h.target, h.changed, h.controller.signal)).rejects.toThrow()
expect(h.watch).not.toHaveBeenCalled()
})
it('closes the acquired watcher when initialization is cancelled', async () => {
const h = await setup()
const pending = h.fs.watch(h.target, h.changed, h.controller.signal)
const rejected = expect(pending).rejects.toThrow()
await h.started
h.controller.abort()
await rejected
expect(h.watcher.closed).toBe(true)
expect(h.changed).not.toHaveBeenCalled()
})
it('reports initialization errors and closes before rejecting', async () => {
const h = await setup()
const error = new Error('watch failed')
const pending = h.fs.watch(h.target, h.changed, h.controller.signal)
const rejected = expect(pending).rejects.toBe(error)
await h.started
h.watcher.emit('error', error)
await rejected
expect(h.changed).toHaveBeenCalledExactlyOnceWith(error)
expect(h.watcher.closed).toBe(true)
})
it('normalizes watcher errors after initialization', async () => {
const h = await setup()
const pending = h.fs.watch(h.target, h.changed, h.controller.signal)
await h.started
h.watcher.emit('ready')
const close = await pending
const error = new Error('watch failed')
h.watcher.emit('error', error)
// EventEmitter callbacks can receive non-Error values from the external watcher.
h.watcher.emit('error', 'watch unavailable')
expect(h.changed.mock.calls).toEqual([[error], [new Error('watch unavailable')]])
await close()
expect(h.watcher.closed).toBe(true)
})
})
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/fs/fs-sandbox/README.md
README.md: e126e71573f4ce1480543d0a94cfe8512f03472b
README.zh.md: b224296711ae7c43849fa2a426a2a6e79c99f65e
README.md: fde670f9a51fafb29b8d7057517ff26f551e9ff4
README.zh.md: ff58cd0a4eb2094e138fa24da03a371ff2054c99
+1 -1
View File
@@ -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> 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> 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.
-----
+1 -1
View File
@@ -47,7 +47,7 @@ kind: "package-reference"
### 可观察的成功与失败
读取、列出与元数据操作与 `fs-local` 完全一致。被拒绝的变更返回携带有效模式的 `FS_SANDBOX_DENIED` 错误;经工具,模型会看到 `[sandbox: file access denied under <mode> mode]` 及唯一一次获批更宽权限的重试提示,与 bash 的拒绝完全相同。获得批准升级的会话可以在该次调用中以严格更宽的模式重试同一操作。
读取、列出、元数据操作与只读监听均与 `fs-local` 完全一致;变更围栏不限制观察。被拒绝的变更返回携带有效模式的 `FS_SANDBOX_DENIED` 错误;经工具,模型会看到 `[sandbox: file access denied under <mode> mode]` 及唯一一次获批更宽权限的重试提示,与 bash 的拒绝完全相同。获得批准升级的会话可以在该次调用中以严格更宽的模式重试同一操作。
-----
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more