dsh-diff-approval 0.7.0 → 0.9.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";
@@ -580,9 +864,16 @@ function apply(ctx, config) {
580
864
  await ensureLoaded(sessionId);
581
865
  if (store.fold(entry)) await persistSession(sessionId);
582
866
  }
867
+ /** The stable `FsError.code`, when the thrown value carries one. */
868
+ function fsErrorCodeOf(error) {
869
+ if (typeof error !== "object" || error === null) return void 0;
870
+ const code = error.code;
871
+ return typeof code === "string" ? code : void 0;
872
+ }
583
873
  /**
584
- * Read one path's live state: present content, an unresolvable (missing)
585
- * path, or a resolved-but-unreadable file.
874
+ * Read one path's live state. The only existence test is `stat`: it returns
875
+ * `undefined` for an absent target (gone), so a deleted file never falls into
876
+ * the unreadable bucket. A file that exists but cannot be read is `unavailable`.
586
877
  * @param path - backend display path to probe through `ctx.fs`.
587
878
  * @returns the live state.
588
879
  */
@@ -590,22 +881,23 @@ function apply(ctx, config) {
590
881
  let target;
591
882
  try {
592
883
  target = await ctx.fs.resolve(path, {});
593
- } catch {
594
- return {
595
- present: false,
596
- kind: "missing"
597
- };
884
+ } catch (error) {
885
+ return fsErrorCodeOf(error) === "FS_NOT_FOUND" ? { kind: "deleted" } : { kind: "unavailable" };
598
886
  }
887
+ let info;
888
+ try {
889
+ info = await ctx.fs.stat(target, void 0);
890
+ } catch (error) {
891
+ return fsErrorCodeOf(error) === "FS_NOT_FOUND" ? { kind: "deleted" } : { kind: "unavailable" };
892
+ }
893
+ if (info === void 0) return { kind: "deleted" };
599
894
  try {
600
895
  return {
601
- present: true,
896
+ kind: "present",
602
897
  content: await ctx.fs.readText(target, void 0)
603
898
  };
604
- } catch {
605
- return {
606
- present: false,
607
- kind: "unreadable"
608
- };
899
+ } catch (error) {
900
+ return fsErrorCodeOf(error) === "FS_NOT_FOUND" ? { kind: "deleted" } : { kind: "unavailable" };
609
901
  }
610
902
  }
611
903
  /**
@@ -614,7 +906,32 @@ function apply(ctx, config) {
614
906
  * @param entries - the store's entries for one session.
615
907
  * @returns entries with `missing` and `diverged` set from the live file.
616
908
  */
