@deepseek-ai/dsh-client-ui-sidebar-files 0.1.6-alpha.2 → 0.1.7-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-files/README.md
5
- README.md: efaf0a584e0e8617d0cb97442f9ebb5fa1fe7b59
6
- README.zh.md: a0d0abffcd6e9f4e979d9a6548537745d0a74ba6
5
+ README.md: 0340991f16a61b80cd616982c6a6bea6ff60beac
6
+ README.zh.md: 016c74d801a3582785cb600f493eaeb92f7567b9
package/README.md CHANGED
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
9
9
 
10
10
  ## Summary
11
11
 
12
- 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.
12
+ 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.
13
13
 
14
14
  ## Table of Contents
15
15
 
@@ -25,25 +25,27 @@ The right Sidebar's navigator tab type: the session's workspace root as a tree,
25
25
  ## What it registers
26
26
 
27
27
  - **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.
28
- - **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.
28
+ - **The body** — the keyed `sidebar.right.pane.tab` seat under that id: a header row under the strip, then the tree. The shared [`PathLabel`](../ui-primitives/README.md#component-catalog) displays the root path with subdued directories and a primary final segment. A clipped path retains its trailing characters with a left-edge fade; hovering reveals the full path. The reload control stays at its right.
29
29
  - **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.
30
30
 
31
- 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).
31
+ 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).
32
32
 
33
33
  <a id="the-tree"></a>
34
34
  ## The tree
35
35
 
36
- 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.
36
+ The root is the session's working directory, read from `useSessions().byId[sessionId].cwd`. Filesystem roots such as `/` and Windows drive roots are valid tree roots. Every level is keyed by absolute path; a child's path is its parent's joined with the entry name by `/`. A level is listed when it is first expanded, through `remote.workspaceFiles.list(sessionId, absolutePath)` on the `@deepseek-ai/dsh-api-workspace-files` namespace; the adapter keeps the listing's entries and truncation flag and drops its workspace-relative path. Rows are ordered directories first, then by natural, case-insensitive name; dotfiles are shown like any other entry.
37
37
 
38
38
  | Entry type | Row |
39
39
  |---|---|
40
- | `directory` | Toggles; the level is fetched the first time it opens and kept while collapsed. |
40
+ | `directory` | Toggles; reopening lists again and restores still-present expanded descendants. Displayed entries remain cached while collapsed. |
41
41
  | `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. |
42
42
  | `other` | Shown greyed and not clickable, so the directory is reported whole. |
43
43
 
44
- 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.
44
+ 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.
45
45
 
46
- 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.
46
+ 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.
47
+
48
+ 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.
47
49
 
48
50
  <a id="model-experience"></a>
49
51
  ## Model Experience
@@ -57,7 +59,7 @@ None; directory listings travel over the Remote and assemble no model request.
57
59
  ## Known Limitations and Deferred Work
58
60
 
59
61
  <a id="known-limitations-and-deferred-work"></a>
60
- - **Listing only.** No search, artifact filter, drag-and-drop, rename, context menu, current-file highlight, or filesystem watching; a level changes only through reload.
62
+ - **Listing only.** No search, artifact filter, drag-and-drop, rename, context menu, or current-file highlight.
61
63
  - **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.
62
64
 
63
65
  <a id="dev-note"></a>
@@ -70,4 +72,4 @@ None.
70
72
 
71
73
  </details>
72
74
 
73
- **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.
75
+ **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.
package/README.zh.md CHANGED
@@ -9,7 +9,7 @@ kind: "package-reference"
9
9
 
10
10
  ## 概述
11
11
 
12
- 右侧 Sidebar 的导航器 tab 类型:把会话的工作区根目录画成一棵树,逐层经线上列出,并把文件打开到 Sidebar 里。它是从引导页进入的页类型,不认领任何地址;它按地址打开文件,交给 `dsh-resource://file` 的查看器认领:`ui-sidebar-right` 里没有任何东西认识本包。
12
+ 浏览 Session 的工作区目录树,并在 Sidebar 预览中打开文件。根目录与已展开目录通过直接子项监听自动刷新,手动重新读取仍可用。该 tab 从引导页进入,不认领资源地址。
13
13
 
14
14
  ## 目录
15
15
 
@@ -25,25 +25,27 @@ kind: "package-reference"
25
25
  ## 注册了什么
26
26
 
27
27
  - **类型**:`ctx.sidebarRightTabs.register(...)`,kind 为 `files`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-files`,档位 `builtin`,没有 patterns,另有一个打开该类型的引导页入口(order 10,标题与描述取自 `sidebarFiles` 命名空间,图标是共享的文件夹图标)。
28
- - **正文**:以该 id 为键的 `sidebar.right.pane.tab` slot:strip 下的一行标题行,然后是树。标题行与文档预览(`ui-sidebar-documentpreview`)的相同:根路径,目录部分灰色、最后一段正色,从不省略号截断(比行宽的路径保留末尾、淡出开头),右端是它唯一的控件、重新读取。这一行是复制而非共享,因为插件 bundle 只经平台模块共享运行时代码;待 artifact 与各 slot 的形态定下来后,可以在 `ui-primitives` 放一份供每个 pane 标题行使用。
28
+ - **正文**:以该 id 为键的 `sidebar.right.pane.tab` slot:strip 下的一行标题行,然后是树。共享的 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog) 显示根路径,目录使用弱化颜色,最后一段使用主色。路径过长时保留尾部字符并在左侧渐隐;悬停显示完整路径。重新读取控件位于右端。
29
29
  - **标签页标题**:以该 id 为键的 `sidebar.right.pane.tab.title` slot:类型标签前的一枚 16px 共享 `FileTypeIcon` 文件夹图标。树本身的行不画这枚图标。
30
30
 
31
- `src/client/` 下七个源文件:`definition.tsx`(类型是什么)、`store.ts`(它保存什么)、`face.ts`(它如何列目录,含 Remote 绑定)、`FilesBody.tsx`(它画什么,含排序与失败行两个辅助函数)、`FilesTitle.tsx`(标签页标题)、`locales.ts`(它说什么)、`index.ts`(接线)。
31
+ `src/client/` 下的源文件:`definition.tsx`(类型是什么)、`store.ts`(它保存什么)、`face.ts`(Remote 读取与监听)、`directory-node.ts`(打开的目录及其生命周期)、`FilesBody.tsx`(它画什么,含排序与失败行两个辅助函数)、`FilesTitle.tsx`(标签页标题)、`locales.ts`(它说什么)、`index.ts`(接线)。
32
32
 
33
33
  <a id="the-tree"></a>
34
34
  ## 树
35
35
 
36
- 根是会话的工作目录,读自 `useSessions().byId[sessionId].cwd`,标题行里的拆分由 `@deepseek-ai/dsh-util-workspace-path` 的 `pathPartsOf` 给出。每一层以绝对路径为键;子路径是父路径以 `/` 拼上条目名。一层在首次展开时经 `@deepseek-ai/dsh-api-workspace-files` 命名空间的 `remote.workspaceFiles.list(sessionId, absolutePath)` 列出;适配器保留列表的条目与截断标志,丢弃其工作区相对路径。行序为目录优先,其后按自然序、不分大小写的名称排列;dotfiles 与其他条目一样显示。
36
+ 根是会话的工作目录,读自 `useSessions().byId[sessionId].cwd`。`/` 和 Windows 盘根等文件系统根路径均可作为树的根。每一层以绝对路径为键;子路径是父路径以 `/` 拼上条目名。一层在首次展开时经 `@deepseek-ai/dsh-api-workspace-files` 命名空间的 `remote.workspaceFiles.list(sessionId, absolutePath)` 列出;适配器保留列表的条目与截断标志,丢弃其工作区相对路径。行序为目录优先,其后按自然序、不分大小写的名称排列;dotfiles 与其他条目一样显示。
37
37
 
38
38
  | 条目类型 | 行 |
39
39
  |---|---|
40
- | `directory` | 切换展开与折叠;该层在首次打开时拉取,折叠期间保留。 |
40
+ | `directory` | 切换展开与折叠;再次打开时重新列举,并恢复仍存在的已展开后代。折叠期间保留已显示条目的缓存。 |
41
41
  | `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 里。 |
