@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 +22 -11
- package/lib/client.js +282 -79
- package/lib/index.js +146 -4
- package/package.json +58 -58
- package/scripts/selftest.mjs +130 -0
- package/scripts/smoke-client.mjs +18 -0
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
|
-
- 🗂
|
|
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.
|
|
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`
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
el("
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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(
|
|
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) =>
|
|
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
|
-
//
|
|
487
|
-
|
|
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
|
|
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 = "
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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":
|
|
3
|
-
"description":
|
|
4
|
-
"version":
|
|
5
|
-
"type":
|
|
6
|
-
"main":
|
|
7
|
-
"types":
|
|
8
|
-
"exports":
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
"dsh":
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"keywords":
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"repository":
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
"peerDependencies":
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
"files":
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
"license":
|
|
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));
|