617
- async function listWithState(entries) {
909
+ /** Drop every undo/redo pair that belongs to a removed entry, so the LIFO
910
+ * queue stays traversable without ever trying to restore an unreadable file. */
911
+ function purgeForEntry(sessionId, entryId, path) {
912
+ const key = String(sessionId);
913
+ for (const stacks of [undoStacks, redoStacks]) {
914
+ const stack = stacks.get(key);
915
+ if (stack === void 0) continue;
916
+ stacks.set(key, stack.filter((pair) => {
917
+ const touches = (state) => state.id === entryId || state.path === path;
918
+ const batch = pair.before.batch ?? pair.after.batch;
919
+ if (batch !== void 0) return !batch.some((item) => item.id === entryId || item.path === path);
920
+ return !touches(pair.before) && !touches(pair.after);
921
+ }));
922
+ }
923
+ }
924
+ /**
925
+ * Settle each listed entry against its live file. Existence is decided by the
926
+ * live state alone: a deleted file leaves the list as an undoable checkpoint
927
+ * (Ctrl+Z recreates it and restores the entry), an unavailable one leaves the
928
+ * list and has its undo/redo records dropped, and externally changed content
929
+ * is adopted as the new baseline with its own checkpoint.
930
+ * @param sessionId - the session being listed.
931
+ * @param entries - the store's entries for one session.
932
+ * @returns the listed entries plus whether an external change cleared redo.
933
+ */
934
+ async function listWithState(sessionId, entries) {
618
935
  const byPath = /* @__PURE__ */ new Map();
619
936
  for (const entry of entries) {
620
937
  const group = byPath.get(entry.path);
@@ -622,23 +939,76 @@ function apply(ctx, config) {
622
939
  else group.push(entry);
623
940
  }
624
941
  const listed = [];
942
+ let redoCleared = false;
625
943
  for (const group of byPath.values()) {
626
944
  const newest = group[group.length - 1];
627
945
  if (newest === void 0) continue;
628
946
  const live = await liveStateOf(newest.path);
629
- const state = live.present ? {
947
+ if (live.kind === "deleted") {
948
+ store.remove(sessionId, newest.id);
949
+ pushUndo(sessionId, {
950
+ id: newest.id,
951
+ path: newest.path,
952
+ entry: newest,
953
+ fileText: newest.newText
954
+ }, {
955
+ id: newest.id,
956
+ path: newest.path,
957
+ entry: void 0,
958
+ fileText: void 0
959
+ });
960
+ await persistSession(sessionId);
961
+ continue;
962
+ }
963
+ if (live.kind === "unavailable") {
964
+ store.remove(sessionId, newest.id);
965
+ purgeForEntry(sessionId, newest.id, newest.path);
966
+ await persistSession(sessionId);
967
+ continue;
968
+ }
969
+ const content = live.content;
970
+ let adopted = newest.newText;
971
+ const hasContent = typeof content === "string";
972
+ if (hasContent && content !== newest.newText) {
973
+ adopted = content;
974
+ const beforeText = newest.newText;
975
+ const redoWasPresent = redoStacks.has(String(sessionId));
976
+ store.update(sessionId, newest.id, { newText: content });
977
+ pushUndo(sessionId, {
978
+ id: newest.id,
979
+ path: newest.path,
980
+ entry: newest,
981
+ fileText: beforeText
982
+ }, {
983
+ id: newest.id,
984
+ path: newest.path,
985
+ entry: {
986
+ ...newest,
987
+ newText: content,
988
+ updatedAt: Date.now()
989
+ },
990
+ fileText: content
991
+ });
992
+ await persistSession(sessionId);
993
+ if (redoWasPresent) redoCleared = true;
994
+ }
995
+ const state = {
630
996
  missing: false,
631
- diverged: live.content !== newest.newText
632
- } : {
633
- missing: live.kind === "missing",
634
- diverged: live.kind === "unreadable"
997
+ diverged: hasContent ? content !== adopted : true
635
998
  };
636
- for (const entry of group) listed.push({
999
+ for (const entry of group) listed.push(entry.id === newest.id ? {
1000
+ ...entry,
1001
+ newText: adopted,
1002
+ ...state
1003
+ } : {
637
1004
  ...entry,
638
1005
  ...state
639
1006
  });
640
1007
  }
641
- return listed;
1008
+ return {
1009
+ files: listed,
1010
+ redoCleared
1011
+ };
642
1012
  }
643
1013
  /**
644
1014
  * The workspace whose session account holds `sessionId`. Web sessions are
@@ -651,6 +1021,79 @@ function apply(ctx, config) {
651
1021
  for (const workspace of ctx.workspaceRegistry.list()) if (workspace.sessionIds.includes(sessionId)) return workspace;
652
1022
  }
653
1023
  /**
1024
+ * Resolve the file-sandbox policy for one session's Revert write, or
1025
+ * undefined when no confining backend is mounted. A live session carries
1026
+ * its workspace root through `ctx.sessions`; a persisted entry from a
1027
+ * session not live in this process falls back to the session's workspace
1028
+ * path (else the deployment default). Without this the sandbox fences the
1029
+ * write against the process cwd and denies it with "file access denied
1030
+ * under workspace-write mode".
1031
+ */
1032
+ function sandboxPolicyOf(sessionId) {
1033
+ const policy = ctx.get("sandboxPolicy");
1034
+ if (policy === void 0) return void 0;
1035
+ const session = ctx.sessions.get(sessionId);
1036
+ if (session !== void 0) return policy.resolve({ session });
1037
+ const workspace = workspaceOf(sessionId);
1038
+ return workspace === void 0 ? policy.resolve({}) : {
1039
+ mode: policy.defaultMode,
1040
+ workspaceRoot: workspace.path
1041
+ };
1042
+ }
1043
+ /**
1044
+ * Write a Revert through `ctx.fs`, carrying the session's sandbox policy
1045
+ * only when a confining backend is mounted (so the plain 4-arg call shape is
1046
+ * preserved for the unsandboxed composition).
1047
+ * @param target - the resolved target to write.
1048
+ * @param content - the restored file content.
1049
+ * @param sessionId - the entry's session, for the per-session policy.
1050
+ * @param signal - aborts before atomic publication takes effect.
1051
+ * @returns the write outcome.
1052
+ */
1053
+ async function writeRevert(target, content, sessionId, signal) {
1054
+ const policy = sandboxPolicyOf(sessionId);
1055
+ return policy === void 0 ? ctx.fs.writeText(target, content, void 0, signal) : ctx.fs.writeText(target, content, void 0, signal, policy);
1056
+ }
1057
+ const undoStacks = /* @__PURE__ */ new Map();
1058
+ const redoStacks = /* @__PURE__ */ new Map();
1059
+ function pushUndo(sessionId, before, after) {
1060
+ const key = String(sessionId);
1061
+ const stack = undoStacks.get(key) ?? [];
1062
+ stack.push({
1063
+ sessionId,
1064
+ before,
1065
+ after
1066
+ });
1067
+ undoStacks.set(key, stack);
1068
+ redoStacks.delete(key);
1069
+ }
1070
+ /**
1071
+ * Restore one snapshot (the before/after side of an undo pair). File writes
1072
+ * carry the session sandbox policy; a divergence guard refuses to overwrite
1073
+ * a file an outside writer has since changed. The store change is applied
1074
+ * only after the write succeeds, keeping the restore all-or-nothing.
1075
+ * @param sessionId - the owning session.
1076
+ * @param state - the snapshot to restore.
1077
+ * @param expectedFile - the other side's file content, checked before a write.
1078
+ * @param signal - aborts before atomic publication takes effect.
1079
+ */
1080
+ async function restoreState(sessionId, state, expectedFile, signal) {
1081
+ if (state.fileText !== void 0) {
1082
+ const resolved = await ctx.fs.resolve(state.path, { signal });
1083
+ if (expectedFile?.fileText !== void 0) {
1084
+ 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");
1085
+ }
1086
+ await writeRevert(resolved, state.fileText, sessionId, signal);
1087
+ }
1088
+ if (state.batch !== void 0) {
1089
+ for (const item of state.batch) if (item.entry !== void 0) store.restore(sessionId, item.entry);
1090
+ else store.remove(sessionId, item.id);
1091
+ return;
1092
+ }
1093
+ if (state.entry !== void 0) store.restore(sessionId, state.entry);
1094
+ else store.remove(sessionId, state.id);
1095
+ }
1096
+ /**
654
1097
  * Record one session in its workspace's account. Every path that touches a
655
1098
  * session registers it, so the list merges all of a workspace's sessions'
656
1099
  * entries — a fresh session after restart still sees the workspace's
@@ -780,11 +1223,13 @@ function apply(ctx, config) {
780
1223
  case "list": {
781
1224
  const sessionId = sessionOf(payload);
782
1225
  if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
1226
+ const { files, redoCleared } = await listWithState(sessionId, await workspaceEntries(sessionId));
783
1227
  return {
784
1228
  ok: true,
785
1229
  value: {
786
- files: await listWithState(await workspaceEntries(sessionId)),
787
- workspacePath: workspaceOf(sessionId)?.path
1230
+ files,
1231
+ workspacePath: workspaceOf(sessionId)?.path,
1232
+ redoCleared: redoCleared || void 0
788
1233
  }
789
1234
  };
790
1235
  }
@@ -792,11 +1237,27 @@ function apply(ctx, config) {
792
1237
  const target = targetOf(payload);
793
1238
  if (target === void 0) return rpcError("sessionId and id must be non-empty strings");
794
1239
  await ensureLoaded(target.sessionId);
795
- const removed = store.remove(target.sessionId, target.id);
796
- if (removed) await persistSession(target.sessionId);
1240
+ const entry = store.get(target.sessionId, target.id);
1241
+ if (entry === void 0) return {
1242
+ ok: true,
1243
+ value: { outcome: "missing" }
1244
+ };
1245
+ store.remove(target.sessionId, target.id);
1246
+ pushUndo(target.sessionId, {
1247
+ id: entry.id,
1248
+ path: entry.path,
1249
+ entry,
1250
+ fileText: void 0
1251
+ }, {
1252
+ id: entry.id,
1253
+ path: entry.path,
1254
+ entry: void 0,
1255
+ fileText: void 0
1256
+ });
1257
+ await persistSession(target.sessionId);
797
1258
  return {
798
1259
  ok: true,
799
- value: { outcome: removed ? "kept" : "missing" }
1260
+ value: { outcome: "kept" }
800
1261
  };
801
1262
  }
802
1263
  case "revert": {
@@ -808,14 +1269,33 @@ function apply(ctx, config) {
808
1269
  ok: true,
809
1270
  value: { outcome: "missing" }
810
1271
  };
1272
+ let undo;
811
1273
  try {
812
1274
  const resolved = await ctx.fs.resolve(entry.path, { signal });
813
1275
  if (entry.kind === "create") await rm(ctx.fs.processPath(resolved), { force: true });
814
- else await ctx.fs.writeText(resolved, entry.oldText, void 0, signal);
1276
+ else {
1277
+ const preWrite = await ctx.fs.readText(resolved, void 0) ?? entry.newText;
1278
+ await writeRevert(resolved, entry.oldText, target.sessionId, signal);
1279
+ undo = {
1280
+ before: {
1281
+ id: entry.id,
1282
+ path: entry.path,
1283
+ entry,
1284
+ fileText: preWrite
1285
+ },
1286
+ after: {
1287
+ id: entry.id,
1288
+ path: entry.path,
1289
+ entry: void 0,
1290
+ fileText: entry.oldText
1291
+ }
1292
+ };
1293
+ }
815
1294
  } catch (error) {
816
1295
  return rpcError(`revert failed: ${errorMessage(error)}`);
817
1296
  }
818
1297
  store.remove(target.sessionId, target.id);
1298
+ if (undo !== void 0) pushUndo(target.sessionId, undo.before, undo.after);
819
1299
  await persistSession(target.sessionId);
820
1300
  return {
821
1301
  ok: true,
@@ -833,8 +1313,29 @@ function apply(ctx, config) {
833
1313
  };
834
1314
  const accepted = contentRangeOf(entry.newText, blockTarget.block.newStart, blockTarget.block.newEnd);
835
1315
  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 });
1316
+ let afterEntry;
1317
+ if (updatedOld === entry.newText) {
1318
+ store.remove(blockTarget.sessionId, blockTarget.id);
1319
+ afterEntry = void 0;
1320
+ } else {
1321
+ store.update(blockTarget.sessionId, blockTarget.id, { oldText: updatedOld });
1322
+ afterEntry = {
1323
+ ...entry,
1324
+ oldText: updatedOld,
1325
+ updatedAt: Date.now()
1326
+ };
1327
+ }
1328
+ pushUndo(blockTarget.sessionId, {
1329
+ id: entry.id,
1330
+ path: entry.path,
1331
+ entry,
1332
+ fileText: void 0
1333
+ }, {
1334
+ id: entry.id,
1335
+ path: entry.path,
1336
+ entry: afterEntry,
1337
+ fileText: void 0
1338
+ });
838
1339
  await persistSession(blockTarget.sessionId);
839
1340
  return {
840
1341
  ok: true,
@@ -852,21 +1353,200 @@ function apply(ctx, config) {
852
1353
  };
853
1354
  const restored = contentRangeOf(entry.oldText, blockTarget.block.oldStart, blockTarget.block.oldEnd);
854
1355
  const updatedNew = replaceContentLines(entry.newText, blockTarget.block.newStart, blockTarget.block.newEnd, restored);
1356
+ let afterEntry;
1357
+ if (updatedNew === entry.oldText) {
1358
+ store.remove(blockTarget.sessionId, blockTarget.id);
1359
+ afterEntry = void 0;
1360
+ } else {
1361
+ store.update(blockTarget.sessionId, blockTarget.id, { newText: updatedNew });
1362
+ afterEntry = {
1363
+ ...entry,
1364
+ newText: updatedNew,
1365
+ updatedAt: Date.now()
1366
+ };
1367
+ }
1368
+ let undo;
855
1369
  try {
856
1370
  const resolved = await ctx.fs.resolve(entry.path, { signal });
857
1371
  if (entry.kind === "create" && updatedNew === "") await rm(ctx.fs.processPath(resolved), { force: true });
858
- else await ctx.fs.writeText(resolved, updatedNew, void 0, signal);
1372
+ else {
1373
+ const preWrite = await ctx.fs.readText(resolved, void 0) ?? entry.newText;
1374
+ await writeRevert(resolved, updatedNew, blockTarget.sessionId, signal);
1375
+ undo = {
1376
+ before: {
1377
+ id: entry.id,
1378
+ path: entry.path,
1379
+ entry,
1380
+ fileText: preWrite
1381
+ },
1382
+ after: {
1383
+ id: entry.id,
1384
+ path: entry.path,
1385
+ entry: afterEntry,
1386
+ fileText: updatedNew
1387
+ }
1388
+ };
1389
+ }
859
1390
  } catch (error) {
860
1391
  return rpcError(`block revert failed: ${errorMessage(error)}`);
861
1392
  }
862
- if (updatedNew === entry.oldText) store.remove(blockTarget.sessionId, blockTarget.id);
863
- else store.update(blockTarget.sessionId, blockTarget.id, { newText: updatedNew });
1393
+ if (undo !== void 0) pushUndo(blockTarget.sessionId, undo.before, undo.after);
864
1394
  await persistSession(blockTarget.sessionId);
865
1395
  return {
866
1396
  ok: true,
867
1397
  value: { outcome: "reverted" }
868
1398
  };
869
1399
  }
1400
+ case "undo": {
1401
+ const sessionId = sessionOf(payload);
1402
+ if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
1403
+ const key = String(sessionId);
1404
+ const stack = undoStacks.get(key) ?? [];
1405
+ const pair = stack.pop();
1406
+ if (pair === void 0) return {
1407
+ ok: true,
1408
+ value: { outcome: "nothing" }
1409
+ };
1410
+ try {
1411
+ await restoreState(sessionId, pair.before, pair.after, signal);
1412
+ } catch (error) {
1413
+ stack.push(pair);
1414
+ return rpcError(`undo failed: ${errorMessage(error)}`);
1415
+ }
1416
+ const redoStack = redoStacks.get(key) ?? [];
1417
+ redoStack.push(pair);
1418
+ redoStacks.set(key, redoStack);
1419
+ await persistSession(sessionId);
1420
+ return {
1421
+ ok: true,
1422
+ value: {
1423
+ outcome: "undone",
1424
+ id: pair.after.id
1425
+ }
1426
+ };
1427
+ }
1428
+ case "redo": {
1429
+ const sessionId = sessionOf(payload);
1430
+ if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
1431
+ const key = String(sessionId);
1432
+ const stack = redoStacks.get(key) ?? [];
1433
+ const pair = stack.pop();
1434
+ if (pair === void 0) return {
1435
+ ok: true,
1436
+ value: { outcome: "nothing" }
1437
+ };
1438
+ try {
1439
+ await restoreState(sessionId, pair.after, pair.before, signal);
1440
+ } catch (error) {
1441
+ stack.push(pair);
1442
+ return rpcError(`redo failed: ${errorMessage(error)}`);
1443
+ }
1444
+ const undoStack = undoStacks.get(key) ?? [];
1445
+ undoStack.push(pair);
1446
+ undoStacks.set(key, undoStack);
1447
+ await persistSession(sessionId);
1448
+ return {
1449
+ ok: true,
1450
+ value: {
1451
+ outcome: "redone",
1452
+ id: pair.after.id
1453
+ }
1454
+ };
1455
+ }
1456
+ case "vcs-import": {
1457
+ const sessionId = sessionOf(payload);
1458
+ if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
1459
+ const workspace = workspaceOf(sessionId);
1460
+ if (workspace === void 0) return rpcError("import unavailable: the session has no workspace");
1461
+ const root = detectVcsRoot(workspace.path);
1462
+ if (root === void 0) return {
1463
+ ok: true,
1464
+ value: {
1465
+ imported: 0,
1466
+ detected: false
1467
+ }
1468
+ };
1469
+ const shell = ctx.get("shell");
1470
+ if (shell === void 0) return rpcError("import unavailable: the deployment has no shell executor");
1471
+ const includeUntracked = payload.includeUntracked === true;
1472
+ const input = {
1473
+ kind: root.kind,
1474
+ root: root.root,
1475
+ workspaceRoot: workspace.path,
1476
+ includeUntracked,
1477
+ shell,
1478
+ readText: (path) => readFile(path, "utf8").catch(() => void 0),
1479
+ signal
1480
+ };
1481
+ let changes;
1482
+ try {
1483
+ changes = await listVcsChanges(input);
1484
+ } catch (error) {
1485
+ return rpcError(`import failed: ${errorMessage(error)}`);
1486
+ }
1487
+ await ensureLoaded(sessionId);
1488
+ const before = new Map(store.list(sessionId).map((entry) => [entry.path, entry]));
1489
+ let imported = 0;
1490
+ const changedPaths = [];
1491
+ for (const change of changes) {
1492
+ const entry = {
1493
+ id: randomUUID(),
1494
+ sessionId,
1495
+ path: change.path,
1496
+ kind: change.kind,
1497
+ oldText: change.oldText,
1498
+ newText: change.newText,
1499
+ updatedAt: Date.now()
1500
+ };
1501
+ if (store.fold(entry)) {
1502
+ imported += 1;
1503
+ changedPaths.push(change.path);
1504
+ }
1505
+ }
1506
+ if (imported > 0) {
1507
+ await persistSession(sessionId);
1508
+ const after = new Map(store.list(sessionId).map((entry) => [entry.path, entry]));
1509
+ const batchBefore = [];
1510
+ const batchAfter = [];
1511
+ for (const path of changedPaths) {
1512
+ const final = after.get(path);
1513
+ if (final === void 0) continue;
1514
+ const pre = before.get(path);
1515
+ batchAfter.push({
1516
+ id: final.id,
1517
+ path,
1518
+ entry: final,
1519
+ fileText: void 0
1520
+ });
1521
+ batchBefore.push({
1522
+ id: final.id,
1523
+ path,
1524
+ entry: pre,
1525
+ fileText: void 0
1526
+ });
1527
+ }
1528
+ if (batchBefore.length > 0) pushUndo(sessionId, {
1529
+ id: batchBefore[0].id,
1530
+ path: batchBefore[0].path,
1531
+ entry: void 0,
1532
+ fileText: void 0,
1533
+ batch: batchBefore
1534
+ }, {
1535
+ id: batchAfter[0].id,
1536
+ path: batchAfter[0].path,
1537
+ entry: void 0,
1538
+ fileText: void 0,
1539
+ batch: batchAfter
1540
+ });
1541
+ }
1542
+ return {
1543
+ ok: true,
1544
+ value: {
1545
+ imported,
1546
+ detected: true
1547
+ }
1548
+ };
1549
+ }
870
1550
  case "open": {
871
1551
  const target = openTargetOf(payload);
872
1552
  if (target === void 0) return rpcError("sessionId, id, and action must be valid");