@windypro-rourou/dsh-code-studio 0.1.4 → 0.2.0

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.md CHANGED
@@ -5,15 +5,22 @@
5
5
  > conversation — then open, edit and save any workspace file without leaving
6
6
  > the browser.
7
7
 
8
- 在 DeepSeek Harness 的 Web 界面里,实时监视 Agent 对文件的每一次修改:**改动落盘的瞬间**,逐行 Diff(`+` 新增 / `−` 删除 / `~` 修改)自动浮现在你的会话旁;内置语法高亮编辑器,随时查看和修改工作区文件。**不用离开 DSH,Agent 改了哪里、改了什么,一目了然。**
8
+ 在 DeepSeek Harness 的 Web 界面里,实时监视 Agent 对文件的每一次修改:**改动落盘的瞬间**,逐行 Diff(`+` 新增 / `−` 删除 / `~` 修改)自动浮现在你的会话旁;内置语法高亮编辑器,随时查看和修改工作区文件。**不用离开 DSH,Agent 改了哪里、改了什么、想还原就还原。**
9
9
 
10
10
  ## 核心价值
11
11
 
12
12
  - 🔭 **实时文件修改监视**:基于会话工具事件直推,Agent 每次 write / edit 完成立即推送 Diff —— 不依赖文件系统轮询,不漏报、不延迟。
13
- - 🗂 **多工作区覆盖**:自动监视所有会话的工作区目录,Agent 在任意目录改文件都能捕获。
14
- - 🔒 **按会话隔离**:每个 Code Studio 只响应当前会话的修改,切换会话互不干扰。
15
- - 🧠 **会话状态记忆**:变更列表、打开的标签、面板宽度按会话分别保留(本地持久化)。
13
+ - 🗂 **多工作区覆盖 + 切换器**:自动监视所有会话的工作区目录,「文件」页签顶部可随时切换工作区根目录(按会话记忆)。
14
+ - 🔒 **按会话隔离**:每个 Code Studio 只响应当前会话的修改,切换会话互不干扰;未查看变更数徽标按会话分别计数。
15
+ - 🧠 **会话状态记忆**:变更列表、打开的标签、面板宽度、过滤条件、工作区选择按会话分别保留(本地持久化)。
16
+ - ↩️ **一键还原(Revert)**:每个变更条目带「还原」按钮 —— 撤销 Agent 在本会话内对该文件的全部修改(还原点取自 Agent 首次写入前的内容快照;新建文件还原为删除;检测到外部修改会提示冲突)。
17
+ - 🕘 **修改历史时间线**:每个文件的 Agent / 用户 / 还原事件按时间倒序回放,任意版本可展开查看 Diff。
18
+ - 💬 **发送到会话**:把当前打开的文件内容一键发给当前会话的 Agent(不可用时自动退化为复制到剪贴板)。
16
19
  - 📝 **语法高亮编辑器**:类编辑器体验,行号、光标、滚动同步;`Ctrl+S` 保存,`Ctrl+D` 查看 Diff。
20
+ - ⚡ **大文件虚拟滚动**:Diff 视图窗口化渲染 + 差异过大截断提示,万行级文件流畅滚动。
21
+ - 🔁 **SSE 断线自愈**:事件带序号 + 服务端环形缓冲,断线重连自动补发丢失的变更(Last-Event-ID)。
22
+ - 🔍 **变更过滤搜索**:按路径关键词 + 文件类型(代码/文档/配置/其他)过滤变更列表。
23
+ - ⌨️ **键盘快捷键**:`Ctrl+Alt+C` 开关面板,`Alt+↓/↑` 在变更之间跳转。
17
24
  - 📐 **可拖拽面板**:宽度自由调整并记住;UI 与 DSH 主题完全一致(`--dsw-alias-*` 令牌)。
18
25
 
19
26
  ## 安装
@@ -33,16 +40,20 @@ dsh plugin --profile web add github:WindyPro-rourou/dsh-code-studio
33
40
 
34
41
  ## 使用
35
42
 
36
- 1. 打开 Code Studio(右侧面板,可拖左缘调宽)。
37
- 2. 让 Agent 修改代码 —— 面板**自动浮现**该文件的逐行 Diff:行号前 `+`(绿)/ `−`(红)/ `~`(黄),未改动大段自动折叠。
38
- 3. 「文件」页签:浏览工作区、打开文件、直接编辑保存(`Ctrl+S`)。
43
+ 1. 打开 Code Studio(右侧面板,可拖左缘调宽;`Ctrl+Alt+C` 快捷开关)。
44
+ 2. 让 Agent 修改代码 —— 面板**自动浮现**该文件的逐行 Diff:行号前 `+`(绿)/ `−`(红)/ `~`(黄),未改动大段自动折叠;面板关闭时侧边栏入口显示未查看变更数徽标。
45
+ 3. 对不满意的修改点「还原」,一键回到 Agent 动手前;点「历史」回放该文件的每一次改动。
46
+ 4. 「文件」页签:切换工作区、浏览文件树、打开文件直接编辑保存(`Ctrl+S`),或「发送到会话」让 Agent 接着改。
47
+ 5. 变更多了?顶部搜索框 + 类型筛选快速定位;`Alt+↓/↑` 在变更间跳转。
39
48
 
40
49
  ## 技术说明
41
50
 
42
- - **Host**(`lib/index.js`):监听 `session/event` 工具事件(`tool/call` ↔ `tool/result` 配对),对写文件工具即时读取并推送 before/after;递归文件监视 + mtime 轮询兜底;`/api/code-studio/*` REST + SSE
43
- - **Client**(`lib/client.js`):浏览器 bundle,仅依赖 react;LCS 行级 Diff 引擎、语法高亮、按会话状态管理。
51
+ - **Host**(`lib/index.js`):监听 `session/event` 工具事件(`tool/call` ↔ `tool/result` 配对),在 `tool/call` 阶段抓取还原点快照、在 `tool/result` 阶段即时读取并推送 before/after;递归文件监视 + mtime 轮询兜底;SSE 事件带自增 `id` 并保留 200 条环形缓冲用于 `Last-Event-ID` 断线补发;`/api/code-studio/*` REST + SSE(含 `/revert`、`/workspaces`、`/history`)。
52
+ - **Client**(`lib/client.js`):浏览器 bundle,仅依赖 react;LCS 行级 Diff 引擎(大文件自动退化贪心算法)、窗口化虚拟滚动、语法高亮、按会话状态管理、未读徽标。
53
+ - **自测**:`node scripts/selftest.mjs`(host 逻辑 21 项断言)、`node scripts/smoke-client.mjs`(浏览器 bundle 冒烟)。
44
54
 
45
55
  ## 已知限制
46
56
 
