dsh-diff-approval 0.6.0 → 0.8.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/lib/index.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
- import { dirname, join, resolve } from "node:path";
3
+ import { dirname, join, resolve, sep } from "node:path";
4
4
  import { dshHomePath, expandHomePath } from "@deepseek-ai/dsh-home-paths";
5
5
  import { SessionId } from "@deepseek-ai/dsh-session";
6
6
  import { spawn } from "node:child_process";
7
+ import { existsSync } from "node:fs";
7
8
  //#region lib/types/pending.js
8
9
  /**
9
10
  * In-memory pending-diff store: one entry per (session, path), holding the
@@ -130,6 +131,31 @@ var PendingDiffStore = class {
130
131
  this.entries.set(key, next);
131
132
  return true;
132
133
  }
134
+ /**
135
+ * Restore one entry exactly as given (an undo/redo replays a snapshot). The
136
+ * entry is inserted or replaced by id, and the path index points at it so a
137
+ * later capture folds into the restored entry. The store keeps one entry per
138
+ * path: restoring an entry for a path that a DIFFERENT entry currently owns
139
+ * drops that occupant first, so an undo can never leave two list items for
140
+ * the same file (e.g. an imported entry and a replayed keep of the same path).
141
+ * @param sessionId - the owning session.
142
+ * @param entry - the entry state to restore.
143
+ * @returns whether the store changed.
144
+ */
145
+ restore(sessionId, entry) {
146
+ const key = entryKey(sessionId, entry.id);
147
+ const existing = this.entries.get(key);
148
+ if (existing !== void 0 && sameEntry(existing, entry)) return false;
149
+ const pathKey_ = pathKey(sessionId, entry.path);
150
+ const occupantId = this.pathIndex.get(pathKey_);
151
+ if (occupantId !== void 0 && occupantId !== entry.id) this.entries.delete(entryKey(sessionId, occupantId));
152
+ this.entries.set(key, {
153
+ ...entry,
154
+ sessionId
155
+ });
156
+ this.pathIndex.set(pathKey_, entry.id);
157
+ return true;
158
+ }
133
159
  /** Total entry count across all sessions. */
134
160
  get size() {
135
161
  return this.entries.size;
@@ -364,6 +390,263 @@ function defaultOpenPath(path, action) {
364
390
  }
365
391
  }
366
392
  //#endregion
