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/README.md +18 -10
- package/README.zh.md +18 -10
- package/docs/images/pending-panel.png +0 -0
- package/docs/images/pending-panel.zh.png +0 -0
- package/lib/client.js +1017 -265
- package/lib/index.js +614 -13
- package/lib/types/client/PendingPanel.d.ts +11 -1
- package/lib/types/client/SettingsTab.d.ts +11 -0
- package/lib/types/client/locales.d.ts +40 -0
- package/lib/types/client/port.d.ts +7 -1
- package/lib/types/client/settings.d.ts +19 -0
- package/lib/types/client/slots.d.ts +11 -1
- package/lib/types/client/store.d.ts +7 -1
- package/lib/types/pending.d.ts +12 -0
- package/lib/types/types.d.ts +13 -2
- package/lib/types/vcs.d.ts +89 -0
- package/package.json +3 -1
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(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'").replace(/&/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 !==
|
|
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
|
|
796
|
-
if (
|
|
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:
|
|
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
|
|
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
|
-
|
|
837
|
-
|
|
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
|
|
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 (
|
|
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");
|