47
- - 单文件 > 512KB 不读取内容(防浏览器卡顿)。
48
- - 通过 bash/pwsh 等非文件工具写入的变更依赖文件监视兜底(仍会捕获,可能有少量延迟)。
57
+ - 单文件 > 512KB 不读取内容(防浏览器卡顿);>100KB 的历史事件内容会截断保存(SSE 推送仍为全量)。
58
+ - 通过 bash/pwsh 等非文件工具写入的变更依赖文件监视兜底(仍会捕获,可能有少量延迟),且仅显示 Agent 声明过的文件。
59
+ - 还原点保存在内存中,服务重启后丢失(新会话的 Agent 修改会重新建立还原点)。
package/lib/client.js CHANGED
@@ -151,6 +151,10 @@ window.__ModuleLoader__.load({
151
151
  const i = name.lastIndexOf(".");
152
152
  return i >= 0 ? name.slice(i + 1).toLowerCase() : "";
153
153
  }
154
+ const CODE_EXTS = new Set(["js","jsx","ts","tsx","mjs","cjs","py","go","rs","java","kt","kts","swift","c","h","cpp","hpp","cc","cs","php","rb","sh","ps1","lua","pl","vue","svelte","html","css","scss","less","sql","proto","dart","ex","exs","ml","fs","scala","zig","asm"]);
155
+ const DOC_EXTS = new Set(["md","mdx","txt","rst","adoc"]);
156
+ const CONF_EXTS = new Set(["json","jsonc","yml","yaml","toml","ini","conf","cfg","xml","env"]);
157
+ function extType(ext) { return CODE_EXTS.has(ext) ? "code" : DOC_EXTS.has(ext) ? "doc" : CONF_EXTS.has(ext) ? "conf" : "other"; }
154
158
  function Highlighted({ text, path }) {
155
159
  const toks = React.useMemo(() => tokenizeLine(text), [text]);
156
160
  return el("span", null,
@@ -175,6 +179,17 @@ window.__ModuleLoader__.load({
175
179
  }
176
180
  function subscribeSession(fn) { sessListeners.add(fn); return () => sessListeners.delete(fn); }
177
181
 
182
+ // ============================ module state ============================
183
+ let ctxRef = null; // plugin ctx (set in apply) for send-to-chat
184
+ let lastSeq = 0; // last SSE seq seen (dedupe + replay recovery)
185
+ const unseen = new Map(); // sessionKey -> unseen change count (sidebar badge)
186
+ const badgeListeners = new Set();
187
+ function bumpUnseen(key, by) { unseen.set(key, (unseen.get(key) ?? 0) + by); for (const fn of badgeListeners) fn(); }
188
+ function clearUnseen(key) { if (key != null && unseen.delete(key)) for (const fn of badgeListeners) fn(); }
189
+ function unseenCount(key) { return unseen.get(key) ?? 0; }
190
+ function subscribeBadge(fn) { badgeListeners.add(fn); return () => badgeListeners.delete(fn); }
191
+ function badgeKey(sessionId) { return sessionId ?? "__unknown"; }
192
+
178
193
  // per-session UI state memory (changes, tabs, width), persisted locally
179
194
  const SESS_STATE_KEY = "cs.sessionStates.v1";
180
195
  function loadSessionStates() {
@@ -217,8 +232,8 @@ window.__ModuleLoader__.load({
217
232
  ".cs-bad-del{background:var(--dsw-alias-state-error-secondary,rgba(239,68,68,.14));color:var(--dsw-alias-state-error-primary,#ef4444)}",
218
233
  ".cs-plus{color:var(--dsw-alias-state-success-primary,#22c55e);flex:none;font-size:11px}",
219
234
  ".cs-minus{color:var(--dsw-alias-state-error-primary,#ef4444);flex:none;font-size:11px}",
220
- ".cs-chg-body{border-top:1px solid var(--dsw-alias-border-l1,#262a33)}",
221
- ".cs-dline{display:flex;font-family:var(--cs-mono);font-size:11.5px;line-height:1.5;min-width:max-content}",
235
+ ".cs-chg-body{border-top:1px solid var(--dsw-alias-border-l1,#262a33);max-height:55vh;overflow:auto}",
236
+ ".cs-dline{display:flex;font-family:var(--cs-mono);font-size:11.5px;line-height:17px;height:17px;min-width:max-content;overflow:hidden}",
222
237
  ".cs-dg{flex:none;display:flex;user-select:none}",
223
238
  ".cs-dsym{width:16px;flex:none;text-align:center;font-weight:700}",
224
239
  ".cs-dnum{min-width:34px;flex:none;text-align:right;padding-right:8px;color:var(--dsw-alias-label-quaternary,#565e6e);font-size:10.5px}",
@@ -226,7 +241,7 @@ window.__ModuleLoader__.load({
226
241
  ".cs-dline.cs-add{background:var(--dsw-alias-state-success-secondary,rgba(34,197,94,.14))}.cs-dline.cs-add .cs-dsym{color:var(--dsw-alias-state-success-primary,#22c55e)}",
227
242
  ".cs-dline.cs-del{background:var(--dsw-alias-state-error-secondary,rgba(239,68,68,.13))}.cs-dline.cs-del .cs-dsym{color:var(--dsw-alias-state-error-primary,#ef4444)}.cs-dline.cs-del .cs-dtxt{text-decoration:line-through;text-decoration-color:rgba(239,68,68,.45)}",
228
243
  ".cs-dline.cs-mod{background:var(--dsw-alias-state-warn-secondary,rgba(234,179,8,.12))}.cs-dline.cs-mod .cs-dsym{color:var(--dsw-alias-state-warn-primary,#eab308)}",
229
- ".cs-dgap{display:flex;align-items:center;gap:8px;padding:0 10px;height:22px;cursor:pointer;color:var(--dsw-alias-label-tertiary,#8b93a3);font-size:10.5px;user-select:none}",
244
+ ".cs-dgap{display:flex;align-items:center;gap:8px;padding:0 10px;height:17px;cursor:pointer;color:var(--dsw-alias-label-tertiary,#8b93a3);font-size:10.5px;user-select:none}",
230
245
  ".cs-dgap:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.05))}",
231
246
  ".cs-dgap .cs-dgbar{flex:1;height:1px;background:var(--dsw-alias-border-l1,#262a33)}",
232
247
  ".cs-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;color:var(--dsw-alias-label-tertiary,#8b93a3);font-size:12px;padding:24px}",
@@ -262,16 +277,18 @@ window.__ModuleLoader__.load({
262
277
  ".cs-status .cs-err{background:var(--dsw-alias-state-error-primary,#ef4444)}",
263
278
  ".cs-badge-f{position:fixed;right:16px;bottom:16px;z-index:40;display:flex;align-items:center;gap:8px;padding:8px 14px;border-radius:10px;background:var(--dsw-alias-bg-overlay,#1b2130);color:var(--dsw-alias-label-primary,#d7dde6);border:1px solid var(--dsw-alias-border-l1,#262a33);font-size:12px;cursor:pointer;box-shadow:0 8px 24px rgba(0,0,0,.3);animation:csSlideIn .2s ease-out}",
264
279
  ".cs-badge-f:hover{background:var(--dsw-alias-bg-layer-3,#222836)}",
265
- ".cs-badge-f .cs-bd{width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-state-business-primary,#2f6fed);flex:none}"
280
+ ".cs-badge-f .cs-bd{width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-state-business-primary,#2f6fed);flex:none}",".cs-badge-n{flex:none;min-width:16px;height:16px;padding:0 4px;border-radius:8px;background:var(--dsw-alias-state-error-primary,#ef4444);color:#fff;font-size:9.5px;font-weight:600;display:flex;align-items:center;justify-content:center;margin-left:auto;line-height:1}",".cs-filter{display:flex;gap:6px;padding:6px 8px;flex:none;border-bottom:1px solid var(--dsw-alias-border-l1,#262a33);background:var(--dsw-alias-bg-layer-2,#1a1f2b)}",".cs-filter input{flex:1;min-width:0;background:var(--dsw-alias-bg-base,#0f1115);border:1px solid var(--dsw-alias-border-l1,#262a33);color:var(--dsw-alias-label-primary,#d7dde6);border-radius:6px;padding:3px 8px;font-size:11px;outline:none}",".cs-filter select{flex:none;background:var(--dsw-alias-bg-base,#0f1115);border:1px solid var(--dsw-alias-border-l1,#262a33);color:var(--dsw-alias-label-secondary,#b6bdc9);border-radius:6px;padding:3px 4px;font-size:11px;outline:none}",".cs-rev{flex:none;background:transparent;border:1px solid var(--dsw-alias-border-l1,#262a33);color:var(--dsw-alias-state-warn-primary,#eab308);border-radius:6px;padding:1px 7px;font-size:10.5px;cursor:pointer;font-family:var(--cs-mono)}",".cs-rev:hover{background:var(--dsw-alias-state-warn-secondary,rgba(234,179,8,.12))}",".cs-rev:disabled{opacity:.4;cursor:default}",".cs-hist-back{display:flex;align-items:center;gap:8px;padding:6px 10px;flex:none;border-bottom:1px solid var(--dsw-alias-border-l1,#262a33);font-size:11px;color:var(--dsw-alias-label-tertiary,#8b93a3);background:var(--dsw-alias-bg-layer-2,#1a1f2b)}",".cs-hist-back .cs-fp{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--cs-mono)}",".cs-hist-item{border:1px solid var(--dsw-alias-border-l1,#262a33);border-radius:8px;margin:0 2px 6px;overflow:hidden;background:var(--dsw-alias-bg-base,#0f1115)}",".cs-hist-row{display:flex;align-items:center;gap:8px;padding:5px 8px;cursor:pointer;font-family:var(--cs-mono);font-size:10.5px;color:var(--dsw-alias-label-secondary,#b6bdc9)}",".cs-hist-row:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.05))}",".cs-hist-tag{font-size:9.5px;border-radius:4px;padding:0 5px;flex:none}",".cs-hist-tag.cs-src{background:var(--dsw-alias-state-business-secondary,rgba(47,111,237,.16));color:var(--dsw-alias-state-business-primary,#2f6fed)}",".cs-hist-tag.cs-usr{background:var(--dsw-alias-state-warn-secondary,rgba(234,179,8,.12));color:var(--dsw-alias-state-warn-primary,#eab308)}",".cs-hist-tag.cs-rev2{background:var(--dsw-alias-state-success-secondary,rgba(34,197,94,.16));color:var(--dsw-alias-state-success-primary,#22c55e)}",".cs-ws-bar{display:flex;align-items:center;gap:6px;padding:5px 8px;border-bottom:1px solid var(--dsw-alias-border-l1,#262a33);flex:none;background:var(--dsw-alias-bg-layer-2,#1a1f2b)}",".cs-ws-bar select{flex:1;min-width:0;background:var(--dsw-alias-bg-base,#0f1115);border:1px solid var(--dsw-alias-border-l1,#262a33);color:var(--dsw-alias-label-secondary,#b6bdc9);border-radius:6px;padding:3px 6px;font-size:11px;outline:none}",".cs-ws-bar span{font-size:10px;color:var(--dsw-alias-label-quaternary,#565e6e);white-space:nowrap}",".cs-kbd{font-family:var(--cs-mono);font-size:10px;background:var(--dsw-alias-bg-mask-1,rgba(255,255,255,.05));border:1px solid var(--dsw-alias-border-l1,#262a33);border-radius:4px;padding:0 4px;color:var(--dsw-alias-label-tertiary,#8b93a3)}"
266
281
  ].join("\n");
267
282
 
268
283
  // ============================ diff view ============================
284
+ const ROW_H = 17; // must match .cs-dline/.cs-dgap CSS height
285
+ const MAX_DIFF_ROWS = 30000;
269
286
  function DiffRows({ before, after, path, expanded }) {
270
287
  const ops = React.useMemo(() => mergeModified(lineDiff(before, after)), [before, after]);
271
288
  const [open, setOpen] = React.useState(false);
272
289
  const showAll = expanded === true || open;
273
290
  const blocks = React.useMemo(() => buildBlocks(ops), [ops]);
274
- const rows = [];
291
+ const [scrollTop, setScrollTop] = React.useState(0);
275
292
  const rowOf = (op, key) => {
276
293
  const type = op.type;
277
294
  const sym = type === "add" ? "+" : type === "del" ? "−" : type === "mod" ? "~" : " ";
@@ -282,21 +299,34 @@ window.__ModuleLoader__.load({
282
299
  el("span", { className: "cs-dtxt" }, el(Highlighted, { text: op.text, path }))
283
300
  );
284
301
  };
285
- for (const block of blocks) {
286
- if (block.kind === "gap") {
287
- rows.push(el("div", { className: "cs-dgap", key: "g" + rows.length, onClick: () => setOpen(true) },
288
- el("span", null, "⋯ " + block.count + " 行未改动"),
289
- el("span", { className: "cs-dgbar" })
290
- ));
291
- } else {
292
- for (const op of block.ops) rows.push(rowOf(op, "r" + rows.length));
302
+ const rows = React.useMemo(() => {
303
+ const out = [];
304
+ for (const block of blocks) {
305
+ if (block.kind === "gap") {
306
+ out.push(el("div", { className: "cs-dgap", key: "g" + out.length, onClick: () => setOpen(true) },
307
+ el("span", null, "⋯ " + block.count + " 行未改动"),
308
+ el("span", { className: "cs-dgbar" })
309
+ ));
310
+ } else {
311
+ for (const op of block.ops) out.push(rowOf(op, "r" + out.length));
312
+ }
293
313
  }
294
- }
295
- if (showAll) {
296
- const all = ops.map((op, i) => rowOf(op, "e" + i));
297
- return el("div", null, all);
298
- }
299
- return el("div", null, rows);
314
+ if (showAll) {
315
+ const all = ops.map((op, i) => rowOf(op, "e" + i));
316
+ return all;
317
+ }
318
+ return out;
319
+ }, [blocks, ops, showAll]);
320
+ const total = Math.min(rows.length, MAX_DIFF_ROWS);
321
+ const onScroll = (e) => setScrollTop(e.currentTarget.scrollTop);
322
+ const start = Math.max(0, Math.floor(scrollTop / ROW_H) - 12);
323
+ const end = Math.min(total, Math.ceil((scrollTop + 420) / ROW_H) + 12);
324
+ return el("div", { className: "cs-chg-body", onScroll },
325
+ total < rows.length ? el("div", { className: "cs-dgap", key: "limit" }, "⋯ 差异过大,仅显示前 " + total + " 行") : null,
326
+ el("div", { key: "top", style: { height: start * ROW_H } }),
327
+ rows.slice(start, end),
328
+ el("div", { key: "bot", style: { height: (total - end) * ROW_H } })
329
+ );
300
330
  }
301
331
 
302
332
  // ============================ file tree ============================
@@ -364,7 +394,7 @@ window.__ModuleLoader__.load({
364
394
  }
365
395
 
366
396
  // ============================ editor (syntax highlighted overlay) ============================
367
- function Editor({ file, onEdit, onSave, onDiff }) {
397
+ function Editor({ file, onEdit, onSave, onDiff, onSend, onHistory }) {
368
398
  const gutterRef = React.useRef(null);
369
399
  const hlRef = React.useRef(null);
370
400
  const taRef = React.useRef(null);
@@ -390,6 +420,8 @@ window.__ModuleLoader__.load({
390
420
  el("b", { style: { color: "var(--dsw-alias-state-error-primary)" } }, "−" + stats.dels)
391
421
  ) : null,
392
422
  el("button", { className: "cs-btn", onClick: onDiff, title: "查看该文件的 Diff" }, "Diff"),
423
+ el("button", { className: "cs-btn", onClick: onHistory, title: "该文件的修改历史" }, "历史"),
424
+ el("button", { className: "cs-btn", onClick: onSend, disabled: file.content == null, title: "把文件内容发送给当前会话的 Agent" }, "发送到会话"),
393
425
  el("button", { className: "cs-btn cs-pri", onClick: onSave, disabled: !file.dirty || file.content == null }, "保存")
394
426
  ),
395
427
  el("div", { className: "cs-edit-wrap" },
@@ -428,6 +460,14 @@ window.__ModuleLoader__.load({
428
460
  } catch { /* ignore */ }
429
461
  return 400;
430
462
  });
463
+ const [reverting, setReverting] = React.useState(null);
464
+ const [histPath, setHistPath] = React.useState(null);
465
+ const [histData, setHistData] = React.useState([]);
466
+ const [histOpen, setHistOpen] = React.useState(null);
467
+ const [filter, setFilter] = React.useState("");
468
+ const [ftype, setFtype] = React.useState("all");
469
+ const [workspaces, setWorkspaces] = React.useState([]);
470
+ const [activeRoot, setActiveRoot] = React.useState(null);
431
471
  const dragRef = React.useRef(null);
432
472
  const changedSet = React.useMemo(() => new Set(changes.map((c) => c.path)), [changes]);
433
473
  // current session for per-session isolation
@@ -435,8 +475,8 @@ window.__ModuleLoader__.load({
435
475
  const sessIdRef = React.useRef(currentSessionId);
436
476
  // per-session UI memory: save current snapshot on switch, restore target's
437
477
  const sessStatesRef = React.useRef(loadSessionStates());
438
- const snapRef = React.useRef({ changes, expandedPath, editor, tab, width });
439
- snapRef.current = { changes, expandedPath, editor, tab, width };
478
+ const snapRef = React.useRef({ changes, expandedPath, editor, tab, width, filter, ftype, activeRoot });
479
+ snapRef.current = { changes, expandedPath, editor, tab, width, filter, ftype, activeRoot };
440
480
  React.useEffect(() => subscribeSession((v) => {
441
481
  const cur = sessIdRef.current;
442
482
  if (cur && cur !== v) {
@@ -452,12 +492,19 @@ window.__ModuleLoader__.load({
452
492
  setEditor(st.editor ?? null);
453
493
  setTab(st.tab ?? "changes");
454
494
  setWidth(st.width ?? 400);
495
+ setFilter(st.filter ?? "");
496
+ setFtype(st.ftype ?? "all");
497
+ setActiveRoot(st.activeRoot ?? null);
455
498
  } else {
456
499
  setChanges([]); setExpandedPath(null); setEditor(null); setTab("changes");
500
+ setFilter(""); setFtype("all"); setActiveRoot(null);
457
501
  }
458
502
  }), []);
459
503
 
460
- React.useEffect(() => subscribePanel(setOpen), []);
504
+ React.useEffect(() => subscribePanel((open) => {
505
+ setOpen(open);
506
+ if (open) { clearUnseen(badgeKey(sessIdRef.current)); }
507
+ }), []);
461
508
  React.useEffect(() => {
462
509
  const tagId = "@windypro-rourou/dsh-code-studio/styles";
463
510
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=\"" + tagId + "\"]") === null) {
@@ -469,7 +516,11 @@ window.__ModuleLoader__.load({
469
516
  }
470
517
  }, []);
471
518
  React.useEffect(() => {
472
- getRoot().then((d) => setRoot(d.root)).catch((e) => { setConn("err"); setConnText(String(e.message)); });
519
+ getRoot().then((d) => {
520
+ setRoot(d.root);
521
+ setActiveRoot((prev) => prev ?? d.root);
522
+ }).catch((e) => { setConn("err"); setConnText(String(e.message)); });
523
+ api("/workspaces").then((d) => setWorkspaces(d.workspaces ?? [])).catch(() => {});
473
524
  }, []);
474
525
 
475
526
  // SSE change feed + auto-appear
@@ -483,8 +534,11 @@ window.__ModuleLoader__.load({
483
534
  try {
484
535
  const ev = JSON.parse(msg.data);
485
536
  if (!ev || typeof ev.path !== "string") return;
486
- // per-session isolation: only react to changes owned by the active session
487
- // (or unattributed ones, treated as the current session's/user's own edits)
537
+ // dedupe across EventSource reconnect replays (Last-Event-ID)
538
+ if (typeof ev.seq === "number") {
539
+ if (ev.seq <= lastSeq) return;
540
+ lastSeq = ev.seq;
541
+ }
488
542
  // per-session isolation: only filter when we KNOW the active session.
489
543
  // If current is null, do not filter - otherwise edits would never appear.
490
544
  if (ev.sessionId && sessIdRef.current && ev.sessionId !== sessIdRef.current) return;
@@ -492,9 +546,10 @@ window.__ModuleLoader__.load({
492
546
  const name = ev.path.split(/[\\/]/).pop() || ev.path;
493
547
  setChanges((prev) => {
494
548
  const next = prev.filter((c) => c.path !== ev.path);
495
- next.unshift({ path: ev.path, name, before: ev.before, after: ev.after, deleted: !!ev.deleted, ts: ev.ts, sessionId: ev.sessionId });
549
+ next.unshift({ path: ev.path, name, before: ev.before, after: ev.after, deleted: !!ev.deleted, ts: ev.ts, seq: ev.seq, sessionId: ev.sessionId });
496
550
  return next.slice(0, 50);
497
551
  });
552
+ if (ev.source !== "revert" && !panel.open) bumpUnseen(badgeKey(ev.sessionId ?? sessIdRef.current), 1);
498
553
  setPanelOpen(true);
499
554
  setExpandedPath(ev.path);
500
555
  } catch { /* ignore */ }
@@ -504,9 +559,21 @@ window.__ModuleLoader__.load({
504
559
  return () => { if (source) source.close(); };
505
560
  }, []);
506
561
 
507
- // Ctrl+S / Ctrl+D
562
+ // keyboard: Ctrl+Alt+C toggle panel, Alt+↓/↑ jump changes, Ctrl+S save, Ctrl+D diff
508
563
  React.useEffect(() => {
509
564
  const onKey = (e) => {
565
+ if ((e.ctrlKey || e.metaKey) && e.altKey && (e.key === "c" || e.key === "C")) {
566
+ e.preventDefault();
567
+ setPanelOpen(!panel.open);
568
+ return;
569
+ }
570
+ if (e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
571
+ const t = e.target;
572
+ if (t && (t.tagName === "TEXTAREA" || t.tagName === "INPUT" || t.isContentEditable)) return;
573
+ e.preventDefault();
574
+ stepChange(e.key === "ArrowDown" ? 1 : -1);
575
+ return;
576
+ }
510
577
  if (!editor) return;
511
578
  if ((e.ctrlKey || e.metaKey) && e.key === "s") { e.preventDefault(); void saveCurrent(); }
512
579
  if ((e.ctrlKey || e.metaKey) && e.key === "d") { e.preventDefault(); viewDiff(editor.path); }
@@ -555,6 +622,62 @@ window.__ModuleLoader__.load({
555
622
  }).catch(() => {});
556
623
  };
557
624
  const clearChanges = () => setChanges([]);
625
+ const revertChange = async (path) => {
626
+ if (typeof globalThis.confirm !== "function" || !globalThis.confirm("还原将撤销 Agent 在本会话内对该文件的全部修改(若期间有外部改动会被覆盖)。确定还原?")) return;
627
+ setReverting(path);
628
+ setConn("busy"); setConnText("还原中…");
629
+ try {
630
+ const r = await api("/revert", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path }) });
631
+ setConn("ok"); setConnText(r.conflict ? "已还原(检测到外部修改,已覆盖)" : "已还原 " + new Date().toLocaleTimeString());
632
+ } catch (e) {
633
+ setConn("err"); setConnText("还原失败: " + e.message);
634
+ } finally { setReverting(null); }
635
+ };
636
+ const openHistory = (path) => {
637
+ setHistPath(path); setHistData([]); setHistOpen(null);
638
+ historyApi(path).then((d) => setHistData(d.events ?? [])).catch(() => setHistData([]));
639
+ };
640
+ const sendToChat = async (path, content) => {
641
+ if (content == null) return;
642
+ const text = "我在 Code Studio 中打开了文件 " + path + ",内容如下:\n\n```\n" + content + "\n```";
643
+ try {
644
+ const sessions = ctxRef && typeof ctxRef.get === "function" ? ctxRef.get("sessions") : void 0;
645
+ const face = sessions && typeof sessions.binding === "function" ? sessions.binding(sessIdRef.current)?.session : void 0;
646
+ if (face && typeof face.prompt === "function") {
647
+ setConn("busy"); setConnText("发送到会话…");
648
+ await face.prompt([{ type: "text", text }], "queue");
649
+ setConn("ok"); setConnText("已发送到会话");
650
+ return;
651
+ }
652
+ } catch (e) {
653
+ setConn("err"); setConnText("发送失败: " + e.message);
654
+ return;
655
+ }
656
+ try {
657
+ await navigator.clipboard.writeText(text);
658
+ setConn("ok"); setConnText("已复制到剪贴板(自动发送不可用)");
659
+ } catch {
660
+ setConn("err"); setConnText("无法发送");
661
+ }
662
+ };
663
+ const stepChange = (dir) => {
664
+ if (changes.length === 0) return;
665
+ const idx = changes.findIndex((c) => c.path === expandedPath);
666
+ const from = idx < 0 ? (dir > 0 ? -1 : 0) : idx;
667
+ const next = changes[Math.max(0, Math.min(changes.length - 1, from + dir))];
668
+ if (!next) return;
669
+ setExpandedPath(next.path);
670
+ const node = document.querySelector('[data-cs-chg="' + (globalThis.CSS && globalThis.CSS.escape ? globalThis.CSS.escape(next.path) : next.path) + '"]');
671
+ if (node) node.scrollIntoView({ block: "center", behavior: "smooth" });
672
+ };
673
+ const shownChanges = React.useMemo(() => {
674
+ const q = filter.trim().toLowerCase();
675
+ return changes.filter((c) => {
676
+ if (q && !c.path.toLowerCase().includes(q) && !c.name.toLowerCase().includes(q)) return false;
677
+ if (ftype !== "all" && extType(extOf(c.path)) !== ftype) return false;
678
+ return true;
679
+ });
680
+ }, [changes, filter, ftype]);
558
681
 
559
682
  // drag to resize
560
683
  const onResizeDown = (e) => {
@@ -577,6 +700,108 @@ window.__ModuleLoader__.load({
577
700
  }
578
701
  };
579
702
 
703
+ const renderChangesTab = () => {
704
+ if (histPath) {
705
+ return el("div", { className: "cs-files" },
706
+ el("div", { className: "cs-hist-back" },
707
+ el("button", { className: "cs-btn", onClick: () => setHistPath(null) }, "← 返回变更"),
708
+ el("span", { className: "cs-fp" }, histPath)
709
+ ),
710
+ el("div", { className: "cs-chg-list" },
711
+ histData.length === 0
712
+ ? el("div", { className: "cs-empty" }, "暂无历史记录")
713
+ : histData.map((ev, i) => {
714
+ const stats = ev.deleted ? { adds: 0, dels: 0 } : fileStats(ev.before, ev.after);
715
+ const tag = ev.source === "revert"
716
+ ? el("span", { className: "cs-hist-tag cs-rev2" }, "还原")
717
+ : ev.source === "user"
718
+ ? el("span", { className: "cs-hist-tag cs-usr" }, "用户")
719
+ : el("span", { className: "cs-hist-tag cs-src" }, "Agent");
720
+ const isOpen = histOpen === i;
721
+ return el("div", { className: "cs-hist-item", key: i },
722
+ el("div", { className: "cs-hist-row", onClick: () => setHistOpen(isOpen ? null : i) },
723
+ el("span", { className: "cs-caret" }, isOpen ? "▾" : "▸"),
724
+ tag,
725
+ el("span", { style: { color: "var(--dsw-alias-label-tertiary)" } }, new Date(ev.ts).toLocaleTimeString()),
726
+ el("span", { className: "cs-plus" }, "+" + stats.adds),
727
+ el("span", { className: "cs-minus" }, "−" + stats.dels)
728
+ ),
729
+ isOpen ? el(DiffRows, { before: ev.before, after: ev.after, path: histPath }) : null
730
+ );
731
+ })
732
+ )
733
+ );
734
+ }
735
+ if (changes.length === 0) {
736
+ return el("div", { className: "cs-empty" },
737
+ el("div", null, "暂无文件变更"),
738
+ el("span", { className: "cs-hint" }, "Agent 修改文件时,这里会自动浮现逐行 Diff(+ 新增 − 删除 ~ 修改)。快捷键:Ctrl+Alt+C 开关面板,Alt+↓/↑ 跳转变更")
739
+ );
740
+ }
741
+ return el("div", { className: "cs-files" },
742
+ el("div", { className: "cs-filter" },
743
+ el("input", { placeholder: "搜索路径…", value: filter, onChange: (e) => setFilter(e.target.value), spellCheck: false }),
744
+ el("select", { value: ftype, onChange: (e) => setFtype(e.target.value) },
745
+ el("option", { value: "all" }, "全部"),
746
+ el("option", { value: "code" }, "代码"),
747
+ el("option", { value: "doc" }, "文档"),
748
+ el("option", { value: "conf" }, "配置"),
749
+ el("option", { value: "other" }, "其他")
750
+ )
751
+ ),
752
+ el("div", { className: "cs-chg-list" },
753
+ shownChanges.length === 0
754
+ ? el("div", { className: "cs-empty" }, "无匹配的变更")
755
+ : shownChanges.map((c) => {
756
+ const stats = c.deleted ? { adds: 0, dels: 0 } : fileStats(c.before, c.after);
757
+ const isOpen = expandedPath === c.path;
758
+ return el("div", { className: "cs-chg", key: c.path, "data-cs-chg": c.path },
759
+ el("div", { className: "cs-chg-row", onClick: () => setExpandedPath(isOpen ? null : c.path) },
760
+ el("span", { className: "cs-caret" }, isOpen ? "▾" : "▸"),
761
+ el("span", { className: "cs-chg-path" }, c.name,
762
+ c.deleted ? el("span", { className: "cs-bad cs-bad-del" }, "删除")
763
+ : c.before == null ? el("span", { className: "cs-bad cs-bad-add" }, "新建")
764
+ : null
765
+ ),
766
+ el("span", { className: "cs-plus" }, "+" + stats.adds),
767
+ el("span", { className: "cs-minus" }, "−" + stats.dels),
768
+ el("button", { className: "cs-rev", title: "还原为该会话 Agent 修改前的版本", disabled: reverting === c.path, onClick: (e) => { e.stopPropagation(); void revertChange(c.path); } }, "还原"),
769
+ el("button", { className: "cs-btn", title: "该文件的修改历史", onClick: (e) => { e.stopPropagation(); openHistory(c.path); } }, "历史")
770
+ ),
771
+ isOpen ? (c.deleted
772
+ ? el("div", { className: "cs-chg-body" }, el("div", { className: "cs-empty" }, "文件已删除"))
773
+ : el(DiffRows, { before: c.before, after: c.after, path: c.path })
774
+ ) : null
775
+ );
776
+ }),
777
+ el("div", { style: { display: "flex", gap: 6, padding: "4px 2px" } },
778
+ el("button", { className: "cs-btn", onClick: clearChanges }, "清空列表")
779
+ )
780
+ )
781
+ );
782
+ };
783
+ const renderFilesTab = () => {
784
+ return el("div", { className: "cs-files" },
785
+ el("div", { className: "cs-ws-bar" },
786
+ el("span", null, "工作区"),
787
+ el("select", { value: activeRoot ?? "", onChange: (e) => setActiveRoot(e.target.value) },
788
+ (workspaces.length === 0 ? [{ path: root, label: root ?? "…", sessionCount: 0 }] : workspaces).map((w) =>
789
+ el("option", { value: w.path, key: w.path }, w.label + (w.sessionCount ? " (" + w.sessionCount + " 会话)" : ""))
790
+ )
791
+ )
792
+ ),
793
+ (activeRoot ?? root) ? el(FileTree, { root: activeRoot ?? root, selected: editor?.path, changed: changedSet, onOpen: openFile })
794
+ : el("div", { className: "cs-empty" }, "加载工作区…"),
795
+ editor ? (
796
+ editor.loading ? el("div", { className: "cs-empty" }, "加载中…")
797
+ : editor.error ? el("div", { className: "cs-empty" }, editor.error)
798
+ : el(Editor, { file: editor, onEdit: editContent, onSave: () => void saveCurrent(), onDiff: () => viewDiff(editor.path), onSend: () => void sendToChat(editor.path, editor.content), onHistory: () => { viewDiff(editor.path); openHistory(editor.path); } })
799
+ ) : el("div", { className: "cs-empty" },
800
+ el("div", null, "点击上方文件开始编辑"),
801
+ el("span", { className: "cs-hint" }, "打开文件后可编辑,Ctrl+S 保存,Ctrl+D 查看 Diff")
802
+ )
803
+ );
804
+ };
580
805
  if (!open) {
581
806
  if (changes.length > 0) {
582
807
  return el("div", { className: "cs-badge-f", onClick: () => setPanelOpen(true) },
@@ -598,7 +823,7 @@ window.__ModuleLoader__.load({
598
823
  el("span", { className: "cs-dot" }),
599
824
  "Code Studio"
600
825
  ),
601
- el("span", { className: "cs-count" }, changes.length > 0 ? changes.length + " 变更" : "就绪"),
826
+ el("span", { className: "cs-count" }, changes.length > 0 ? shownChanges.length + "/" + changes.length + " 变更" : "就绪"),
602
827
  el("button", { className: "cs-close", onClick: () => setPanelOpen(false), title: "关闭" }, "✕")
603
828
  ),
604
829
  el("div", { className: "cs-tabs" },
@@ -606,66 +831,33 @@ window.__ModuleLoader__.load({
606
831
  el("div", { className: "cs-tab" + (tab === "files" ? " cs-on" : ""), onClick: () => setTab("files") }, "文件")
607
832
  ),
608
833
  el("div", { className: "cs-body" },
609
- tab === "changes" ? (
610
- changes.length === 0
611
- ? el("div", { className: "cs-empty" },
612
- el("div", null, "暂无文件变更"),
613
- el("span", { className: "cs-hint" }, "Agent 修改文件时,这里会自动浮现逐行 Diff(+ 新增 − 删除 ~ 修改)")
614
- )
615
- : el("div", { className: "cs-chg-list" },
616
- changes.map((c) => {
617
- const stats = c.deleted ? { adds: 0, dels: 0 } : fileStats(c.before, c.after);
618
- const isOpen = expandedPath === c.path;
619
- return el("div", { className: "cs-chg", key: c.path },
620
- el("div", { className: "cs-chg-row", onClick: () => setExpandedPath(isOpen ? null : c.path) },
621
- el("span", { className: "cs-caret" }, isOpen ? "▾" : "▸"),
622
- el("span", { className: "cs-chg-path" }, c.name,
623
- c.deleted ? el("span", { className: "cs-bad cs-bad-del" }, "删除")
624
- : c.before == null ? el("span", { className: "cs-bad cs-bad-add" }, "新建")
625
- : null
626
- ),
627
- el("span", { className: "cs-plus" }, "+" + stats.adds),
628
- el("span", { className: "cs-minus" }, "−" + stats.dels)
629
- ),
630
- isOpen ? el("div", { className: "cs-chg-body" },
631
- c.deleted
632
- ? el("div", { className: "cs-empty" }, "文件已删除")
633
- : el(DiffRows, { before: c.before, after: c.after, path: c.path })
634
- ) : null
635
- );
636
- }),
637
- el("div", { style: { display: "flex", gap: 6, padding: "4px 2px" } },
638
- el("button", { className: "cs-btn", onClick: clearChanges }, "清空列表")
639
- )
640
- )
641
- ) : (
642
- el("div", { className: "cs-files" },
643
- root ? el(FileTree, { root, selected: editor?.path, changed: changedSet, onOpen: openFile })
644
- : el("div", { className: "cs-empty" }, "加载工作区…"),
645
- editor ? (
646
- editor.loading ? el("div", { className: "cs-empty" }, "加载中…")
647
- : editor.error ? el("div", { className: "cs-empty" }, editor.error)
648
- : el(Editor, { file: editor, onEdit: editContent, onSave: () => void saveCurrent(), onDiff: () => viewDiff(editor.path) })
649
- ) : el("div", { className: "cs-empty" },
650
- el("div", null, "点击上方文件开始编辑"),
651
- el("span", { className: "cs-hint" }, "打开文件后可编辑,Ctrl+S 保存,Ctrl+D 查看 Diff")
652
- )
653
- )
654
- )
834
+ tab === "changes" ? renderChangesTab() : renderFilesTab()
655
835
  ),
656
836
  el("div", { className: "cs-status" },
657
837
  el("span", { className: "cs-sdot cs-" + conn }),
658
838
  el("span", null, connText),
659
839
  el("span", { style: { marginLeft: "auto" } }, "变更 " + changes.length + " 文件"),
660
840
  sessId ? el("span", { title: "仅显示当前会话的变更" }, "会话 " + String(sessId).slice(-6)) : null,
661
- el("span", null, editor && editor.dirty ? "未保存" : "就绪")
841
+ el("span", null, editor && editor.dirty ? "未保存" : "就绪"),
842
+ el("span", { style: { marginLeft: "auto" } }, "Ctrl+Alt+C 面板 · Alt+↓/↑ 跳转")
662
843
  )
663
844
  );
664
845
  }
665
846
 
666
847
  // ============================ sidebar entry ============================
667
848
  const ENTRY_SELECTOR = "[data-cs-entry]";
668
- const ENTRY_VERSION = "4";
849
+ const ENTRY_VERSION = "5";
850
+ let badgeEl = null;
851
+ function updateBadge() {
852
+ if (badgeEl === null) return;
853
+ const n = panel.open ? 0 : unseenCount(badgeKey(currentSessionId));
854
+ if (n > 0) {
855
+ badgeEl.textContent = n > 99 ? "99+" : String(n);
856
+ badgeEl.style.display = "flex";
857
+ } else {
858
+ badgeEl.style.display = "none";
859
+ }
860
+ }
669
861
  const ENTRY_ICON = "<svg viewBox=\"0 0 16 16\" width=\"15\" height=\"15\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M4 1.5h6l2.5 2.5v10.5a.5.5 0 0 1-.5.5H4a.5.5 0 0 1-.5-.5V2a.5.5 0 0 1 .5-.5z\"/><path d=\"M4 6h8M4 9h8M4 12h5\"/></svg>";
670
862
  function sidebarRoot() {
671
863
  const column = document.querySelector("[data-pane=\"sidebar\"], [class*=\"sidebarCol\"]");
@@ -691,7 +883,7 @@ window.__ModuleLoader__.load({
691
883
  entry.setAttribute("data-dsh-plugin", "code-studio");
692
884
  entry.setAttribute("data-dsh-part", "sidebar-entry");
693
885
  entry.title = "Code Studio — 代码编辑与 Diff(拖拽面板左缘可调宽)";
694
- entry.innerHTML = "<span>" + ENTRY_ICON + "</span><span>Code Studio</span>";
886
+ entry.innerHTML = "<span>" + ENTRY_ICON + "</span><span>Code Studio</span><span data-cs-badge class=\"cs-badge-n\" style=\"display:none\"></span>";
695
887
  entry.style.cssText = "display:flex;align-items:center;gap:8px;width:100%;padding:8px 12px;background:transparent;border:none;color:inherit;cursor:pointer;font-size:13px;border-radius:8px;";
696
888
  entry.addEventListener("click", () => setPanelOpen(!panel.open));
697
889
  let root;
@@ -709,7 +901,17 @@ window.__ModuleLoader__.load({
709
901
  const observer = new MutationObserver(place);
710
902
  observer.observe(document.body, { childList: true, subtree: true });
711
903
  place();
712
- return () => { observer.disconnect(); entry.remove(); };
904
+ badgeEl = entry.querySelector("[data-cs-badge]");
905
+ const unBadge = subscribeBadge(updateBadge);
906
+ const unPanel = subscribePanel(updateBadge);
907
+ const unSess = subscribeSession(updateBadge);
908
+ updateBadge();
909
+ return () => {
910
+ observer.disconnect();
911
+ entry.remove();
912
+ unBadge(); unPanel(); unSess();
913
+ badgeEl = null;
914
+ };
713
915
  }
714
916
 
715
917
  // ============================ apply ============================
@@ -717,6 +919,7 @@ window.__ModuleLoader__.load({
717
919
  function apply(ctx) {
718
920
  if (mounted) return;
719
921
  mounted = true;
922
+ ctxRef = ctx;
720
923
  ctx.effect(() => {
721
924
  const disposers = [];
722
925
  try {
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { readFile, readdir, stat, writeFile } from "node:fs/promises";
1
+ import { readFile, readdir, stat, writeFile, unlink } from "node:fs/promises";
2
2
  import { watch as fsWatch, statSync, realpathSync } from "node:fs";
3
3
  import { join, dirname, basename, resolve, isAbsolute } from "node:path";
4
4
 
@@ -16,6 +16,9 @@ const HEARTBEAT_MS = 25000;
16
16
  const DEBOUNCE_MS = 120;
17
17
  const MAX_READ_BYTES = 512 * 1024; // protect the browser from huge files
18
18
  const HISTORY_LIMIT = 30;
19
+ const HISTORY_MAX_CONTENT = 100 * 1024; // per-event content kept in history (SSE still carries full)
20
+ const SSE_RING = 200; // events retained for Last-Event-ID replay
21
+ const REVERT_LIMIT = 400;
19
22
 
20
23
  /* ---------- helpers ---------- */
21
24
 
@@ -73,6 +76,13 @@ class FileLedger {
73
76
  this.debounce = new Map();
74
77
  /** file path -> owning session { sessionId, ts } (learnt from tool/call events) */
75
78
  this.owners = new Map();
79
+ /** monotonic SSE sequence + replay ring (Last-Event-ID recovery) */
80
+ this.seq = 0;
81
+ this.ring = [];
82
+ /** agent-first-write snapshot per file: abs path -> { content|null, existed, sessionId, ts } */
83
+ this.revertPoints = new Map();
84
+ /** abs workspace root -> Set<sessionId> (for the workspace switcher) */
85
+ this.sessionRoots = new Map();
76
86
  if (root) this.addRoot(root);
77
87
  }
78
88
 
@@ -137,9 +147,20 @@ class FileLedger {
137
147
  return false;
138
148
  }
139
149
 
150
+ /**
151
+ * Only files the agent has actually touched (claimed via write/edit tool
152
+ * events) are interesting. Everything else - the user editing in another
153
+ * editor, IDE/build writes, background processes - is silently ignored so
154
+ * the panel shows exactly "which file the agent changed".
155
+ */
156
+ isInteresting(path) {
157
+ return this.owners.has(path);
158
+ }
159
+
140
160
  /** Called when a file may have changed on disk. */
141
161
  async handleChange(path) {
142
162
  if (FileLedger.isNoise(path)) return;
163
+ if (!this.isInteresting(path)) return;
143
164
  let current;
144
165
  try {
145
166
  current = await this.readCurrent(path);
@@ -163,16 +184,60 @@ class FileLedger {
163
184
  this.addHistory(path, event);
164
185
  }
165
186
 
187
+ /**
188
+ * Snapshot the file content BEFORE the agent's first write in a session, so
189
+ * the user can undo the agent's whole session on this file. Called at
190
+ * tool/call time (the write has not happened yet). Content null + existed
191
+ * false means the file did not exist (revert = keep deleted).
192
+ */
193
+ async captureRevertPoint(path, sessionId) {
194
+ if (typeof path !== "string" || path === "" || typeof sessionId !== "string") return;
195
+ const existing = this.revertPoints.get(path);
196
+ if (existing && existing.sessionId === sessionId) return;
197
+ let content = null;
198
+ let existed = false;
199
+ try {
200
+ const st = await stat(path);
201
+ existed = true;
202
+ if (st.size <= MAX_READ_BYTES) content = await readFile(path, "utf8");
203
+ } catch (error) {
204
+ if (error.code !== "ENOENT") return;
205
+ }
206
+ if (this.revertPoints.size >= REVERT_LIMIT) {
207
+ const oldest = [...this.revertPoints.entries()].sort((a, b) => a[1].ts - b[1].ts)[0];
208
+ if (oldest) this.revertPoints.delete(oldest[0]);
209
+ }
210
+ this.revertPoints.set(path, { content, existed, sessionId, ts: Date.now() });
211
+ }
212
+
213
+ /** Attribute a workspace root to a session (drives the switcher). */
214
+ noteSessionRoot(root, sessionId) {
215
+ if (typeof root !== "string" || root === "") return;
216
+ let resolved;
217
+ try { resolved = realpathSync(root); } catch { return; }
218
+ let set = this.sessionRoots.get(resolved);
219
+ if (!set) { set = new Set(); this.sessionRoots.set(resolved, set); }
220
+ if (sessionId) set.add(sessionId);
221
+ }
222
+
166
223
  addHistory(path, event) {
224
+ const trim = (v) => typeof v === "string" && v.length > HISTORY_MAX_CONTENT
225
+ ? v.slice(0, HISTORY_MAX_CONTENT) + "\n…[history 截断]"
226
+ : v;
227
+ const copy = { ...event, before: trim(event.before), after: trim(event.after) };
167
228
  const list = this.history.get(path) ?? [];
168
- list.push(event);
229
+ list.push(copy);
169
230
  if (list.length > HISTORY_LIMIT) list.shift();
170
231
  this.history.set(path, list);
171
232
  }
172
233
 
173
234
  push(event) {
235
+ event.seq = ++this.seq;
236
+ if (this.ring.length >= SSE_RING) this.ring.shift();
237
+ this.ring.push(event);
238
+ const payload = `id: ${event.seq}\ndata: ${JSON.stringify(event)}\n\n`;
174
239
  for (const res of this.subscribers) {
175
- try { res.write(`data: ${JSON.stringify(event)}\n\n`); } catch { /* ignore */ }
240
+ try { res.write(payload); } catch { /* ignore */ }
176
241
  }
177
242
  }
178
243
 
@@ -326,6 +391,70 @@ function makeRoutes(ledger, root, cwd) {
326
391
  json(res, 200, { ok: true, path, events: path === void 0 ? [] : (ledger.history.get(path) ?? []) });
327
392
  }
328
393
  },
394
+ {
395
+ kind: "exact",
396
+ path: `${PREFIX}/revert`,
397
+ handler: async (req, res) => {
398
+ if (req.method !== "POST") return json(res, 405, { ok: false, error: "method-not-allowed" });
399
+ if (!guard(req, res)) return;
400
+ let body;
401
+ try { body = await readBody(req); }
402
+ catch { return json(res, 400, { ok: false, error: "invalid-body" }); }
403
+ const path = safePath(body?.path);
404
+ if (path === void 0) return json(res, 400, { ok: false, error: "path-required" });
405
+ const point = ledger.revertPoints.get(path);
406
+ if (point === void 0) return json(res, 404, { ok: false, error: "no-revert-point" });
407
+ if (point.existed && point.content === null) return json(res, 409, { ok: false, error: "revert-point-too-large" });
408
+ // conflict: file differs from the last content we saw (changed outside the agent's write)
409
+ let current = null, currentDeleted = false, conflict = false;
410
+ try {
411
+ const st = await stat(path);
412
+ if (st.size <= MAX_READ_BYTES) current = await readFile(path, "utf8");
413
+ } catch (error) {
414
+ if (error.code === "ENOENT") currentDeleted = true;
415
+ }
416
+ const baseline = ledger.snapshot(path);
417
+ if (!currentDeleted && baseline !== void 0 && baseline.content !== null && current !== null && baseline.content !== current) conflict = true;
418
+ try {
419
+ if (!point.existed) {
420
+ await unlink(path).catch((error) => { if (error.code !== "ENOENT") throw error; });
421
+ } else {
422
+ await writeFile(path, point.content, "utf8");
423
+ }
424
+ } catch (error) {
425
+ return json(res, 400, { ok: false, error: String(error.message ?? error) });
426
+ }
427
+ const st2 = await stat(path).catch(() => void 0);
428
+ const event = {
429
+ path,
430
+ ts: Date.now(),
431
+ before: currentDeleted ? null : current,
432
+ after: point.existed ? point.content : null,
433
+ deleted: !point.existed,
434
+ source: "revert",
435
+ sessionId: ledger.ownerOf(path)
436
+ };
437
+ ledger.recordBaseline(path, point.content, st2?.mtimeMs ?? Date.now(), point.content?.length ?? 0);
438
+ ledger.push(event);
439
+ ledger.addHistory(path, event);
440
+ json(res, 200, { ok: true, path, reverted: true, conflict });
441
+ }
442
+ },
443
+ {
444
+ kind: "exact",
445
+ path: `${PREFIX}/workspaces`,
446
+ handler: (req, res) => {
447
+ if (req.method !== "GET") return json(res, 405, { ok: false, error: "method-not-allowed" });
448
+ if (!guard(req, res)) return;
449
+ const workspaces = [];
450
+ for (const r of ledger.roots) {
451
+ const sessions = ledger.sessionRoots.get(r);
452
+ workspaces.push({ path: r, label: r.split(/[\\/]/).pop() || r, sessionCount: sessions ? sessions.size : 0 });
453
+ }
454
+ workspaces.sort((a, b) => (a.path === root ? -1 : b.path === root ? 1 : a.label.localeCompare(b.label)));
455
+ json(res, 200, { ok: true, workspaces, current: root });
456
+ }
457
+ },
329
458
  {
330
459
  kind: "exact",
331
460
  path: `${PREFIX}/events`,
@@ -338,6 +467,14 @@ function makeRoutes(ledger, root, cwd) {
338
467
  connection: "keep-alive"
339
468
  });
340
469
  res.write(": connected\n\n");
470
+ const lastEventId = Number(req.headers["last-event-id"]);
471
+ if (Number.isFinite(lastEventId) && lastEventId > 0) {
472
+ for (const ev of ledger.ring) {
473
+ if (ev.seq > lastEventId) {
474
+ try { res.write(`id: ${ev.seq}\ndata: ${JSON.stringify(ev)}\n\n`); } catch { /* ignore */ }
475
+ }
476
+ }
477
+ }
341
478
  ledger.subscribers.add(res);
342
479
  const heartbeat = setInterval(() => { try { res.write(": ping\n\n"); } catch { /* ignore */ } }, HEARTBEAT_MS);
343
480
  const close = () => {
@@ -410,6 +547,11 @@ function apply(ctx, config) {
410
547
  sessionId: session?.id,
411
548
  cwd: session?.header?.cwd
412
549
  });
550
+ // snapshot the pre-write content NOW (tool/call fires before the write)
551
+ if (PATH_TOOLS.has(data.name)) {
552
+ const abs = toolPath(data.arguments, session?.header?.cwd);
553
+ if (abs !== void 0) void ledger.captureRevertPoint(abs, session?.id);
554
+ }
413
555
  if (pendingCalls.size > 500) {
414
556
  const oldest = pendingCalls.keys().next().value;
415
557
  if (oldest !== void 0) pendingCalls.delete(oldest);
@@ -445,4 +587,4 @@ function apply(ctx, config) {
445
587
  }, "code-studio: routes and watcher");
446
588
  }
447
589
 
448
- export { apply, inject, name };
590
+ export { apply, inject, name, FileLedger, makeRoutes };
package/package.json CHANGED
@@ -1,58 +1,58 @@
1
- {
2
- "name": "@windypro-rourou/dsh-code-studio",
3
- "description": "File-change monitor \u0026 code editor for the DSH Web UI: watch agent edits land as line-by-line diffs in real time, right beside your conversation, then view and edit any workspace file without leaving the browser.",
4
- "version": "0.1.4",
5
- "type": "module",
6
- "main": "lib/index.js",
7
- "types": "lib/types/index.d.ts",
8
- "exports": {
9
- ".": {
10
- "types": "./lib/types/index.d.ts",
11
- "default": "./lib/index.js"
12
- },
13
- "./client": {
14
- "types": "./lib/types/client/index.d.ts",
15
- "default": "./lib/client.js"
16
- },
17
- "./package.json": "./package.json"
18
- },
19
- "dsh": {
20
- "bundle": {
21
- "patch": "./cordis.patch.yml"
22
- },
23
- "client": {
24
- "inject": [
25
- "@deepseek-ai/dsh-client-runtime"
26
- ],
27
- "platform": "web"
28
- }
29
- },
30
- "keywords": [
31
- "dsh-plugin",
32
- "dsh",
33
- "deepseek-harness",
34
- "code-studio",
35
- "cline",
36
- "diff",
37
- "editor",
38
- "web-ui"
39
- ],
40
- "repository": {
41
- "type": "git",
42
- "url": "git+https://github.com/windypro-rourou/dsh-code-studio.git"
43
- },
44
- "peerDependencies": {
45
- "@deepseek-ai/cordis": "^4.0.1",
46
- "react": "^18.2.0",
47
- "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.8",
48
- "@deepseek-ai/dsh-client-connection": "^0.1.0-rc.8"
49
- },
50
- "files": [
51
- "lib/**/*.js",
52
- "lib/**/*.d.ts",
53
- "cordis.patch.yml",
54
- "README.md",
55
- "scripts"
56
- ],
57
- "license": "MIT"
58
- }
1
+ {
2
+ "name": "@windypro-rourou/dsh-code-studio",
3
+ "description": "File-change monitor & code editor for the DSH Web UI: watch agent edits land as line-by-line diffs in real time, right beside your conversation, then view and edit any workspace file without leaving the browser.",
4
+ "version": "0.2.0",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "lib/types/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/types/index.d.ts",
11
+ "default": "./lib/index.js"
12
+ },
13
+ "./client": {
14
+ "types": "./lib/types/client/index.d.ts",
15
+ "default": "./lib/client.js"
16
+ },
17
+ "./package.json": "./package.json"
18
+ },
19
+ "dsh": {
20
+ "bundle": {
21
+ "patch": "./cordis.patch.yml"
22
+ },
23
+ "client": {
24
+ "inject": [
25
+ "@deepseek-ai/dsh-client-runtime"
26
+ ],
27
+ "platform": "web"
28
+ }
29
+ },
30
+ "keywords": [
31
+ "dsh-plugin",
32
+ "dsh",
33
+ "deepseek-harness",
34
+ "code-studio",
35
+ "cline",
36
+ "diff",
37
+ "editor",
38
+ "web-ui"
39
+ ],
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/windypro-rourou/dsh-code-studio.git"
43
+ },
44
+ "peerDependencies": {
45
+ "@deepseek-ai/cordis": "^4.0.1",
46
+ "react": "^18.2.0",
47
+ "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.8",
48
+ "@deepseek-ai/dsh-client-connection": "^0.1.0-rc.8"
49
+ },
50
+ "files": [
51
+ "lib/**/*.js",
52
+ "lib/**/*.d.ts",
53
+ "cordis.patch.yml",
54
+ "README.md",
55
+ "scripts"
56
+ ],
57
+ "license": "MIT"
58
+ }
@@ -0,0 +1,130 @@
1
+ // selftest.mjs - zero-dependency sanity checks for the code-studio host half.
2
+ // Run: node scripts/selftest.mjs (needs a writable temp dir; cleans up after)
3
+ import { mkdtemp, writeFile, readFile, rm, stat, unlink } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join, dirname } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { FileLedger, makeRoutes } from "../lib/index.js";
8
+
9
+ const HERE = dirname(fileURLToPath(import.meta.url));
10
+ const ROOT = await mkdtemp(join(tmpdir(), "cs-selftest-"));
11
+ const PREFIX = "/api/code-studio";
12
+ let passed = 0, failed = 0;
13
+ const ok = (cond, name) => { if (cond) { passed++; console.log(" ✓ " + name); } else { failed++; console.log(" ✗ FAIL: " + name); } };
14
+
15
+ function mockReq(method, url, body) {
16
+ const req = { method, url, headers: {}, socket: { remoteAddress: "127.0.0.1" },
17
+ on(ev, fn) {
18
+ if (ev === "data" && body !== undefined) queueMicrotask(() => fn(Buffer.from(JSON.stringify(body))));
19
+ if (ev === "end") queueMicrotask(() => fn());
20
+ return req;
21
+ } };
22
+ return req;
23
+ }
24
+ function mockRes() {
25
+ return { status: 0, body: "", writeHead(s) { this.status = s; }, end(b) { if (typeof b === "string") this.body = b; }, write(b) { this.body += b; }, destroy() {} };
26
+ }
27
+ const route = (routes, p) => routes.find((r) => r.path === PREFIX + p);
28
+
29
+ console.log("== FileLedger basics ==");
30
+ const ledger = new FileLedger(ROOT);
31
+ ok(ledger.roots.has(ROOT), "root added");
32
+
33
+ // SSE seq + ring
34
+ const received = [];
35
+ ledger.subscribers.add({ write: (chunk) => received.push(chunk) });
36
+ ledger.push({ path: "a", ts: 1 });
37
+ ledger.push({ path: "b", ts: 2 });
38
+ ok(received[0].startsWith("id: 1\ndata:"), "first event carries id:1");
39
+ ok(received[1].includes("id: 2"), "second event carries id:2");
40
+ ok(ledger.ring.length === 2 && ledger.ring[0].seq === 1 && ledger.ring[1].seq === 2, "ring keeps ordered seq");
41
+ ledger.subscribers.clear();
42
+
43
+ // claim + handleChange produces before/after event
44
+ const f1 = join(ROOT, "alpha.txt");
45
+ await writeFile(f1, "line1\nline2\n", "utf8");
46
+ ledger.claim(f1, "s1");
47
+ await ledger.handleChange(f1);
48
+ let hist = ledger.history.get(f1) ?? [];
49
+ ok(hist.length === 1 && hist[0].before === null && hist[0].after === "line1\nline2\n" && hist[0].sessionId === "s1", "first change: before=null after=content, session s1");
50
+ await writeFile(f1, "line1\nCHANGED\n", "utf8");
51
+ await ledger.handleChange(f1);
52
+ hist = ledger.history.get(f1) ?? [];
53
+ ok(hist.length === 2 && hist[1].before === "line1\nline2\n" && hist[1].after === "line1\nCHANGED\n", "second change carries before/after");
54
+
55
+ // history trim
56
+ const big = "x".repeat(150 * 1024);
57
+ await writeFile(f1, big, "utf8");
58
+ await ledger.handleChange(f1);
59
+ hist = ledger.history.get(f1) ?? [];
60
+ const last = hist[hist.length - 1];
61
+ ok(typeof last.after === "string" && last.after.length <= 100 * 1024 + 40 && last.after.includes("[history 截断]"), "history content trimmed");
62
+ ok(ledger.ring[ledger.ring.length - 1].after.length === big.length, "SSE ring keeps FULL content");
63
+
64
+ console.log("== revert ==");
65
+ const f2 = join(ROOT, "beta.js");
66
+ await writeFile(f2, "v1\n", "utf8");
67
+ await ledger.captureRevertPoint(f2, "s1");
68
+ await writeFile(f2, "v2\n", "utf8");
69
+ ledger.claim(f2, "s1");
70
+ await ledger.handleChange(f2);
71
+ // capture again same session -> must NOT overwrite
72
+ await ledger.captureRevertPoint(f2, "s1");
73
+ ok(ledger.revertPoints.get(f2).content === "v1\n", "revert point keeps pre-write content");
74
+ // new session -> overwrite with current
75
+ await ledger.captureRevertPoint(f2, "s2");
76
+ ok(ledger.revertPoints.get(f2).content === "v2\n", "new session re-baselines revert point");
77
+ await ledger.captureRevertPoint(f2, "s1");
78
+ ok(ledger.revertPoints.get(f2).content === "v2\n", "old session does not clobber");
79
+
80
+ const routes = makeRoutes(ledger, ROOT, ROOT);
81
+ const rev = route(routes, "/revert");
82
+ ok(rev !== void 0, "/revert route exists");
83
+ let res = mockRes();
84
+ await rev.handler(mockReq("POST", "/revert", { path: f2 }), res);
85
+ const r1 = JSON.parse(res.body);
86
+ ok(res.status === 200 && r1.ok && r1.reverted, "revert succeeds");
87
+ ok((await readFile(f2, "utf8")) === "v2\n", "file restored to revert point (v2)");
88
+
89
+ // conflict detection: external edit not seen by ledger
90
+ await writeFile(f2, "EXTERNAL\n", "utf8"); // bypass handleChange on purpose
91
+ res = mockRes();
92
+ await rev.handler(mockReq("POST", "/revert", { path: f2 }), res);
93
+ const r2 = JSON.parse(res.body);
94
+ ok(res.status === 200 && r2.conflict === true, "conflict flagged when file changed outside agent writes");
95
+
96
+ // revert of a file that did not exist pre-agent
97
+ const f3 = join(ROOT, "newfile.ts");
98
+ await ledger.captureRevertPoint(f3, "s1"); // ENOENT -> existed=false
99
+ await writeFile(f3, "created by agent\n", "utf8");
100
+ ledger.claim(f3, "s1");
101
+ await ledger.handleChange(f3);
102
+ res = mockRes();
103
+ await rev.handler(mockReq("POST", "/revert", { path: f3 }), res);
104
+ const r3 = JSON.parse(res.body);
105
+ ok(res.status === 200 && r3.ok, "revert of created file ok");
106
+ ok((await stat(f3).catch(() => null)) === null, "created file removed on revert");
107
+ ok((await ledger.history.get(f3) ?? []).some((e) => e.source === "revert"), "revert recorded in history");
108
+
109
+ // no-revert-point 404
110
+ res = mockRes();
111
+ await rev.handler(mockReq("POST", "/revert", { path: join(ROOT, "never.txt") }), res);
112
+ ok(res.status === 404, "no-revert-point -> 404");
113
+
114
+ console.log("== workspaces ==");
115
+ ledger.noteSessionRoot(ROOT, "s1");
116
+ ledger.noteSessionRoot(ROOT, "s2");
117
+ const ws = route(routes, "/workspaces");
118
+ res = mockRes();
119
+ await ws.handler(mockReq("GET", "/workspaces"), res);
120
+ const w = JSON.parse(res.body);
121
+ ok(w.ok && w.workspaces.length === 1 && w.workspaces[0].path === ROOT && w.workspaces[0].sessionCount === 2, "workspaces lists root with session count");
122
+
123
+ console.log("== guard ==");
124
+ res = mockRes();
125
+ const evil = mockReq("POST", "/revert", { path: f2 }); evil.socket.remoteAddress = "8.8.8.8"; await rev.handler(evil, res);
126
+ ok(res.status === 403, "non-loopback rejected");
127
+
128
+ await rm(ROOT, { recursive: true, force: true });
129
+ console.log("\n" + passed + " passed, " + failed + " failed");
130
+ process.exit(failed === 0 ? 0 : 1);
@@ -0,0 +1,18 @@
1
+ // smoke-client.mjs - loads lib/client.js under a mocked browser env
2
+ import { readFile } from "node:fs/promises";
3
+ const code = await readFile(new URL("../lib/client.js", import.meta.url), "utf8");
4
+ let loaded = null;
5
+ globalThis.window = {
6
+ __ModuleLoader__: { load(spec) { loaded = spec; } }
7
+ };
8
+ const react = { createElement: (t, p, ...c) => ({ t, p, c }) };
9
+ (0, eval)(code); // executes window.__ModuleLoader__.load({...})
10
+ if (loaded === null) throw new Error("module loader was not invoked");
11
+ const factory = loaded.factory;
12
+ const mod = factory((name) => {
13
+ if (name === "react") return react;
14
+ throw new Error("unexpected require: " + name);
15
+ });
16
+ if (typeof mod.apply !== "function") throw new Error("apply missing");
17
+ if (!Array.isArray(mod.inject)) throw new Error("inject missing");
18
+ console.log("smoke OK: apply=" + typeof mod.apply + " inject=" + JSON.stringify(mod.inject));