393
+ //#region lib/types/vcs.js
394
+ /**
395
+ * Version-control integration, host half: detect which VCS (git/svn/p4)
396
+ * encloses a workspace by walking up the directory tree, and enumerate the
397
+ * workspace's LOCAL changes for import into the pending list. The workspace
398
+ * is often a subdirectory of the VCS root, so the root is found by walking up
399
+ * from the workspace path and the imported changes are filtered to files
400
+ * inside the workspace. Commands run through the deployment's `ctx.shell`
401
+ * executor (applying its sandbox/policy) with the VCS root as the working
402
+ * directory.
403
+ *
404
+ * Scope of one import (mirrors the panel's preferences):
405
+ * - modified files (git: working tree vs index — "仅未暂存"; svn: vs BASE;
406
+ * p4: opened edits), imported as `edit` (old = baseline, new = working);
407
+ * - deleted files, imported as `edit` with an empty new side (revert restores);
408
+ * - new/untracked files (git `??`, svn `?`, p4 not-yet-opened files), imported
409
+ * as `create` ONLY when the untracked preference is on. For p4 that means a
410
+ * full workspace scan (`p4 status`, which can be slow); with the preference
411
+ * off only already-opened files are read.
412
+ * @module dsh-diff-approval/vcs
413
+ */
414
+ /** Cap on one VCS command's runtime; scans (git status, svn status) can be slow
415
+ * on large trees but must not hang the import. */
416
+ const VCS_COMMAND_TIMEOUT_MS = 6e4;
417
+ /** Quote one argument for a POSIX-ish shell, so paths with spaces or special
418
+ * characters survive interpolation into VCS command lines. */
419
+ function shq(value) {
420
+ return `'${value.replace(/'/g, `'\\''`)}'`;
421
+ }
422
+ /** POSIX path-inside check, case-insensitive on Windows. */
423
+ function isPathInside(absolutePath, root) {
424
+ const folded = (value) => process.platform === "win32" ? value.toLowerCase() : value;
425
+ const path = folded(resolve(absolutePath));
426
+ const base = folded(resolve(root));
427
+ if (path === base) return true;
428
+ return path.startsWith(base + sep);
429
+ }
430
+ /** The VCS marker of one directory, or undefined when it holds none. */
431
+ function markerOf(directory) {
432
+ if (existsSync(resolve(directory, ".git"))) return "git";
433
+ if (existsSync(resolve(directory, ".svn"))) return "svn";
434
+ if (existsSync(resolve(directory, ".p4config")) || existsSync(resolve(directory, ".p4config.txt"))) return "p4";
435
+ }
436
+ /**
437
+ * Find the VCS enclosing `start` by walking up the directory tree: the first
438
+ * directory (deepest) holding a marker wins, with git > svn > p4 when several
439
+ * markers share one directory. Stops at the filesystem root.
440
+ * @param start - the workspace directory to start from.
441
+ * @returns the detected root, or undefined when no VCS marker is found.
442
+ */
443
+ function detectVcsRoot(start) {
444
+ let directory = resolve(start);
445
+ for (;;) {
446
+ const kind = markerOf(directory);
447
+ if (kind !== void 0) return {
448
+ kind,
449
+ root: directory
450
+ };
451
+ const parent = dirname(directory);
452
+ if (parent === directory) return void 0;
453
+ directory = parent;
454
+ }
455
+ }
456
+ /** Run one command through the shell executor; a non-zero exit throws. */
457
+ async function runShell(shell, command, workdir, signal) {
458
+ const spec = shell.resolve({
459
+ command,
460
+ workdir,
461
+ timeoutMs: VCS_COMMAND_TIMEOUT_MS,
462
+ signal
463
+ });
464
+ const result = await shell.run(spec);
465
+ if (result.exitCode !== 0) {
466
+ const detail = (result.stderr.text || result.stdout.text).trim();
467
+ throw new Error(`command failed (exit ${String(result.exitCode)}): ${detail || command}`);
468
+ }
469
+ return result.stdout.text;
470
+ }
471
+ /** Parse `git status --porcelain=v1 -z` output into (XY, repo-relative path)
472
+ * records. Rename/copy records carry a trailing destination field, which is
473
+ * consumed and skipped. */
474
+ function parseGitPorcelainZ(output) {
475
+ const records = [];
476
+ let index = 0;
477
+ while (index < output.length) {
478
+ const end = output.indexOf("\0", index);
479
+ if (end === -1) break;
480
+ const field = output.slice(index, end);
481
+ index = end + 1;
482
+ if (field.length < 3) continue;
483
+ const xy = field.slice(0, 2);
484
+ const rel = field.slice(3);
485
+ if (xy[0] === "R" || xy[0] === "C") {
486
+ const destEnd = output.indexOf("\0", index);
487
+ if (destEnd === -1) break;
488
+ index = destEnd + 1;
489
+ continue;
490
+ }
491
+ records.push({
492
+ xy,
493
+ rel
494
+ });
495
+ }
496
+ return records;
497
+ }
498
+ /** Enumerate the workspace's local changes in a git checkout. */
499
+ async function gitChanges(input) {
500
+ const { root, workspaceRoot, includeUntracked, shell, readText, signal } = input;
501
+ const stdout = await runShell(shell, "git -c status.renames=false status --porcelain=v1 -z --untracked-files=all", root, signal);
502
+ const changes = [];
503
+ for (const { xy, rel } of parseGitPorcelainZ(stdout)) {
504
+ const absolute = resolve(root, rel);
505
+ if (!isPathInside(absolute, workspaceRoot)) continue;
506
+ if (xy === "??") {
507
+ if (!includeUntracked) continue;
508
+ const newText = await readText(absolute) ?? "";
509
+ changes.push({
510
+ path: absolute,
511
+ kind: "create",
512
+ oldText: "",
513
+ newText
514
+ });
515
+ continue;
516
+ }
517
+ const worktree = xy[1] ?? " ";
518
+ if (worktree !== "M" && worktree !== "D") continue;
519
+ let oldText = "";
520
+ try {
521
+ oldText = await runShell(shell, `git show :0:${shq(rel)}`, root, signal);
522
+ } catch {
523
+ oldText = "";
524
+ }
525
+ const newText = worktree === "D" ? "" : await readText(absolute) ?? "";
526
+ if (oldText === "" && newText === "") continue;
527
+ changes.push({
528
+ path: absolute,
529
+ kind: "edit",
530
+ oldText,
531
+ newText
532
+ });
533
+ }
534
+ return changes;
535
+ }
536
+ /** Unescape the XML entities `svn status --xml` writes into paths. */
537
+ function xmlUnescape(value) {
538
+ return value.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&apos;/g, "'").replace(/&amp;/g, "&");
539
+ }
540
+ /** Enumerate the workspace's local changes in an svn working copy. */
541
+ async function svnChanges(input) {
542
+ const { root, workspaceRoot, includeUntracked, shell, readText, signal } = input;
543
+ const stdout = await runShell(shell, "svn status --xml", root, signal);
544
+ const changes = [];
545
+ const entryPattern = /<entry[^>]*path="([^"]*)"[^>]*>\s*<wc-status[^>]*item="([^"]*)"/g;
546
+ let match;
547
+ while ((match = entryPattern.exec(stdout)) !== null) {
548
+ const rel = xmlUnescape(match[1]);
549
+ const item = match[2];
550
+ const absolute = resolve(root, rel);
551
+ if (!isPathInside(absolute, workspaceRoot)) continue;
552
+ if (item === "modified" || item === "deleted") {
553
+ let oldText = "";
554
+ try {
555
+ oldText = await runShell(shell, `svn cat -r BASE ${shq(rel)}`, root, signal);
556
+ } catch {
557
+ oldText = "";
558
+ }
559
+ const newText = item === "deleted" ? "" : await readText(absolute) ?? "";
560
+ if (oldText === "" && newText === "") continue;
561
+ changes.push({
562
+ path: absolute,
563
+ kind: "edit",
564
+ oldText,
565
+ newText
566
+ });
567
+ } else if (item === "added") {
568
+ const newText = await readText(absolute) ?? "";
569
+ changes.push({
570
+ path: absolute,
571
+ kind: "create",
572
+ oldText: "",
573
+ newText
574
+ });
575
+ } else if (item === "unversioned" && includeUntracked) {
576
+ const newText = await readText(absolute) ?? "";
577
+ changes.push({
578
+ path: absolute,
579
+ kind: "create",
580
+ oldText: "",
581
+ newText
582
+ });
583
+ }
584
+ }
585
+ return changes;
586
+ }
587
+ /** One changed-file line from `p4 opened`/`p4 status`: the depot path and the
588
+ * action. The action keyword is found anywhere on the line, so both the
589
+ * `- edit`/`- add` and the `opened for edit`/`opened for add` wordings parse. */
590
+ function p4ChangeOf(line) {
591
+ const trimmed = line.trim();
592
+ if (trimmed === "") return void 0;
593
+ const depot = trimmed.split(/\s+/, 1)[0];
594
+ if (depot === void 0 || !depot.startsWith("//")) return void 0;
595
+ const action = /(move\/delete|move\/add|delete|integrate|branch|add|edit)/.exec(trimmed)?.[1] ?? "edit";
596
+ return {
597
+ depot: depot.replace(/#.*$/, ""),
598
+ action
599
+ };
600
+ }
601
+ /** Enumerate the workspace's locally changed files in a p4 client. With the
602
+ * untracked preference on a full workspace scan (`p4 status`) catches files
603
+ * not yet opened for add; off keeps to already-opened files (`p4 opened`) so
604
+ * the scan — which can be slow — is skipped. */
605
+ async function p4Changes(input) {
606
+ const { root, workspaceRoot, includeUntracked, shell, readText, signal } = input;
607
+ const stdout = await runShell(shell, includeUntracked ? "p4 status" : "p4 opened", root, signal);
608
+ const changes = [];
609
+ for (const line of stdout.split("\n")) {
610
+ const opened = p4ChangeOf(line);
611
+ if (opened === void 0) continue;
612
+ const local = (await runShell(shell, `p4 where ${shq(opened.depot)}`, root, signal)).trim().split(/\s+/).pop();
613
+ if (local === void 0 || local.length === 0) continue;
614
+ const absolute = resolve(local);
615
+ if (!isPathInside(absolute, workspaceRoot)) continue;
616
+ const deleted = opened.action === "delete" || opened.action === "move/delete";
617
+ const created = opened.action === "add" || opened.action === "move/add";
618
+ const newText = deleted ? "" : await readText(absolute) ?? "";
619
+ let oldText = "";
620
+ if (!created) try {
621
+ oldText = await runShell(shell, `p4 print -q ${shq(opened.depot)}#have`, root, signal);
622
+ } catch {
623
+ oldText = "";
624
+ }
625
+ if (oldText === "" && newText === "") continue;
626
+ changes.push({
627
+ path: absolute,
628
+ kind: created ? "create" : "edit",
629
+ oldText,
630
+ newText
631
+ });
632
+ }
633
+ return changes;
634
+ }
635
+ /**
636
+ * Enumerate the workspace's local changes for one VCS. Runs the VCS read-only
637
+ * commands and returns one {@link VcsChange} per changed file inside the
638
+ * workspace.
639
+ * @param input - the VCS, its root, the workspace root, and the reading tools.
640
+ * @returns the changes; an absent/unusable VCS surfaces as a thrown error.
641
+ */
642
+ async function listVcsChanges(input) {
643
+ switch (input.kind) {
644
+ case "git": return gitChanges(input);
645
+ case "svn": return svnChanges(input);
646
+ case "p4": return p4Changes(input);
647
+ }
648
+ }
649
+ //#endregion
367
650
  //#region lib/types/index.js