42
42
  | `other` | 灰显且不可点击,从而完整呈现目录内容。 |
43
43
 
44
- 被端点条目上限截断的层以一条标记收尾;空层如实说明;失败的层按错误码各显示一行(`workspace-file/not-found`、`outside-workspace`、`not-directory`),其他情况显示传输层自己的消息。重新读取丢弃所有已列出的层并只对展开中的层重新请求;折叠的层在下次打开时重新拉取。没有工作目录的会话只显示一行说明,而不是树。
44
+ 被端点条目上限截断的层以一条标记收尾;空层如实说明;失败的层按错误码各显示一行(`workspace-file/not-found`、`outside-workspace`、`not-directory`),其他情况显示传输层自己的消息。重新读取就地刷新根与展开中的层,读取期间保留显示条目,不重置整棵树;折叠的层在下次打开时重新拉取。没有工作目录的会话只显示一行说明,而不是树。
45
45
 
46
- 状态保存在类型自己的存储里,按 tab id 分桶:`root`、`levels`(每个绝对路径的 loading / ready / failed)、`expanded` 与 `scrollTop`——滚动期间偏移由正文自己记录,卸载时一次性写入。存储比 body 活得久,切到其他侧栏 tab 再切回来时树带着已加载的层重新挂载,滚动位置也随之恢复。owner 的 `signal` 终结一个桶:中止时忘掉该 tab,其后才结算的列表与卸载时的偏移提交都什么也不写。
46
+ 状态保存在类型自己的存储里,按 tab id 分桶:`root`、`levels`(每个绝对路径的 loading / ready / failed)、`expanded`、`autoRefresh` 与 `scrollTop`——滚动期间偏移由正文自己记录,卸载时一次性写入。存储比 body 活得久,切到其他侧栏 tab 再切回来时树带着已加载的层重新挂载,滚动位置也随之恢复。owner 的 `signal` 终结一个桶:中止时忘掉该 tab,其后才结算的列表与卸载时的偏移提交都什么也不写。
47
+
48
+ 每个打开的 `DirectoryNode` 在 Tab 生命周期内持有自己的目标监听;折叠关闭该节点及其不可见后代。祖先恢复期间的展开状态变化会更新存储和待恢复节点,后代恢复时遵循最新展开偏好。自动刷新默认开启;独立开关暂时隐藏,状态、文案、样式与切换逻辑保留。目录读取期间或收尾时收到的变化仍会留待下一次刷新。
47
49
 
48
50
  <a id="model-experience"></a>
49
51
  ## 模型体验
@@ -57,7 +59,7 @@ kind: "package-reference"
57
59
  ## 已知限制与暂缓事项
58
60
 
59
61
  <a id="known-limitations-and-deferred-work"></a>
60
- - **只有列目录。**没有搜索、产物过滤、拖拽、重命名、右键菜单、当前文件高亮或文件系统监听;一层只会因重新读取而变化。
62
+ - **只有列目录。**没有搜索、产物过滤、拖拽、重命名、右键菜单或当前文件高亮。
61
63
  - **只有一个根。**树以会话工作目录为根;没有办法浏览到它之上,而 Host 本来也拒绝工作区根之外的路径。
62
64
 
63
65
  <a id="dev-note"></a>
@@ -70,4 +72,4 @@ kind: "package-reference"
70
72
 
71
73
  </details>
72
74
 