368
651
  /**
369
652
  * Pending-edit review, host half. Captures every successful `edit` and `write`
@@ -406,7 +689,8 @@ const name = "diff-approval";
406
689
  const inject = [
407
690
  "fs",
408
691
  "connection",
409
- "workspaceRegistry"
692
+ "workspaceRegistry",
693
+ "sessions"
410
694
  ];
411
695
  /** The connection RPC channel this plugin serves. */
412
696
  const DIFF_APPROVAL_CHANNEL = "/diff-approval";
@@ -626,14 +910,23 @@ function apply(ctx, config) {
626
910
  const newest = group[group.length - 1];
627
911
  if (newest === void 0) continue;
628
912
  const live = await liveStateOf(newest.path);
913
+ let adopted = newest.newText;
914
+ if (live.present && typeof live.content === "string" && live.content !== newest.newText) {
915
+ adopted = live.content;
916
+ if (store.update(newest.sessionId, newest.id, { newText: live.content })) await persistSession(newest.sessionId);
917
+ }
629
918
  const state = live.present ? {
630
919
  missing: false,
631
- diverged: live.content !== newest.newText
920
+ diverged: live.content !== adopted
632
921
  } : {
633
922
  missing: live.kind === "missing",
634
923
  diverged: live.kind === "unreadable"
635
924
  };
636
- for (const entry of group) listed.push({
925
+ for (const entry of group) listed.push(entry.id === newest.id ? {
926
+ ...entry,
927
+ newText: adopted,
928
+ ...state
929
+ } : {
637
930
  ...entry,
638
931
  ...state
639
932
  });
@@ -651,6 +944,79 @@ function apply(ctx, config) {
651
944
  for (const workspace of ctx.workspaceRegistry.list()) if (workspace.sessionIds.includes(sessionId)) return workspace;
652
945
  }
653
946
  /**
947
+ * Resolve the file-sandbox policy for one session's Revert write, or
948
+ * undefined when no confining backend is mounted. A live session carries
949
+ * its workspace root through `ctx.sessions`; a persisted entry from a
950
+ * session not live in this process falls back to the session's workspace
951
+ * path (else the deployment default). Without this the sandbox fences the
952
+ * write against the process cwd and denies it with "file access denied
953
+ * under workspace-write mode".
954
+ */
955
+ function sandboxPolicyOf(sessionId) {
956
+ const policy = ctx.get("sandboxPolicy");
957
+ if (policy === void 0) return void 0;
958
+ const session = ctx.sessions.get(sessionId);
959
+ if (session !== void 0) return policy.resolve({ session });
960
+ const workspace = workspaceOf(sessionId);
961
+ return workspace === void 0 ? policy.resolve({}) : {
962
+ mode: policy.defaultMode,
963
+ workspaceRoot: workspace.path
964
+ };
965
+ }
966
+ /**
967
+ * Write a Revert through `ctx.fs`, carrying the session's sandbox policy
968
+ * only when a confining backend is mounted (so the plain 4-arg call shape is
969
+ * preserved for the unsandboxed composition).
970
+ * @param target - the resolved target to write.
971
+ * @param content - the restored file content.
972
+ * @param sessionId - the entry's session, for the per-session policy.
973
+ * @param signal - aborts before atomic publication takes effect.
974
+ * @returns the write outcome.
975
+ */
976
+ async function writeRevert(target, content, sessionId, signal) {
977
+ const policy = sandboxPolicyOf(sessionId);
978
+ return policy === void 0 ? ctx.fs.writeText(target, content, void 0, signal) : ctx.fs.writeText(target, content, void 0, signal, policy);
979
+ }
980
+ const undoStacks = /* @__PURE__ */ new Map();
981
+ const redoStacks = /* @__PURE__ */ new Map();
982
+ function pushUndo(sessionId, before, after) {
983
+ const key = String(sessionId);
984
+ const stack = undoStacks.get(key) ?? [];
985
+ stack.push({
986
+ sessionId,
987
+ before,
988
+ after
989
+ });
990
+ undoStacks.set(key, stack);
991
+ redoStacks.delete(key);
992
+ }
993
+ /**
994
+ * Restore one snapshot (the before/after side of an undo pair). File writes
995
+ * carry the session sandbox policy; a divergence guard refuses to overwrite
996
+ * a file an outside writer has since changed. The store change is applied
997
+ * only after the write succeeds, keeping the restore all-or-nothing.
998
+ * @param sessionId - the owning session.
999
+ * @param state - the snapshot to restore.
1000
+ * @param expectedFile - the other side's file content, checked before a write.
1001
+ * @param signal - aborts before atomic publication takes effect.
1002
+ */
1003
+ async function restoreState(sessionId, state, expectedFile, signal) {
1004
+ if (state.fileText !== void 0) {
1005
+ const resolved = await ctx.fs.resolve(state.path, { signal });
1006
+ if (expectedFile?.fileText !== void 0) {
1007
+ if (await ctx.fs.readText(resolved, void 0) !== expectedFile.fileText) throw new Error("the file changed outside the review after the action; undo is unavailable");
1008
+ }
1009
+ await writeRevert(resolved, state.fileText, sessionId, signal);
1010
+ }
1011
+ if (state.batch !== void 0) {
1012
+ for (const item of state.batch) if (item.entry !== void 0) store.restore(sessionId, item.entry);
1013
+ else store.remove(sessionId, item.id);
1014
+ return;
1015
+ }
1016
+ if (state.entry !== void 0) store.restore(sessionId, state.entry);
1017
+ else store.remove(sessionId, state.id);
1018
+ }
1019
+ /**
654
1020
  * Record one session in its workspace's account. Every path that touches a
655
1021
  * session registers it, so the list merges all of a workspace's sessions'
656
1022
  * entries — a fresh session after restart still sees the workspace's
@@ -792,11 +1158,27 @@ function apply(ctx, config) {
792
1158
  const target = targetOf(payload);
793
1159
  if (target === void 0) return rpcError("sessionId and id must be non-empty strings");
794
1160
  await ensureLoaded(target.sessionId);
795
- const removed = store.remove(target.sessionId, target.id);
796
- if (removed) await persistSession(target.sessionId);
1161
+ const entry = store.get(target.sessionId, target.id);
1162
+ if (entry === void 0) return {
1163
+ ok: true,
1164
+ value: { outcome: "missing" }
1165
+ };
1166
+ store.remove(target.sessionId, target.id);
1167
+ pushUndo(target.sessionId, {
1168
+ id: entry.id,
1169
+ path: entry.path,
1170
+ entry,
1171
+ fileText: void 0
1172
+ }, {
1173
+ id: entry.id,
1174
+ path: entry.path,
1175
+ entry: void 0,
1176
+ fileText: void 0
1177
+ });
1178
+ await persistSession(target.sessionId);
797
1179
  return {
798
1180
  ok: true,
799
- value: { outcome: removed ? "kept" : "missing" }
1181
+ value: { outcome: "kept" }
800
1182
  };
801
1183
  }
802
1184
  case "revert": {
@@ -808,14 +1190,33 @@ function apply(ctx, config) {
808
1190
  ok: true,
809
1191
  value: { outcome: "missing" }
810
1192
  };
1193
+ let undo;
811
1194
  try {
812
1195
  const resolved = await ctx.fs.resolve(entry.path, { signal });
813
1196
  if (entry.kind === "create") await rm(ctx.fs.processPath(resolved), { force: true });
814
- else await ctx.fs.writeText(resolved, entry.oldText, void 0, signal);
1197
+ else {
1198
+ const preWrite = await ctx.fs.readText(resolved, void 0) ?? entry.newText;
1199
+ await writeRevert(resolved, entry.oldText, target.sessionId, signal);
1200
+ undo = {
1201
+ before: {
1202
+ id: entry.id,
1203
+ path: entry.path,
1204
+ entry,
1205
+ fileText: preWrite
1206
+ },
1207
+ after: {
1208
+ id: entry.id,
1209
+ path: entry.path,
1210
+ entry: void 0,
1211
+ fileText: entry.oldText
1212
+ }
1213
+ };
1214
+ }
815
1215
  } catch (error) {
816
1216
  return rpcError(`revert failed: ${errorMessage(error)}`);
817
1217
  }
818
1218
  store.remove(target.sessionId, target.id);
1219
+ if (undo !== void 0) pushUndo(target.sessionId, undo.before, undo.after);
819
1220
  await persistSession(target.sessionId);
820
1221
  return {
821
1222
  ok: true,
@@ -833,8 +1234,29 @@ function apply(ctx, config) {
833
1234
  };
834
1235
  const accepted = contentRangeOf(entry.newText, blockTarget.block.newStart, blockTarget.block.newEnd);
835
1236
  const updatedOld = replaceContentLines(entry.oldText, blockTarget.block.oldStart, blockTarget.block.oldEnd, accepted);
836
- if (updatedOld === entry.newText) store.remove(blockTarget.sessionId, blockTarget.id);
837
- else store.update(blockTarget.sessionId, blockTarget.id, { oldText: updatedOld });
1237
+ let afterEntry;
1238
+ if (updatedOld === entry.newText) {
1239
+ store.remove(blockTarget.sessionId, blockTarget.id);
1240
+ afterEntry = void 0;
1241
+ } else {
1242
+ store.update(blockTarget.sessionId, blockTarget.id, { oldText: updatedOld });
1243
+ afterEntry = {
1244
+ ...entry,
1245
+ oldText: updatedOld,
1246
+ updatedAt: Date.now()
1247
+ };
1248
+ }
1249
+ pushUndo(blockTarget.sessionId, {
1250
+ id: entry.id,
1251
+ path: entry.path,
1252
+ entry,
1253
+ fileText: void 0
1254
+ }, {
1255
+ id: entry.id,
1256
+ path: entry.path,
1257
+ entry: afterEntry,
1258
+ fileText: void 0
1259
+ });
838
1260
  await persistSession(blockTarget.sessionId);
839
1261
  return {
840
1262
  ok: true,
@@ -852,21 +1274,200 @@ function apply(ctx, config) {
852
1274
  };
853
1275
  const restored = contentRangeOf(entry.oldText, blockTarget.block.oldStart, blockTarget.block.oldEnd);
854
1276
  const updatedNew = replaceContentLines(entry.newText, blockTarget.block.newStart, blockTarget.block.newEnd, restored);
1277
+ let afterEntry;
1278
+ if (updatedNew === entry.oldText) {
1279
+ store.remove(blockTarget.sessionId, blockTarget.id);
1280
+ afterEntry = void 0;
1281
+ } else {
1282
+ store.update(blockTarget.sessionId, blockTarget.id, { newText: updatedNew });
1283
+ afterEntry = {
1284
+ ...entry,
1285
+ newText: updatedNew,
1286
+ updatedAt: Date.now()
1287
+ };
1288
+ }
1289
+ let undo;
855
1290
  try {
856
1291
  const resolved = await ctx.fs.resolve(entry.path, { signal });
857
1292
  if (entry.kind === "create" && updatedNew === "") await rm(ctx.fs.processPath(resolved), { force: true });
858
- else await ctx.fs.writeText(resolved, updatedNew, void 0, signal);
1293
+ else {
1294
+ const preWrite = await ctx.fs.readText(resolved, void 0) ?? entry.newText;
1295
+ await writeRevert(resolved, updatedNew, blockTarget.sessionId, signal);
1296
+ undo = {
1297
+ before: {
1298
+ id: entry.id,
1299
+ path: entry.path,
1300
+ entry,
1301
+ fileText: preWrite
1302
+ },
1303
+ after: {
1304
+ id: entry.id,
1305
+ path: entry.path,
1306
+ entry: afterEntry,
1307
+ fileText: updatedNew
1308
+ }
1309
+ };
1310
+ }
859
1311
  } catch (error) {
860
1312
  return rpcError(`block revert failed: ${errorMessage(error)}`);
861
1313
  }
862
- if (updatedNew === entry.oldText) store.remove(blockTarget.sessionId, blockTarget.id);
863
- else store.update(blockTarget.sessionId, blockTarget.id, { newText: updatedNew });
1314
+ if (undo !== void 0) pushUndo(blockTarget.sessionId, undo.before, undo.after);
864
1315
  await persistSession(blockTarget.sessionId);
865
1316
  return {
866
1317
  ok: true,
867
1318
  value: { outcome: "reverted" }
868
1319
  };
869
1320
  }
1321
+ case "undo": {
1322
+ const sessionId = sessionOf(payload);
1323
+ if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
1324
+ const key = String(sessionId);
1325
+ const stack = undoStacks.get(key) ?? [];
1326
+ const pair = stack.pop();
1327
+ if (pair === void 0) return {
1328
+ ok: true,
1329
+ value: { outcome: "nothing" }
1330
+ };
1331
+ try {
1332
+ await restoreState(sessionId, pair.before, pair.after, signal);
1333
+ } catch (error) {
1334
+ stack.push(pair);
1335
+ return rpcError(`undo failed: ${errorMessage(error)}`);
1336
+ }
1337
+ const redoStack = redoStacks.get(key) ?? [];
1338
+ redoStack.push(pair);
1339
+ redoStacks.set(key, redoStack);
1340
+ await persistSession(sessionId);
1341
+ return {
1342
+ ok: true,
1343
+ value: {
1344
+ outcome: "undone",
1345
+ id: pair.after.id
1346
+ }
1347
+ };
1348
+ }
1349
+ case "redo": {
1350
+ const sessionId = sessionOf(payload);
1351
+ if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
1352
+ const key = String(sessionId);
1353
+ const stack = redoStacks.get(key) ?? [];
1354
+ const pair = stack.pop();
1355
+ if (pair === void 0) return {
1356
+ ok: true,
1357
+ value: { outcome: "nothing" }
1358
+ };
1359
+ try {
1360
+ await restoreState(sessionId, pair.after, pair.before, signal);
1361
+ } catch (error) {
1362
+ stack.push(pair);
1363
+ return rpcError(`redo failed: ${errorMessage(error)}`);
1364
+ }
1365
+ const undoStack = undoStacks.get(key) ?? [];
1366
+ undoStack.push(pair);
1367
+ undoStacks.set(key, undoStack);
1368
+ await persistSession(sessionId);
1369
+ return {
1370
+ ok: true,
1371
+ value: {
1372
+ outcome: "redone",
1373
+ id: pair.after.id
1374
+ }
1375
+ };
1376
+ }
1377
+ case "vcs-import": {
1378
+ const sessionId = sessionOf(payload);
1379
+ if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
1380
+ const workspace = workspaceOf(sessionId);
1381
+ if (workspace === void 0) return rpcError("import unavailable: the session has no workspace");
1382
+ const root = detectVcsRoot(workspace.path);
1383
+ if (root === void 0) return {
1384
+ ok: true,
1385
+ value: {
1386
+ imported: 0,
1387
+ detected: false
1388
+ }
1389
+ };
1390
+ const shell = ctx.get("shell");
1391
+ if (shell === void 0) return rpcError("import unavailable: the deployment has no shell executor");
1392
+ const includeUntracked = payload.includeUntracked === true;
1393
+ const input = {
1394
+ kind: root.kind,
1395
+ root: root.root,
1396
+ workspaceRoot: workspace.path,
1397
+ includeUntracked,
1398
+ shell,
1399
+ readText: (path) => readFile(path, "utf8").catch(() => void 0),
1400
+ signal
1401
+ };
1402
+ let changes;
1403
+ try {
1404
+ changes = await listVcsChanges(input);
1405
+ } catch (error) {
1406
+ return rpcError(`import failed: ${errorMessage(error)}`);
1407
+ }
1408
+ await ensureLoaded(sessionId);
1409
+ const before = new Map(store.list(sessionId).map((entry) => [entry.path, entry]));
1410
+ let imported = 0;
1411
+ const changedPaths = [];
1412
+ for (const change of changes) {
1413
+ const entry = {
1414
+ id: randomUUID(),
1415
+ sessionId,
1416
+ path: change.path,
1417
+ kind: change.kind,
1418
+ oldText: change.oldText,
1419
+ newText: change.newText,
1420
+ updatedAt: Date.now()
1421
+ };
1422
+ if (store.fold(entry)) {
1423
+ imported += 1;
1424
+ changedPaths.push(change.path);
1425
+ }
1426
+ }
1427
+ if (imported > 0) {
1428
+ await persistSession(sessionId);
1429
+ const after = new Map(store.list(sessionId).map((entry) => [entry.path, entry]));
1430
+ const batchBefore = [];
1431
+ const batchAfter = [];
1432
+ for (const path of changedPaths) {
1433
+ const final = after.get(path);
1434
+ if (final === void 0) continue;
1435
+ const pre = before.get(path);
1436
+ batchAfter.push({
1437
+ id: final.id,
1438
+ path,
1439
+ entry: final,
1440
+ fileText: void 0
1441
+ });
1442
+ batchBefore.push({
1443
+ id: final.id,
1444
+ path,
1445
+ entry: pre,
1446
+ fileText: void 0
1447
+ });
1448
+ }
1449
+ if (batchBefore.length > 0) pushUndo(sessionId, {
1450
+ id: batchBefore[0].id,
1451
+ path: batchBefore[0].path,
1452
+ entry: void 0,
1453
+ fileText: void 0,
1454
+ batch: batchBefore
1455
+ }, {
1456
+ id: batchAfter[0].id,
1457
+ path: batchAfter[0].path,
1458
+ entry: void 0,
1459
+ fileText: void 0,
1460
+ batch: batchAfter
1461
+ });
1462
+ }
1463
+ return {
1464
+ ok: true,
1465
+ value: {
1466
+ imported,
1467
+ detected: true
1468
+ }
1469
+ };
1470
+ }
870
1471
  case "open": {
871
1472
  const target = openTargetOf(payload);
872
1473
  if (target === void 0) return rpcError("sessionId, id, and action must be valid");