73
- **运行时不变量:** 不发布 companion。树唯一的运行时状态是每 tab 一份的 Slot store,由持有它的正文写入、随 tab 的中止信号忘掉;没有第二个观测源可与之比对。
75
+ **运行时不变量:** 不发布 companion。tab 持有的 face 内部管理目录读取与监听,并通过 Slot store 写入显示状态;tab 取消时释放二者。本包未暴露可用于运行时比对的独立观测源。
package/lib/client.js CHANGED
@@ -6,6 +6,7 @@ window.__ModuleLoader__.load({
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  let react_jsx_runtime = require("react/jsx-runtime");
8
8
  let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
9
+ require("@deepseek-ai/cordis");
9
10
  let react = require("react");
10
11
  let _deepseek_ai_dsh_client_store = require("@deepseek-ai/dsh-client-store");
11
12
  //#region lib/types/client/definition.js
@@ -42,8 +43,215 @@ window.__ModuleLoader__.load({
42
43
  };
43
44
  }
44
45
  //#endregion
46
+ //#region ../../typert/protocol/src/remote-error.ts
47
+ /**
48
+ * One Remote call failure: a real Error carrying its stable code and typed
49
+ * details. Owners throw it at the failure point; the Host Gateway encodes it
50
+ * onto the wire unchanged; the Client face rebuilds an instance for the
51
+ * `RemoteResult` error branch, so `throw result.error` keeps throw semantics.
52
+ * Discrimination is always by `code`, never by instanceof.
53
+ */
54
+ var RemoteError = class extends Error {
55
+ code;
56
+ details;
57
+ /** Structural marker: cross-realm/bundle identification never uses instanceof. */
58
+ isDSHRemoteError = true;
59
+ /**
60
+ * @param code - stable failure code declared in {@link RemoteErrorDetailsMap}.
61
+ * @param message - human diagnostic carried across the wire.
62
+ * @param details - structured payload typed by the code.
63
+ * @param options - standard Error options (`cause` survives in-process only).
64
+ */
65
+ constructor(code, message, details, options) {
66
+ super(message, options);
67
+ this.code = code;
68
+ this.details = details;
69
+ this.name = "RemoteError";
70
+ }
71
+ };
72
+ //#endregion
73
+ //#region ../../typert/protocol/src/index.ts
74
+ /**
75
+ * Remote decorators and explicit Gateway bindings backed by versioned
76
+ * descriptors carried on decorated class prototypes. Strict reflection
77
+ * remains a Typert compiler responsibility.
78
+ * @module @deepseek-ai/dsh-typert-protocol
79
+ */
80
+ //#endregion
81
+ //#region lib/types/client/directory-node.js
82
+ /** One open directory and its active child nodes; cached view preferences live in the store. */
83
+ var DirectoryNode = class DirectoryNode {
84
+ path;
85
+ load;
86
+ watch;
87
+ failed;
88
+ restore;
89
+ /** Open direct-child directories, keyed by absolute path. */
90
+ children = /* @__PURE__ */ new Map();
91
+ controller = new AbortController();
92
+ signal;
93
+ task;
94
+ reading;
95
+ dirty = false;
96
+ initialized = false;
97
+ automatic = true;
98
+ constructor(path, load, watch, failed, lifetime, restore = []) {
99
+ this.path = path;
100
+ this.load = load;
101
+ this.watch = watch;
102
+ this.failed = failed;
103
+ this.restore = restore;
104
+ this.signal = AbortSignal.any([lifetime, this.controller.signal]);
105
+ }
106
+ /**
107
+ * Start observation once; readiness triggers the initial listing.
108
+ * @returns this node.
109
+ */
110
+ open() {
111
+ this.task ??= this.follow();
112
+ return this;
113
+ }
114
+ /**
115
+ * Find an active node in this subtree.
116
+ * @param path - absolute directory path.
117
+ * @returns the matching node, or undefined when that directory is closed.
118
+ */
119
+ find(path) {
120
+ if (path === this.path) return this;
121
+ for (const child of this.children.values()) {
122
+ const found = child.find(path);
123
+ if (found !== void 0) return found;
124
+ }
125
+ }
126
+ /**
127
+ * Update the expansion preferences used by pending directory listings.
128
+ * @param expanded - latest expansion preferences from the store.
129
+ */
130
+ setExpanded(expanded) {
131
+ this.restore = expanded;
132
+ for (const child of this.children.values()) child.setExpanded(expanded);
133
+ }
134
+ /**
135
+ * Open a direct child using this node's lifetime and automatic-refresh setting.
136
+ * @param path - absolute direct-child directory path.
137
+ * @param restore - descendant expansion preferences to restore after listing.
138
+ * @returns the active child, or undefined after cancellation.
139
+ */
140
+ expand(path, restore = []) {
141
+ if (this.signal.aborted) return void 0;
142
+ let child = this.children.get(path);
143
+ if (child === void 0) {
144
+ child = new DirectoryNode(path, this.load, this.watch, this.failed, this.signal, restore);
145
+ child.automatic = this.automatic;
146
+ this.children.set(path, child);
147
+ }
148
+ return child.open();
149
+ }
150
+ /**
151
+ * Remove a child and its pending restoration preferences.
152
+ * @param path - absolute direct-child directory path.
153
+ * @returns once the child subtree's reads and watches have ended.
154
+ */
155
+ async collapse(path) {
156
+ this.restore = this.restore.filter((value) => value !== path && !value.startsWith(`${path}/`));
157
+ const child = this.children.get(path);
158
+ this.children.delete(path);
159
+ await child?.close();
160
+ }
161
+ /**
162
+ * Control subtree rereads without ending subscriptions.
163
+ * @param enabled - refresh dirty nodes automatically.
164
+ */
165
+ setAutomatic(enabled) {
166
+ this.automatic = enabled;
167
+ if (enabled && this.dirty) this.refresh();
168
+ for (const child of this.children.values()) child.setAutomatic(enabled);
169
+ }
170
+ /** Queue a reread, coalescing with active work. @returns completion of the active read and its coalesced rereads. */
171
+ refresh() {
172
+ this.dirty = true;
173
+ this.reading ??= this.read();
174
+ return this.reading;
175
+ }
176
+ /** Refresh this node and its open descendants. @returns once their listings settle. */
177
+ async refreshTree() {
178
+ await this.refresh();
179
+ await Promise.all([...this.children.values()].map((child) => child.refreshTree()));
180
+ }
181
+ /** Cancel the active subtree. @returns once all owned reads and watches have ended. */
182
+ async close() {
183
+ this.controller.abort();
184
+ await Promise.all([
185
+ this.task,
186
+ this.reading,
187
+ ...[...this.children.values()].map((child) => child.close())
188
+ ]);
189
+ this.children.clear();
190
+ }
191
+ async follow() {
192
+ try {
193
+ for await (const _event of this.watch(this.path, this.signal)) {
194
+ this.dirty = true;
195
+ if (!this.initialized || this.automatic) this.refresh();
196
+ }
197
+ } catch (error) {
198
+ if (!this.signal.aborted) {
199
+ if (!this.initialized) await this.refresh();
200
+ if (!(typeof error === "object" && error !== null && "code" in error && error.code === "workspace-file/watch-unsupported")) this.failed(this.path, error);
201
+ }
202
+ }
203
+ }
204
+ async read() {
205
+ const readAgain = () => this.dirty && this.automatic && !this.signal.aborted;
206
+ try {
207
+ do {
208
+ this.dirty = false;
209
+ const level = await this.load(this.path, this.signal);
210
+ if (this.signal.aborted || level === void 0) return;
211
+ this.initialized = true;
212
+ const directories = new Set(level.entries.filter((entry) => entry.type === "directory").map((entry) => `${this.path.replace(/[/\\]+$/, "")}/${entry.name}`));
213
+ for (const path of this.children.keys()) if (!directories.has(path)) await this.collapse(path);
214
+ for (const path of directories) if (this.restore.includes(path)) this.expand(path, this.restore);
215
+ this.restore = [];
216
+ } while (readAgain());
217
+ } finally {
218
+ this.reading = void 0;
219
+ }
220
+ }
221
+ };
222
+ //#endregion
45
223
  //#region lib/types/client/face.js
46
224
  /**
225
+ * Bind directory observation to the Remote stream supervisor.
226
+ * @param remote - Client Remote with workspace file streams.
227
+ * @returns a watcher that awaits stream disposal when its node ends.
228
+ */
229
+ function createWatch(remote) {
230
+ return async function* (sessionId, path, signal) {
231
+ const aborted = () => signal.aborted;
232
+ if (aborted()) return;
233
+ const stream = remote.$stream({
234
+ name: `directory ${path}`,
235
+ open: (lifetime) => remote.workspaceFiles.changes(sessionId, path, lifetime),
236
+ ended: () => /* @__PURE__ */ new Error(`Directory watch ended: ${path}`)
237
+ });
238
+ const abort = () => {
239
+ stream.dispose();
240
+ };
241
+ signal.addEventListener("abort", abort, { once: true });
242
+ try {
243
+ for await (const item of stream) {
244
+ if (aborted()) return;
245
+ if (item.value.kind === "ready") item.accept();
246
+ yield item.value.kind;
247
+ }
248
+ } finally {
249
+ signal.removeEventListener("abort", abort);
250
+ await stream.dispose();
251
+ }
252
+ };
253
+ }
254
+ /**
47
255
  * Bind the listing to one Remote face, keeping only what the tree stores.
48
256
  * @param remote - the Client Remote face carrying the `workspaceFiles` namespace.
49
257
  * @returns the listing the tree's face performs.
@@ -76,12 +284,14 @@ window.__ModuleLoader__.load({
76
284
  /**
77
285
  * Bind the tree's face to one directory listing.
78
286
  * @param list - the bound `workspaceFiles.list` call.
287
+ * @param watch - target-scoped directory observation.
79
288
  * @returns the Slot `inject` factory: session and bound actions in, face out.
80
289
  */
81
- function filesFace(list) {
290
+ function filesFace(list, watch) {
82
291
  return (sessionId, actions) => {
83
292
  /** Per tab, per absolute path: the listing generation a settlement must match; the latest request wins. */
84
293
  const generations = /* @__PURE__ */ new Map();
294
+ const roots = /* @__PURE__ */ new Map();
85
295
  const nextGeneration = (tabId, path) => {
86
296
  const byPath = generations.get(tabId) ?? /* @__PURE__ */ new Map();
87
297
  generations.set(tabId, byPath);
@@ -89,29 +299,52 @@ window.__ModuleLoader__.load({
89
299
  byPath.set(path, generation);
90
300
  return generation;
91
301
  };
92
- const load = (tabId, path, signal) => {
302
+ const load = async (tabId, path, signal) => {
93
303
  if (signal.aborted) return;
94
304
  const generation = nextGeneration(tabId, path);
95
305
  actions.loading(tabId, path);
96
- list(sessionId, path, signal).then((result) => {
97
- if (generations.get(tabId)?.get(path) !== generation) return;
306
+ return list(sessionId, path, signal).then((result) => {
307
+ if (signal.aborted || generations.get(tabId)?.get(path) !== generation) return;
98
308
  if (result.ok) actions.loaded(tabId, path, result.value);
99
309
  else actions.failed(tabId, path, result.error);
310
+ return result.ok ? result.value : void 0;
100
311
  });
101
312
  };
102
313
  return {
314
+ refresh: (tabId) => {
315
+ roots.get(tabId)?.refreshTree();
316
+ },
317
+ setAutoRefresh: (tabId, enabled) => {
318
+ actions.autoRefresh(tabId, enabled);
319
+ roots.get(tabId)?.setAutomatic(enabled);
320
+ },
103
321
  start(tabId, root, signal) {
104
322
  actions.start(tabId, root);
105
323
  signal.addEventListener("abort", () => {
324
+ roots.get(tabId)?.close();
325
+ roots.delete(tabId);
106
326
  generations.delete(tabId);
107
327
  actions.forget(tabId);
108
328
  }, { once: true });
109
- load(tabId, root, signal);
329
+ roots.set(tabId, new DirectoryNode(root, (path, lifetime) => load(tabId, path, lifetime), (path, lifetime) => watch(sessionId, path, lifetime), (path, error) => {
330
+ if (!signal.aborted) actions.failed(tabId, path, new RemoteError("gateway/internal", error instanceof Error ? error.message : String(error), {}));
331
+ }, signal).open());
332
+ },
333
+ load: (tabId, path, signal) => {
334
+ load(tabId, path, signal);
110
335
  },
111
- load,
112
- toggle(tabId, path, loaded, signal) {
336
+ toggle(tabId, parentPath, path, expanded, signal) {
337
+ if (signal.aborted) return;
338
+ const root = roots.get(tabId);
339
+ if (root === void 0) return;
340
+ const parent = root.find(parentPath);
341
+ if (parent === void 0 && !expanded.includes(parentPath)) return;
342
+ const collapsing = expanded.includes(path);
343
+ const next = collapsing ? expanded.filter((value) => value !== path) : [...expanded, path];
344
+ root.setExpanded(next);
345
+ if (collapsing) parent?.collapse(path);
346
+ else parent?.expand(path, next);
113
347
  actions.toggled(tabId, path);
114
- if (!loaded) load(tabId, path, signal);
115
348
  }
116
349
  };
117
350
  };
@@ -172,27 +405,6 @@ window.__ModuleLoader__.load({
172
405
  return path.startsWith("/") || isWindowsStylePath(path);
173
406
  }
174
407
  /**
175
- * Split a path for display: the directories through their last separator, and
176
- * the final segment after it. Both `/` and `\` separate, so a Windows path
177
- * splits where its own segments end; trailing separators are dropped first, so
178
- * a directory path names its own last segment. A path with no separator, or a
179
- * separator-only path, is all name.
180
- * @param path - file or directory path using POSIX or Windows separators.
181
- * @returns the directory prefix (possibly empty) and the final segment.
182
- */
183
- function pathPartsOf(path) {
184
- const trimmed = path.replace(/[/\\]+$/, "");
185
- if (trimmed === "") return {
186
- directory: "",
187
- name: path
188
- };
189
- const cut = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")) + 1;
190
- return {
191
- directory: trimmed.slice(0, cut),
192
- name: trimmed.slice(cut)
193
- };
194
- }
195
- /**
196
408
  * The address for a path as a caller holds it: a relative path, or an absolute
197
409
  * path inside the Session's workspace, becomes a `session`-scoped address; an
198
410
  * absolute path outside it, or one whose workspace root is unknown, keeps its
@@ -212,7 +424,7 @@ window.__ModuleLoader__.load({
212
424
  }
213
425
  //#endregion
214
426
  //#region \0dsh-css:/home/runner/work/deepseek-harness/deepseek-harness/packages/client/ui-sidebar-files/src/client/FilesBody.module.css.mjs
215
- const css = ".k-1LKG_root{height:100%;min-height:0;color:var(--dsw-alias-label-primary);font-size:var(--dsh-content-font-size-secondary,13px);flex-direction:column;flex:auto;line-height:1.5;display:flex}.k-1LKG_header{box-sizing:border-box;border-bottom:.5px solid var(--dsw-alias-border-l3);flex:none;align-items:center;gap:4px;height:38px;padding:0 6px 0 16px;display:flex}.k-1LKG_path{white-space:nowrap;flex:auto;justify-content:flex-end;min-width:0;margin-right:12px;font-size:12px;display:flex;overflow:hidden}.k-1LKG_path[data-files-path-clipped]{mask-image:linear-gradient(90deg,#0000,#000 28px)}.k-1LKG_pathText{flex:none;margin-right:auto}.k-1LKG_pathDirectory{color:var(--dsw-alias-label-tertiary)}.k-1LKG_pathName{color:var(--dsw-alias-label-primary)}.k-1LKG_body{scrollbar-gutter:stable;flex:auto;min-height:0;margin-right:2px;padding:8px 0 8px 8px;overflow:auto}.k-1LKG_body::-webkit-scrollbar-track{margin:2px}.k-1LKG_level{margin:0;padding:0;list-style:none}.k-1LKG_level .k-1LKG_level{padding-left:18px}.k-1LKG_item{margin:0;padding:0}.k-1LKG_row{width:100%;min-width:0;color:inherit;font:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:10px;align-items:center;gap:6px;padding:5px 10px;display:flex}.k-1LKG_row:hover{background:var(--dsw-alias-interactive-bg-hover)}.k-1LKG_icon{color:var(--dsw-alias-label-tertiary);flex:none}.k-1LKG_fileIcon{flex:none}.k-1LKG_name{white-space:nowrap;text-overflow:ellipsis;min-width:0;overflow:hidden}.k-1LKG_other{color:var(--dsw-alias-label-tertiary);cursor:default}.k-1LKG_other:hover{background:0 0}.k-1LKG_note{color:var(--dsw-alias-label-tertiary);margin:0;padding:3px 10px;font-size:12px}.k-1LKG_status{flex-direction:column;padding:12px 10px;display:flex}.k-1LKG_statusLine{color:var(--dsw-alias-label-secondary);font-size:var(--dsh-content-font-size-secondary,13px);margin:0;line-height:1.6}.k-1LKG_tool{width:28px;height:28px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;border-radius:28px;flex:none;justify-content:center;align-items:center;padding:6px;line-height:1;display:inline-flex}.k-1LKG_tool svg{width:15px;height:15px}.k-1LKG_tool:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}.k-1LKG_titleIcon{flex:none}";
427
+ const css = ".k-1LKG_root{height:100%;min-height:0;color:var(--dsw-alias-label-primary);font-size:var(--dsh-content-font-size-secondary,13px);flex-direction:column;flex:auto;line-height:1.5;display:flex}.k-1LKG_header{box-sizing:border-box;border-bottom:.5px solid var(--dsw-alias-border-l3);flex:none;align-items:center;gap:4px;height:38px;padding:0 6px 0 16px;display:flex}.k-1LKG_path{margin-right:12px}.k-1LKG_body{scrollbar-gutter:stable;flex:auto;min-height:0;margin-right:2px;padding:8px 0 8px 8px;overflow:auto}.k-1LKG_body::-webkit-scrollbar-track{margin:2px}.k-1LKG_level{margin:0;padding:0;list-style:none}.k-1LKG_level .k-1LKG_level{padding-left:18px}.k-1LKG_item{margin:0;padding:0}.k-1LKG_row{width:100%;min-width:0;color:inherit;font:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:10px;align-items:center;gap:6px;padding:5px 10px;display:flex}.k-1LKG_row:hover{background:var(--dsw-alias-interactive-bg-hover)}.k-1LKG_icon{color:var(--dsw-alias-label-tertiary);flex:none}.k-1LKG_fileIcon{flex:none}.k-1LKG_name{white-space:nowrap;text-overflow:ellipsis;min-width:0;overflow:hidden}.k-1LKG_other{color:var(--dsw-alias-label-tertiary);cursor:default}.k-1LKG_other:hover{background:0 0}.k-1LKG_note{color:var(--dsw-alias-label-tertiary);margin:0;padding:3px 10px;font-size:12px}.k-1LKG_status{flex-direction:column;padding:12px 10px;display:flex}.k-1LKG_statusLine{color:var(--dsw-alias-label-secondary);font-size:var(--dsh-content-font-size-secondary,13px);margin:0;line-height:1.6}.k-1LKG_tool{width:28px;height:28px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;border-radius:28px;flex:none;justify-content:center;align-items:center;padding:6px;line-height:1;display:inline-flex}.k-1LKG_tool svg{width:15px;height:15px}.k-1LKG_tool:hover,.k-1LKG_tool[aria-pressed=true]{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}.k-1LKG_titleIcon{flex:none}";
216
428
  const tagId = "@deepseek-ai/dsh-client-ui-sidebar-files/FilesBody.module.css";
217
429
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
218
430
  const tag = document.createElement("style");
@@ -232,9 +444,6 @@ window.__ModuleLoader__.load({
232
444
  "note": "k-1LKG_note",
233
445
  "other": "k-1LKG_other",
234
446
  "path": "k-1LKG_path",
235
- "pathDirectory": "k-1LKG_pathDirectory",
236
- "pathName": "k-1LKG_pathName",
237
- "pathText": "k-1LKG_pathText",
238
447
  "root": "k-1LKG_root",
239
448
  "row": "k-1LKG_row",
240
449
  "status": "k-1LKG_status",
@@ -251,10 +460,8 @@ window.__ModuleLoader__.load({
251
460
  * for goes through its injected face. The component itself only decides what to
252
461
  * draw for each absolute path and what a click means: a directory toggles, a
253
462
  * file opens through the owner's `tabActions` for a `file:` viewer to claim, and
254
- * anything else is shown but refuses to open. The header row is the text
255
- * preview's: the root's path, directories greyed and the last segment in full
256
- * ink, then the one control at its end, reload, which drops every listed level
257
- * and asks again for the expanded ones.
463
+ * anything else is shown but refuses to open. The header uses the shared
464
+ * PathLabel for the root, followed by reload for the expanded directories.
258
465
  */
259
466
  /** Natural, case-insensitive name order, so `file2` precedes `file10`. */
260
467
  const byName = new Intl.Collator(void 0, {
@@ -288,35 +495,6 @@ window.__ModuleLoader__.load({
288
495
  default: return t("error.unavailable", { message: failure.message });
289
496
  }
290
497
  }
291
- /**
292
- * Keep the path row's `data-files-path-clipped` current: set while the path's
293
- * text is wider than its box, so the stylesheet fades the clipped start. Read
294
- * after each commit that can change the path or mount the header, and whenever
295
- * either box resizes; written to the DOM directly because it changes only how
296
- * the stylesheet fades what is already rendered.
297
- */
298
- function usePathClipped(box, text, path) {
299
- (0, react.useLayoutEffect)(() => {
300
- const outer = box.current;
301
- const inner = text.current;
302
- if (outer === null || inner === null) return void 0;
303
- const apply = () => {
304
- if (inner.offsetWidth > outer.clientWidth) outer.dataset.filesPathClipped = "";
305
- else delete outer.dataset.filesPathClipped;
306
- };
307
- apply();
308
- const observer = typeof ResizeObserver === "undefined" ? void 0 : new ResizeObserver(apply);
309
- observer?.observe(outer);
310
- observer?.observe(inner);
311
- return () => {
312
- observer?.disconnect();
313
- };
314
- }, [
315
- box,
316
- text,
317
- path
318
- ]);
319
- }
320
498
  /** One entry's row, and its children when it is an expanded directory. */
321
499
  function Entry({ parent, entry, tree }) {
322
500
  const path = childPath(parent, entry.name);
@@ -331,9 +509,9 @@ window.__ModuleLoader__.load({
331
509
  className: FilesBody_module_css_default.row,
332
510
  "aria-expanded": expanded,
333
511
  onClick: () => {
334
- tree.onToggle(path);
512
+ tree.onToggle(parent, path);
335
513
  },
336
- children: [expanded ? (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconFolderOpen16, { className: FilesBody_module_css_default.icon }) : (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconFolderClose16, { className: FilesBody_module_css_default.icon }), (0, react_jsx_runtime.jsx)("span", {
514
+ children: [expanded ? (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconFolderOpenRegular, { className: FilesBody_module_css_default.icon }) : (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconFolderCloseRegular, { className: FilesBody_module_css_default.icon }), (0, react_jsx_runtime.jsx)("span", {
337
515
  className: FilesBody_module_css_default.name,
338
516
  children: entry.name
339
517
  })]
@@ -398,6 +576,11 @@ window.__ModuleLoader__.load({
398
576
  });
399
577
  const entries = orderEntries(level.level.entries);
400
578
  return (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
579
+ level.failure !== void 0 && (0, react_jsx_runtime.jsx)("li", {
580
+ className: FilesBody_module_css_default.note,
581
+ "data-files-row": "failed",
582
+ children: failureLine(t, level.failure)
583
+ }),
401
584
  entries.length === 0 && (0, react_jsx_runtime.jsx)("li", {
402
585
  className: FilesBody_module_css_default.note,
403
586
  "data-files-row": "empty",
@@ -416,16 +599,13 @@ window.__ModuleLoader__.load({
416
599
  ] });
417
600
  }
418
601
  /** The file tree's body: the workspace root and whatever the reader has opened under it. */
419
- function FilesBody({ useTabInfo, sessionId, useSessions, useStore, actions, start, load, toggle, t }) {
602
+ function FilesBody({ useTabInfo, sessionId, useSessions, useStore, actions, start, refresh, setAutoRefresh, toggle, t }) {
420
603
  const { tab } = useTabInfo();
421
604
  const { signal, actions: tabActions } = tab;
422
605
  const cwd = useSessions((sessions) => sessions.byId[sessionId]?.cwd);
423
606
  const state = useStore((store) => store.byTab[tab.id]);
424
- const pathRef = (0, react.useRef)(null);
425
- const pathTextRef = (0, react.useRef)(null);
426
607
  const bodyRef = (0, react.useRef)(null);
427
608
  const scrollTopRef = (0, react.useRef)(0);
428
- usePathClipped(pathRef, pathTextRef, state?.root);
429
609
  const seeded = state !== void 0;
430
610
  (0, react.useLayoutEffect)(() => {
431
611
  const body = bodyRef.current;
@@ -463,8 +643,8 @@ window.__ModuleLoader__.load({
463
643
  if (state === void 0) return null;
464
644
  const tree = {
465
645
  state,
466
- onToggle: (path) => {
467
- toggle(tab.id, path, state.levels[path] !== void 0, signal);
646
+ onToggle: (parent, path) => {
647
+ toggle(tab.id, parent, path, state.expanded, signal);
468
648
  },
469
649
  onOpen: (path) => {
470
650
  tabActions.openResource(fileAddressFor(sessionId, state.root, path));
@@ -472,41 +652,45 @@ window.__ModuleLoader__.load({
472
652
  t
473
653
  };
474
654
  const reload = () => {
475
- actions.reset(tab.id);
476
- for (const path of state.expanded) load(tab.id, path, signal);
655
+ refresh(tab.id);
477
656
  };
478
- const { directory, name } = pathPartsOf(state.root);
479
657
  return (0, react_jsx_runtime.jsxs)("div", {
480
658
  className: FilesBody_module_css_default.root,
481
659
  "data-files-state": "tree",
482
660
  "data-files-root": state.root,
483
661
  children: [(0, react_jsx_runtime.jsxs)("div", {
484
662
  className: FilesBody_module_css_default.header,
485
- children: [(0, react_jsx_runtime.jsx)("div", {
486
- ref: pathRef,
487
- className: FilesBody_module_css_default.path,
488
- title: state.root,
489
- "data-files-path": true,
490
- children: (0, react_jsx_runtime.jsxs)("span", {
491
- ref: pathTextRef,
492
- className: FilesBody_module_css_default.pathText,
493
- children: [directory !== "" && (0, react_jsx_runtime.jsx)("span", {
494
- className: FilesBody_module_css_default.pathDirectory,
495
- children: directory
496
- }), (0, react_jsx_runtime.jsx)("span", {
497
- className: FilesBody_module_css_default.pathName,
498
- children: name
499
- })]
663
+ children: [
664
+ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.PathLabel, {
665
+ path: state.root,
666
+ className: FilesBody_module_css_default.path,
667
+ "data-files-path": true
668
+ }),
669
+ (0, react_jsx_runtime.jsx)("span", {
670
+ hidden: true,
671
+ children: (0, react_jsx_runtime.jsx)("button", {
672
+ type: "button",
673
+ className: FilesBody_module_css_default.tool,
674
+ "aria-label": t("autoRefresh"),
675
+ "aria-pressed": state.autoRefresh,
676
+ "data-files-auto-refresh": true,
677
+ title: t(state.autoRefresh ? "autoRefresh.disable" : "autoRefresh.enable"),
678
+ onClick: () => {
679
+ setAutoRefresh(tab.id, !state.autoRefresh);
680
+ },
681
+ children: state.autoRefresh ? (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconPauseOutlineRegular, {}) : (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconPlayOutlineRegular, {})
682
+ })
683
+ }),
684
+ (0, react_jsx_runtime.jsx)("button", {
685
+ type: "button",
686
+ className: FilesBody_module_css_default.tool,
687
+ "aria-label": t("reload"),
688
+ title: t("reload"),
689
+ "data-files-reload": true,
690
+ onClick: reload,
691
+ children: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconRefreshOutlineRegular, {})
500
692
  })
501
- }), (0, react_jsx_runtime.jsx)("button", {
502
- type: "button",
503
- className: FilesBody_module_css_default.tool,
504
- "aria-label": t("reload"),
505
- title: t("reload"),
506
- "data-files-reload": true,
507
- onClick: reload,
508
- children: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconRefreshOutline16, {})
509
- })]
693
+ ]
510
694
  }), (0, react_jsx_runtime.jsx)("div", {
511
695
  ref: bodyRef,
512
696
  className: FilesBody_module_css_default.body,
@@ -551,6 +735,9 @@ window.__ModuleLoader__.load({
551
735
  truncated: "条目太多,只显示了一部分。",
552
736
  noWorkspace: "这个会话没有工作区目录。",
553
737
  reload: "重新读取",
738
+ autoRefresh: "自动刷新",
739
+ "autoRefresh.enable": "开启自动刷新",
740
+ "autoRefresh.disable": "关闭自动刷新",
554
741
  "entry.other": "这不是文件或目录,没法打开。",
555
742
  "error.notFound": "这个目录不在了。可能已被移动或删除。",
556
743
  "error.outsideWorkspace": "这个目录在工作区之外,侧栏不会读取它。",
@@ -567,6 +754,9 @@ window.__ModuleLoader__.load({
567
754
  truncated: "Too many entries, showing only some of them.",
568
755
  noWorkspace: "This session has no workspace directory.",
569
756
  reload: "Reload",
757
+ autoRefresh: "Auto refresh",
758
+ "autoRefresh.enable": "Enable auto refresh",
759
+ "autoRefresh.disable": "Disable auto refresh",
570
760
  "entry.other": "Not a file or a directory, so it cannot be opened.",
571
761
  "error.notFound": "That directory is gone. It may have been moved or deleted.",
572
762
  "error.outsideWorkspace": "That directory is outside the workspace, so the sidebar will not read it.",
@@ -610,6 +800,9 @@ window.__ModuleLoader__.load({
610
800
  return (0, _deepseek_ai_dsh_client_store.defineStore)({
611
801
  init: () => ({ byTab: {} }),
612
802
  actions: {
803
+ autoRefresh: (d, tabId, enabled) => {
804
+ bucket(d, tabId).autoRefresh = enabled;
805
+ },
613
806
  /**
614
807
  * Seed one tab's tree at its workspace root, with the root expanded.
615
808
  * @param d - draft state.
@@ -621,7 +814,8 @@ window.__ModuleLoader__.load({
621
814
  root,
622
815
  levels: {},
623
816
  expanded: [root],
624
- scrollTop: 0
817
+ scrollTop: 0,
818
+ autoRefresh: true
625
819
  };
626
820
  },
627
821
  /**
@@ -631,7 +825,8 @@ window.__ModuleLoader__.load({
631
825
  * @param path - absolute directory path.
632
826
  */
633
827
  loading: (d, tabId, path) => {
634
- bucket(d, tabId).levels[path] = { kind: "loading" };
828
+ const state = bucket(d, tabId);
829
+ if (state.levels[path]?.kind !== "ready") state.levels[path] = { kind: "loading" };
635
830
  },
636
831
  /**
637
832
  * Record one directory's contents.
@@ -641,7 +836,18 @@ window.__ModuleLoader__.load({
641
836
  * @param level - the listing to show under it.
642
837
  */
643
838
  loaded: (d, tabId, path, level) => {
644
- bucket(d, tabId).levels[path] = {
839
+ const state = bucket(d, tabId);
840
+ const previous = state.levels[path];
841
+ if (previous?.kind === "ready") {
842
+ const directories = new Set(level.entries.filter((entry) => entry.type === "directory").map((entry) => entry.name));
843
+ for (const entry of previous.level.entries) {
844
+ if (entry.type !== "directory" || directories.has(entry.name)) continue;
845
+ const removed = `${path.replace(/[/\\]+$/, "")}/${entry.name}`;
846
+ state.expanded = state.expanded.filter((value) => value !== removed && !value.startsWith(`${removed}/`));
847
+ state.levels = Object.fromEntries(Object.entries(state.levels).filter(([key]) => key !== removed && !key.startsWith(`${removed}/`)));
848
+ }
849
+ }
850
+ state.levels[path] = {
645
851
  kind: "ready",
646
852
  level
647
853
  };
@@ -654,7 +860,11 @@ window.__ModuleLoader__.load({
654
860
  * @param failure - the settled Remote failure.
655
861
  */
656
862
  failed: (d, tabId, path, failure) => {
657
- bucket(d, tabId).levels[path] = {
863
+ const level = bucket(d, tabId).levels[path];
864
+ bucket(d, tabId).levels[path] = level?.kind === "ready" ? {
865
+ ...level,
866
+ failure
867
+ } : {
658
868
  kind: "failed",
659
869
  failure
660
870
  };
@@ -731,7 +941,7 @@ window.__ModuleLoader__.load({
731
941
  en
732
942
  }), "ui-sidebar-files: dictionaries");
733
943
  const store = createFilesStore();
734
- const inject = filesFace(createList(ctx.remote));
944
+ const inject = filesFace(createList(ctx.remote), createWatch(ctx.remote));
735
945
  ctx.effect(() => ctx.slots.inject("sidebar.right.pane.tab", () => ctx.slots.register({
736
946
  name: "sidebar.right.pane.tab",
737
947
  key: FILES_ID,
@@ -22,5 +22,5 @@ export declare function orderEntries(entries: readonly WorkspaceDirectoryEntry[]
22
22
  */
23
23
  export declare function failureLine(t: TranslateNS<'sidebarFiles'>, failure: RemoteFailure): string;
24
24
  /** The file tree's body: the workspace root and whatever the reader has opened under it. */
25
- export declare function FilesBody({ useTabInfo, sessionId, useSessions, useStore, actions, start, load, toggle, t, }: FilesBodyProps): ReactNode;
25
+ export declare function FilesBody({ useTabInfo, sessionId, useSessions, useStore, actions, start, refresh, setAutoRefresh, toggle, t, }: FilesBodyProps): ReactNode;
26
26
  //# sourceMappingURL=FilesBody.d.ts.map
@@ -0,0 +1,63 @@
1
+ /** Watch and read ownership for the Files panel's expanded directory tree. */
2
+ import type { DirLevel } from './store.ts';
3
+ /** One open directory and its active child nodes; cached view preferences live in the store. */
4
+ export declare class DirectoryNode {
5
+ readonly path: string;
6
+ private readonly load;
7
+ private readonly watch;
8
+ private readonly failed;
9
+ private restore;
10
+ /** Open direct-child directories, keyed by absolute path. */
11
+ readonly children: Map<string, DirectoryNode>;
12
+ private readonly controller;
13
+ private readonly signal;
14
+ private task;
15
+ private reading;
16
+ private dirty;
17
+ private initialized;
18
+ private automatic;
19
+ constructor(path: string, load: (path: string, signal: AbortSignal) => Promise<DirLevel | undefined>, watch: (path: string, signal: AbortSignal) => AsyncIterable<'ready' | 'change'>, failed: (path: string, error: unknown) => void, lifetime: AbortSignal, restore?: readonly string[]);
20
+ /**
21
+ * Start observation once; readiness triggers the initial listing.
22
+ * @returns this node.
23
+ */
24
+ open(): this;
25
+ /**
26
+ * Find an active node in this subtree.
27
+ * @param path - absolute directory path.
28
+ * @returns the matching node, or undefined when that directory is closed.
29
+ */
30
+ find(path: string): DirectoryNode | undefined;
31
+ /**
32
+ * Update the expansion preferences used by pending directory listings.
33
+ * @param expanded - latest expansion preferences from the store.
34
+ */
35
+ setExpanded(expanded: readonly string[]): void;
36
+ /**
37
+ * Open a direct child using this node's lifetime and automatic-refresh setting.
38
+ * @param path - absolute direct-child directory path.
39
+ * @param restore - descendant expansion preferences to restore after listing.
40
+ * @returns the active child, or undefined after cancellation.
41
+ */
42
+ expand(path: string, restore?: readonly string[]): DirectoryNode | undefined;
43
+ /**
44
+ * Remove a child and its pending restoration preferences.
45
+ * @param path - absolute direct-child directory path.
46
+ * @returns once the child subtree's reads and watches have ended.
47
+ */
48
+ collapse(path: string): Promise<void>;
49
+ /**
50
+ * Control subtree rereads without ending subscriptions.
51
+ * @param enabled - refresh dirty nodes automatically.
52
+ */
53
+ setAutomatic(enabled: boolean): void;
54
+ /** Queue a reread, coalescing with active work. @returns completion of the active read and its coalesced rereads. */
55
+ refresh(): Promise<void>;
56
+ /** Refresh this node and its open descendants. @returns once their listings settle. */
57
+ refreshTree(): Promise<void>;
58
+ /** Cancel the active subtree. @returns once all owned reads and watches have ended. */
59
+ close(): Promise<void>;
60
+ private follow;
61
+ private read;
62
+ }
63
+ //# sourceMappingURL=directory-node.d.ts.map
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The tree's asynchronous half: listing directories into the store.
3
3
  *
4
- * The component never awaits anything. It calls `start` / `load` / `toggle`, and
4
+ * The component never awaits anything. It calls `start` / `refresh` / `toggle`, and
5
5
  * this face performs the listing and writes the outcome through the store's own
6
6
  * actions — the Slot-standard `inject` shape, so the session id is resolved by
7
7
  * the framework and the write set stays the store's.
@@ -23,6 +23,20 @@ import type { BoundActions } from '@deepseek-ai/dsh-client-store';
23
23
  import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit';
24
24
  import type { SessionId } from '@deepseek-ai/dsh-session/types';
25
25
  import type { DirLevel, createFilesStore } from './store.ts';
26
+ /**
27
+ * Observe one directory without recursively watching its descendants.
28
+ * @param sessionId - Session owning the directory tree.
29
+ * @param path - absolute directory path.
30
+ * @param signal - node lifetime.
31
+ * @returns readiness and invalidation notifications.
32
+ */
33
+ export type WatchWorkspaceDirectory = (sessionId: SessionId, path: string, signal: AbortSignal) => AsyncIterable<'ready' | 'change'>;
34
+ /**
35
+ * Bind directory observation to the Remote stream supervisor.
36
+ * @param remote - Client Remote with workspace file streams.
37
+ * @returns a watcher that awaits stream disposal when its node ends.
38
+ */
39
+ export declare function createWatch(remote: ClientRemote): WatchWorkspaceDirectory;
26
40
  /**
27
41
  * One directory listing, bound to a Remote face.
28
42
  *
@@ -56,6 +70,10 @@ export declare function createList(remote: WorkspaceFilesListRemote): ListWorksp
56
70
  export declare function childPath(parent: string, name: string): string;
57
71
  /** The tree's injected business face, as the body receives it. */
58
72
  export interface FilesInjected {
73
+ /** Refresh the open directory tree. @param tabId - owning tab. */
74
+ readonly refresh: (tabId: TabId) => void;
75
+ /** Control automatic rereads without closing watches. @param tabId - owning tab. @param enabled - automatic-refresh setting. */
76
+ readonly setAutoRefresh: (tabId: TabId, enabled: boolean) => void;
59
77
  /**
60
78
  * Seed this tab's tree and list its root.
61
79
  * @param tabId - the tab being drawn.
@@ -71,18 +89,20 @@ export interface FilesInjected {
71
89
  */
72
90
  readonly load: (tabId: TabId, path: string, signal: AbortSignal) => void;
73
91
  /**
74
- * Open or collapse one directory, listing it the first time it opens.
92
+ * Open or collapse one directory, retaining intent during ancestor restoration.
75
93
  * @param tabId - the tab being drawn.
94
+ * @param parentPath - the listed parent directory's exact tree key.
76
95
  * @param path - absolute directory path.
77
- * @param loaded - whether this level already has state.
96
+ * @param expanded - current expansion preferences, including descendants to restore.
78
97
  * @param signal - the tab record's lifetime.
79
98
  */
80
- readonly toggle: (tabId: TabId, path: string, loaded: boolean, signal: AbortSignal) => void;
99
+ readonly toggle: (tabId: TabId, parentPath: string, path: string, expanded: readonly string[], signal: AbortSignal) => void;
81
100
  }
82
101
  /**
83
102
  * Bind the tree's face to one directory listing.
84
103
  * @param list - the bound `workspaceFiles.list` call.
104
+ * @param watch - target-scoped directory observation.
85
105
  * @returns the Slot `inject` factory: session and bound actions in, face out.
86
106
  */
87
- export declare function filesFace(list: ListWorkspaceDirectory): (sessionId: SessionId, actions: BoundActions<ReturnType<typeof createFilesStore>>) => FilesInjected;
107
+ export declare function filesFace(list: ListWorkspaceDirectory, watch: WatchWorkspaceDirectory): (sessionId: SessionId, actions: BoundActions<ReturnType<typeof createFilesStore>>) => FilesInjected;
88
108
  //# sourceMappingURL=face.d.ts.map
@@ -14,6 +14,9 @@ export declare const zh: {
14
14
  truncated: string;
15
15
  noWorkspace: string;
16
16
  reload: string;
17
+ autoRefresh: string;
18
+ 'autoRefresh.enable': string;
19
+ 'autoRefresh.disable': string;
17
20
  'entry.other': string;
18
21
  'error.notFound': string;
19
22
  'error.outsideWorkspace': string;
@@ -32,6 +35,9 @@ export declare const en: {
32
35
  truncated: string;
33
36
  noWorkspace: string;
34
37
  reload: string;
38
+ autoRefresh: string;
39
+ 'autoRefresh.enable': string;
40
+ 'autoRefresh.disable': string;
35
41
  'entry.other': string;
36
42
  'error.notFound': string;
37
43
  'error.outsideWorkspace': string;
@@ -32,6 +32,7 @@ export type LevelState = {
32
32
  } | {
33
33
  readonly kind: 'ready';
34
34
  readonly level: DirLevel;
35
+ readonly failure?: RemoteFailure;
35
36
  } | {
36
37
  readonly kind: 'failed';
37
38
  readonly failure: RemoteFailure;
@@ -43,6 +44,7 @@ export type LevelState = {
43
44
  * the Host reports it, and a child is the parent joined with the entry name.
44
45
  */
45
46
  export interface FilesTabState {
47
+ autoRefresh: boolean;
46
48
  /** Absolute path of the workspace root this tree is rooted at. */
47
49
  root: string;
48
50
  /** Level state by absolute directory path; a path absent here was never asked for. */
@@ -58,6 +60,7 @@ export interface FilesState {
58
60
  }
59
61
  /** The tree store's write set; every action names the tab it writes. */
60
62
  type FilesActions = {
63
+ autoRefresh: (draft: FilesState, tabId: TabId, enabled: boolean) => void;
61
64
  start: (draft: FilesState, tabId: TabId, root: string) => void;
62
65
  loading: (draft: FilesState, tabId: TabId, path: string) => void;
63
66
  loaded: (draft: FilesState, tabId: TabId, path: string, level: DirLevel) => void;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-client-ui-sidebar-files",
3
3
  "description": "Workspace file tree tab type for the right Sidebar: lazy directory listing over the workspaceFiles Remote namespace, opening files into the Sidebar",
4
- "version": "0.1.6-alpha.2",
4
+ "version": "0.1.7-alpha.2",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "license": "MIT",
40
40
  "peerDependencies": {
41
- "@deepseek-ai/cordis": "^4.0.2"
41
+ "@deepseek-ai/cordis": "~4.0.4"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@testing-library/react": "^16.1.0",
@@ -47,20 +47,21 @@
47
47
  "react": "^18.2.0",
48
48
  "clsx": "^2.0.0",
49
49
  "react-dom": "^18.2.0",
50
- "@deepseek-ai/cordis": "^4.0.2",
51
- "@deepseek-ai/dsh-api-remotes": "^0.1.6-alpha.2",
52
- "@deepseek-ai/dsh-client-store": "^0.1.6-alpha.2",
53
- "@deepseek-ai/dsh-client-test-runtime": "^0.1.6-alpha.2",
54
- "@deepseek-ai/dsh-api-workspace-files": "^0.1.6-alpha.2",
55
- "@deepseek-ai/dsh-client-ui-dockkit": "^0.1.6-alpha.2",
56
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.6-alpha.2",
57
- "@deepseek-ai/dsh-client-locale": "^0.1.6-alpha.2",
58
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.6-alpha.2",
59
- "@deepseek-ai/dsh-client-ui-session": "^0.1.6-alpha.2",
60
- "@deepseek-ai/dsh-client-ui-sidebar-right": "^0.1.6-alpha.2",
61
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.6-alpha.2",
62
- "@deepseek-ai/dsh-util-workspace-path": "^0.1.6-alpha.2",
63
- "@deepseek-ai/dsh-session": "^0.1.6-alpha.2"
50
+ "@deepseek-ai/cordis": "~4.0.4",
51
+ "@deepseek-ai/dsh-api-workspace-files": "0.1.7-alpha.2",
52
+ "@deepseek-ai/dsh-client-locale": "0.1.7-alpha.2",
53
+ "@deepseek-ai/dsh-client-store": "0.1.7-alpha.2",
54
+ "@deepseek-ai/dsh-client-test-runtime": "0.1.7-alpha.2",
55
+ "@deepseek-ai/dsh-client-ui-dockkit": "0.1.7-alpha.2",
56
+ "@deepseek-ai/dsh-api-remotes": "0.1.7-alpha.2",
57
+ "@deepseek-ai/dsh-client-ui-renderer": "0.1.7-alpha.2",
58
+ "@deepseek-ai/dsh-client-ui-session": "0.1.7-alpha.2",
59
+ "@deepseek-ai/dsh-client-ui-sidebar-right": "0.1.7-alpha.2",
60
+ "@deepseek-ai/dsh-client-ui-slots": "0.1.7-alpha.2",
61
+ "@deepseek-ai/dsh-session": "0.1.7-alpha.2",
62
+ "@deepseek-ai/dsh-typert-protocol": "0.1.7-alpha.2",
63
+ "@deepseek-ai/dsh-util-workspace-path": "0.1.7-alpha.2",
64
+ "@deepseek-ai/dsh-client-ui-primitives": "0.1.7-alpha.2"
64
65
  },
65
66
  "files": [
66
67
  "lib/index.js",