dsh-diff-approval 0.19.2 → 0.20.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
@@ -138,6 +138,18 @@ var PendingDiffStore = class {
138
138
  return true;
139
139
  }
140
140
  /**
141
+ * Admit one entry into the list without the change guard {@link fold} applies.
142
+ * A listed entry may carry no diff at all: a path the user added by hand has
143
+ * no local change until one appears, and a fully-resolved file has already
144
+ * folded its diff away. This is the guard-free put {@link restore} performs,
145
+ * named for its other caller.
146
+ * @param entry - the entry to insert or replace by path.
147
+ * @returns whether the store changed.
148
+ */
149
+ insert(entry) {
150
+ return this.restore(entry);
151
+ }
152
+ /**
141
153
  * Merge persisted entries into the store, one per path after folding. A live
142
154
  * entry wins over a persisted one only when its time is newer (folders are
143
155
  * applied in capture order, so a later persisted capture is strictly newer).
@@ -440,6 +452,18 @@ function isPathInside(absolutePath, root) {
440
452
  if (path === base) return true;
441
453
  return path.startsWith(base + sep);
442
454
  }
455
+ /**
456
+ * Whether one changed path belongs to the scan: inside the workspace, and inside
457
+ * the narrowed `scope` when the caller set one.
458
+ * @param absolutePath - the changed file's absolute path.
459
+ * @param workspaceRoot - the session's workspace root.
460
+ * @param scope - one path to restrict the scan to, or undefined for all of it.
461
+ * @returns whether the change is in scope.
462
+ */
463
+ function inScanScope(absolutePath, workspaceRoot, scope) {
464
+ if (!isPathInside(absolutePath, workspaceRoot)) return false;
465
+ return scope === void 0 || isPathInside(absolutePath, scope);
466
+ }
443
467
  /** The VCS marker of one directory, or undefined when it holds none. */
444
468
  function markerOf(directory) {
445
469
  if (existsSync(resolve(directory, ".git"))) return "git";
@@ -511,12 +535,12 @@ function parseGitPorcelainZ(output) {
511
535
  }
512
536
  /** Enumerate the workspace's local changes in a git checkout. */
513
537
  async function gitChanges(input) {
514
- const { root, workspaceRoot, includeUntracked, shell, readText, signal } = input;
538
+ const { root, workspaceRoot, includeUntracked, scope, shell, readText, signal } = input;
515
539
  const stdout = await runShell(shell, "git -c status.renames=false status --porcelain=v1 -z --untracked-files=all", root, signal);
516
540
  const changes = [];
517
541
  for (const { xy, rel } of parseGitPorcelainZ(stdout)) {
518
542
  const absolute = resolve(root, rel);
519
- if (!isPathInside(absolute, workspaceRoot)) continue;
543
+ if (!inScanScope(absolute, workspaceRoot, scope)) continue;
520
544
  if (xy === "??") {
521
545
  if (!includeUntracked) continue;
522
546
  const newText = await readText(absolute) ?? "";
@@ -559,7 +583,7 @@ function xmlUnescape(value) {
559
583
  }
560
584
  /** Enumerate the workspace's local changes in an svn working copy. */
561
585
  async function svnChanges(input) {
562
- const { root, workspaceRoot, includeUntracked, shell, readText, signal } = input;
586
+ const { root, workspaceRoot, includeUntracked, scope, shell, readText, signal } = input;
563
587
  const stdout = await runShell(shell, "svn status --xml", root, signal);
564
588
  const changes = [];
565
589
  const entryPattern = /<entry[^>]*path="([^"]*)"[^>]*>\s*<wc-status[^>]*item="([^"]*)"/g;
@@ -568,7 +592,7 @@ async function svnChanges(input) {
568
592
  const rel = xmlUnescape(match[1]);
569
593
  const item = match[2];
570
594
  const absolute = resolve(root, rel);
571
- if (!isPathInside(absolute, workspaceRoot)) continue;
595
+ if (!inScanScope(absolute, workspaceRoot, scope)) continue;
572
596
  if (item === "modified" || item === "deleted") {
573
597
  let oldText = "";
574
598
  try {
@@ -623,7 +647,7 @@ function p4ChangeOf(line) {
623
647
  * not yet opened for add; off keeps to already-opened files (`p4 opened`) so
624
648
  * the scan — which can be slow — is skipped. */
625
649
  async function p4Changes(input) {
626
- const { root, workspaceRoot, includeUntracked, shell, readText, signal } = input;
650
+ const { root, workspaceRoot, includeUntracked, scope, shell, readText, signal } = input;
627
651
  const stdout = await runShell(shell, includeUntracked ? "p4 status" : "p4 opened", root, signal);
628
652
  const changes = [];
629
653
  for (const line of stdout.split("\n")) {
@@ -632,7 +656,7 @@ async function p4Changes(input) {
632
656
  const local = (await runShell(shell, `p4 where ${shq(opened.depot)}`, root, signal)).trim().split(/\s+/).pop();
633
657
  if (local === void 0 || local.length === 0) continue;
634
658
  const absolute = resolve(local);
635
- if (!isPathInside(absolute, workspaceRoot)) continue;
659
+ if (!inScanScope(absolute, workspaceRoot, scope)) continue;
636
660
  const deleted = opened.action === "delete" || opened.action === "move/delete";
637
661
  const created = opened.action === "add" || opened.action === "move/add";
638
662
  const newText = deleted ? "" : await readText(absolute) ?? "";
@@ -808,6 +832,50 @@ function writeOutcomeOf(value) {
808
832
  function errorMessage(error) {
809
833
  return error instanceof Error ? error.message : String(error);
810
834
  }
835
+ /** Directories one browse level hides: VCS/build noise a review list never wants.
836
+ * The path box can still reach them by typing the path outright. */
837
+ const BROWSE_HIDDEN_NAMES = /* @__PURE__ */ new Set([".git", "node_modules"]);
838
+ /** Cap on one browse level's children: the panel renders a list, not a dump of a
839
+ * huge directory, and it reports `truncated` rather than silently cutting. */
840
+ const BROWSE_ENTRY_CAP = 500;
841
+ /** Cap on the files one no-change walk reads: ticking the box on a directory
842
+ * asks for "everything under here", which must not mean reading a whole tree
843
+ * (every file's text) inside one call. */
844
+ const ADD_UNCHANGED_CAP = 300;
845
+ /**
846
+ * One absolute path as a workspace-relative path with `/` separators, or
847
+ * `undefined` when it lies outside the root. `''` is the root itself.
848
+ * @param root - the workspace root.
849
+ * @param absolute - the path to express relative to it.
850
+ * @returns the relative path, or undefined when outside.
851
+ */
852
+ function workspaceRelativeOf(root, absolute) {
853
+ const rel = relative(resolve(root), resolve(absolute));
854
+ if (rel === "") return "";
855
+ if (isAbsolute(rel)) return void 0;
856
+ const parts = rel.split(/[\\/]/);
857
+ if (parts[0] === "..") return void 0;
858
+ return parts.join("/");
859
+ }
860
+ /**
861
+ * Resolve one caller-supplied path against the workspace root. A relative path
862
+ * is taken as workspace-relative; an absolute one must still land inside.
863
+ * @param root - the workspace root.
864
+ * @param input - the caller's path (absolute or workspace-relative).
865
+ * @returns the absolute path, or undefined when it escapes the workspace.
866
+ */
867
+ function resolveInsideWorkspace(root, input) {
868
+ const absolute = isAbsolute(input) ? resolve(input) : resolve(root, input);
869
+ return workspaceRelativeOf(root, absolute) === void 0 ? void 0 : absolute;
870
+ }
871
+ /** The workspace-relative parent of a relative directory path (`''` at the root). */
872
+ /** Fold one path for comparison: absolute, `/`-separated, case-folded on Windows.
873
+ * Entries are keyed by the path spelling their capture carried (a tool's display
874
+ * path or a scan's absolute one), so an equality test has to normalize first. */
875
+ function pathIdentity(absolute) {
876
+ const unified = resolve(absolute).split(/[\\/]/).join("/");
877
+ return process.platform === "win32" ? unified.toLowerCase() : unified;
878
+ }
811
879
  /** Narrow a tool-execution-shaped value to its name, call id, and agent. */
812
880
  function actorOf(value) {
813
881
  if (typeof value !== "object" || value === null) return void 0;
@@ -1091,6 +1159,138 @@ function apply(ctx, config) {
1091
1159
  };
1092
1160
  }
1093
1161
  /**
1162
+ * Fold a batch of entries into one session's list as a single undoable action.
1163
+ * Nothing touches the files, so the batch is undone by restoring each affected
1164
+ * path's pre-fold entry (or removing it when the path was not listed), which is
1165
+ * what the import and the hand-add path both want.
1166
+ * @param sessionId - the session whose list gains the entries.
1167
+ * @param entries - the entries to fold, in capture order.
1168
+ * @param admitNoDiff - also admit entries that carry no diff at all (the
1169
+ * guard-free insert), which is how a hand-added clean path is listed.
1170
+ * @returns how many entries landed; 0 leaves the store, persistence, and the
1171
+ * undo queue untouched.
1172
+ */
1173
+ async function foldBatch(sessionId, entries, admitNoDiff = false) {
1174
+ await ensureLoaded();
1175
+ const before = new Map(store.list(sessionId).map((entry) => [entry.path, entry]));
1176
+ let folded = 0;
1177
+ const changedPaths = [];
1178
+ for (const entry of entries) if (entry.oldText === entry.newText ? admitNoDiff && store.insert(entry) : store.fold(entry)) {
1179
+ folded += 1;
1180
+ changedPaths.push(entry.path);
1181
+ }
1182
+ if (folded === 0) return 0;
1183
+ persistSession(true);
1184
+ const after = new Map(store.list(sessionId).map((entry) => [entry.path, entry]));
1185
+ const batchBefore = [];
1186
+ const batchAfter = [];
1187
+ for (const path of changedPaths) {
1188
+ const final = after.get(path);
1189
+ if (final === void 0) continue;
1190
+ const pre = before.get(path);
1191
+ batchAfter.push({
1192
+ id: final.id,
1193
+ path,
1194
+ entry: final,
1195
+ fileText: void 0
1196
+ });
1197
+ batchBefore.push({
1198
+ id: final.id,
1199
+ path,
1200
+ entry: pre,
1201
+ fileText: void 0
1202
+ });
1203
+ }
1204
+ if (batchBefore.length > 0) pushUndo(sessionId, {
1205
+ id: batchBefore[0].id,
1206
+ path: batchBefore[0].path,
1207
+ entry: void 0,
1208
+ fileText: void 0,
1209
+ batch: batchBefore
1210
+ }, {
1211
+ id: batchAfter[0].id,
1212
+ path: batchAfter[0].path,
1213
+ entry: void 0,
1214
+ fileText: void 0,
1215
+ batch: batchAfter
1216
+ });
1217
+ return folded;
1218
+ }
1219
+ /**
1220
+ * One path's text, or `undefined` when it is absent or not readable as text
1221
+ * (a binary, an unreadable permission). An empty file reads as `''`, which is
1222
+ * a value: a listed entry may carry no content at all.
1223
+ * @param absolute - the path to read.
1224
+ * @param signal - caller lifetime.
1225
+ * @returns the text, or undefined.
1226
+ */
1227
+ async function readTextOrNone(absolute, signal) {
1228
+ try {
1229
+ return await ctx.fs.readText(await ctx.fs.resolve(absolute, { signal }), signal);
1230
+ } catch {
1231
+ return;
1232
+ }
1233
+ }
1234
+ /**
1235
+ * Every regular file under one directory, with its text. Breadth-first in the
1236
+ * backend's name order, hiding the browse's noise names, skipping whatever is
1237
+ * not readable as text, and stopping at {@link ADD_UNCHANGED_CAP} files so
1238
+ * "include paths with no change" cannot read an unbounded tree in one call.
1239
+ * Symlinked directories are followed once, so a cycle ends rather than loops.
1240
+ * @param root - the resolved directory target to walk.
1241
+ * @param rootAbsolute - that directory's absolute path.
1242
+ * @param signal - caller lifetime.
1243
+ * @returns the files found and whether the cap cut the walk short.
1244
+ */
1245
+ async function collectFilesUnder(root, rootAbsolute, signal) {
1246
+ const files = [];
1247
+ const visited = /* @__PURE__ */ new Set();
1248
+ if (typeof root.targetKey === "string" && root.targetKey !== "") visited.add(root.targetKey);
1249
+ const queue = [{
1250
+ absolute: rootAbsolute,
1251
+ target: root
1252
+ }];
1253
+ while (queue.length > 0) {
1254
+ const current = queue.shift();
1255
+ if (current === void 0) break;
1256
+ let children;
1257
+ try {
1258
+ children = await ctx.fs.listDir(current.target, signal);
1259
+ } catch {
1260
+ continue;
1261
+ }
1262
+ for (const child of children) {
1263
+ if (BROWSE_HIDDEN_NAMES.has(child.name)) continue;
1264
+ const absolute = resolve(current.absolute, child.name);
1265
+ if (child.type === "directory") {
1266
+ const key = child.target.targetKey;
1267
+ if (typeof key === "string" && key !== "") {
1268
+ if (visited.has(key)) continue;
1269
+ visited.add(key);
1270
+ }
1271
+ queue.push({
1272
+ absolute,
1273
+ target: child.target
1274
+ });
1275
+ continue;
1276
+ }
1277
+ if (child.type !== "file") continue;
1278
+ if (files.length >= ADD_UNCHANGED_CAP) return {
1279
+ files,
1280
+ truncated: true
1281
+ };
1282
+ files.push({
1283
+ path: absolute,
1284
+ content: await readTextOrNone(absolute, signal)
1285
+ });
1286
+ }
1287
+ }
1288
+ return {
1289
+ files,
1290
+ truncated: false
1291
+ };
1292
+ }
1293
+ /**
1094
1294
  * The workspace whose session account holds `sessionId`. Web sessions are
1095
1295
  * attached to a workspace at creation, so an unowned session is the
1096
1296
  * memory-only edge (its entries never persist).
@@ -1298,6 +1498,33 @@ function apply(ctx, config) {
1298
1498
  value: { outcome: "missing" }
1299
1499
  };
1300
1500
  }
1501
+ if (payload.keepListed === true) {
1502
+ store.update(target.id, { oldText: entry.newText });
1503
+ const afterEntry = {
1504
+ ...entry,
1505
+ oldText: entry.newText,
1506
+ updatedAt: Date.now()
1507
+ };
1508
+ pushUndo(target.sessionId, {
1509
+ id: entry.path,
1510
+ path: entry.path,
1511
+ entry,
1512
+ fileText: void 0
1513
+ }, {
1514
+ id: entry.path,
1515
+ path: entry.path,
1516
+ entry: afterEntry,
1517
+ fileText: void 0
1518
+ });
1519
+ persistSession(true);
1520
+ return {
1521
+ ok: true,
1522
+ value: {
1523
+ outcome: "kept",
1524
+ resolved: true
1525
+ }
1526
+ };
1527
+ }
1301
1528
  store.remove(target.id);
1302
1529
  pushUndo(target.sessionId, {
1303
1530
  id: entry.path,
@@ -1334,6 +1561,34 @@ function apply(ctx, config) {
1334
1561
  } catch (error) {
1335
1562
  return rpcError(`revert failed: ${errorMessage(error)}`);
1336
1563
  }
1564
+ if (payload.keepListed === true) {
1565
+ const content = undo?.after.fileText ?? "";
1566
+ store.update(target.id, { newText: content });
1567
+ const afterEntry = {
1568
+ ...entry,
1569
+ newText: content,
1570
+ updatedAt: Date.now()
1571
+ };
1572
+ if (undo !== void 0) pushUndo(target.sessionId, {
1573
+ id: entry.path,
1574
+ path: entry.path,
1575
+ entry,
1576
+ fileText: undo.before.fileText
1577
+ }, {
1578
+ id: entry.path,
1579
+ path: entry.path,
1580
+ entry: afterEntry,
1581
+ fileText: content
1582
+ });
1583
+ persistSession(true);
1584
+ return {
1585
+ ok: true,
1586
+ value: {
1587
+ outcome: "reverted",
1588
+ resolved: true
1589
+ }
1590
+ };
1591
+ }
1337
1592
  store.remove(target.id);
1338
1593
  if (undo !== void 0) pushUndo(target.sessionId, undo.before, undo.after);
1339
1594
  persistSession(true);
@@ -1623,67 +1878,256 @@ function apply(ctx, config) {
1623
1878
  } catch (error) {
1624
1879
  return rpcError(`import failed: ${errorMessage(error)}`);
1625
1880
  }
1881
+ return {
1882
+ ok: true,
1883
+ value: {
1884
+ imported: await foldBatch(sessionId, changes.map((change) => ({
1885
+ id: change.path,
1886
+ sessionId,
1887
+ path: change.path,
1888
+ kind: change.kind,
1889
+ oldText: change.oldText,
1890
+ newText: change.newText,
1891
+ updatedAt: Date.now(),
1892
+ sessionIds: [sessionId]
1893
+ }))),
1894
+ detected: true
1895
+ }
1896
+ };
1897
+ }
1898
+ case "vcs-refresh": {
1899
+ const target = targetOf(payload);
1900
+ if (target === void 0) return rpcError("sessionId and id must be non-empty strings");
1626
1901
  await ensureLoaded();
1627
- const before = new Map(store.list(sessionId).map((entry) => [entry.path, entry]));
1628
- let imported = 0;
1629
- const changedPaths = [];
1630
- for (const change of changes) {
1631
- const entry = {
1632
- id: change.path,
1633
- sessionId,
1634
- path: change.path,
1635
- kind: change.kind,
1636
- oldText: change.oldText,
1637
- newText: change.newText,
1638
- updatedAt: Date.now(),
1639
- sessionIds: [sessionId]
1640
- };
1641
- if (store.fold(entry)) {
1642
- imported += 1;
1643
- changedPaths.push(change.path);
1902
+ const entry = store.get(target.id);
1903
+ if (entry === void 0) return {
1904
+ ok: true,
1905
+ value: { outcome: "missing" }
1906
+ };
1907
+ const workspace = workspaceOf(target.sessionId);
1908
+ if (workspace === void 0) return rpcError("refresh unavailable: the session has no workspace");
1909
+ const root = detectVcsRoot(workspace.path);
1910
+ if (root === void 0) return {
1911
+ ok: true,
1912
+ value: { outcome: "no-vcs" }
1913
+ };
1914
+ const shell = ctx.get("shell");
1915
+ if (shell === void 0) return rpcError("refresh unavailable: the deployment has no shell executor");
1916
+ let changes;
1917
+ try {
1918
+ changes = await listVcsChanges({
1919
+ kind: root.kind,
1920
+ root: root.root,
1921
+ workspaceRoot: workspace.path,
1922
+ includeUntracked: payload.includeUntracked === true,
1923
+ scope: entry.path,
1924
+ shell,
1925
+ readText: (path) => readFile(path, "utf8").catch(() => void 0),
1926
+ signal
1927
+ });
1928
+ } catch (error) {
1929
+ return rpcError(`refresh failed: ${errorMessage(error)}`);
1930
+ }
1931
+ const change = changes.find((candidate) => pathIdentity(candidate.path) === pathIdentity(entry.path));
1932
+ if (change === void 0) return {
1933
+ ok: true,
1934
+ value: { outcome: "no-change" }
1935
+ };
1936
+ if (change.kind === entry.kind && change.oldText === entry.oldText && change.newText === entry.newText) return {
1937
+ ok: true,
1938
+ value: { outcome: "unchanged" }
1939
+ };
1940
+ const refreshed = {
1941
+ ...entry,
1942
+ kind: change.kind,
1943
+ oldText: change.oldText,
1944
+ newText: change.newText,
1945
+ updatedAt: Date.now()
1946
+ };
1947
+ store.restore(refreshed);
1948
+ pushUndo(target.sessionId, {
1949
+ id: entry.path,
1950
+ path: entry.path,
1951
+ entry,
1952
+ fileText: void 0
1953
+ }, {
1954
+ id: entry.path,
1955
+ path: entry.path,
1956
+ entry: refreshed,
1957
+ fileText: void 0
1958
+ });
1959
+ persistSession(true);
1960
+ return {
1961
+ ok: true,
1962
+ value: { outcome: "refreshed" }
1963
+ };
1964
+ }
1965
+ case "list-path": {
1966
+ const sessionId = sessionOf(payload);
1967
+ if (sessionId === void 0) return rpcError("sessionId must be a non-empty string");
1968
+ const workspace = workspaceOf(sessionId);
1969
+ if (workspace === void 0) return rpcError("browse unavailable: the session has no workspace");
1970
+ const requested = pathFieldOf(payload) ?? "";
1971
+ const absolute = resolveInsideWorkspace(workspace.path, requested);
1972
+ if (absolute === void 0) return rpcError("browse failed: the path is outside the workspace");
1973
+ let children;
1974
+ try {
1975
+ const target = await ctx.fs.resolve(absolute, { signal });
1976
+ const info = await ctx.fs.stat(target, signal);
1977
+ if (info === void 0 || info.type !== "directory") return rpcError("browse failed: not a directory");
1978
+ children = await ctx.fs.listDir(target, signal);
1979
+ } catch (error) {
1980
+ return rpcError(`browse failed: ${errorMessage(error)}`);
1981
+ }
1982
+ const entries = [];
1983
+ for (const child of children) {
1984
+ if (BROWSE_HIDDEN_NAMES.has(child.name)) continue;
1985
+ const childAbsolute = resolve(absolute, child.name);
1986
+ if (workspaceRelativeOf(workspace.path, childAbsolute) === void 0) continue;
1987
+ entries.push({
1988
+ name: child.name,
1989
+ type: child.type === "directory" ? "directory" : child.type === "file" ? "file" : "other",
1990
+ path: childAbsolute,
1991
+ size: child.type === "file" ? child.size : void 0
1992
+ });
1993
+ }
1994
+ entries.sort((left, right) => {
1995
+ const rank = (value) => value.type === "directory" ? 0 : 1;
1996
+ const byKind = rank(left) - rank(right);
1997
+ return byKind !== 0 ? byKind : left.name.localeCompare(right.name, void 0, {
1998
+ numeric: true,
1999
+ sensitivity: "base"
2000
+ });
2001
+ });
2002
+ const truncated = entries.length > BROWSE_ENTRY_CAP;
2003
+ return {
2004
+ ok: true,
2005
+ value: {
2006
+ path: absolute,
2007
+ entries: truncated ? entries.slice(0, BROWSE_ENTRY_CAP) : entries,
2008
+ truncated
1644
2009
  }
2010
+ };
2011
+ }
2012
+ case "add-path": {
2013
+ const target = addTargetOf(payload);
2014
+ if (target === void 0) return rpcError("sessionId and path must be non-empty strings");
2015
+ const workspace = workspaceOf(target.sessionId);
2016
+ if (workspace === void 0) return rpcError("add unavailable: the session has no workspace");
2017
+ const absolute = resolveInsideWorkspace(workspace.path, target.path);
2018
+ if (absolute === void 0) return {
2019
+ ok: true,
2020
+ value: {
2021
+ outcome: "outside",
2022
+ added: 0,
2023
+ duplicates: 0
2024
+ }
2025
+ };
2026
+ await ensureLoaded();
2027
+ let info;
2028
+ try {
2029
+ const target = await ctx.fs.resolve(absolute, { signal });
2030
+ info = await ctx.fs.stat(target, signal);
2031
+ } catch (error) {
2032
+ return rpcError(`add failed: ${errorMessage(error)}`);
1645
2033
  }
1646
- if (imported > 0) {
1647
- persistSession();
1648
- const after = new Map(store.list(sessionId).map((entry) => [entry.path, entry]));
1649
- const batchBefore = [];
1650
- const batchAfter = [];
1651
- for (const path of changedPaths) {
1652
- const final = after.get(path);
1653
- if (final === void 0) continue;
1654
- const pre = before.get(path);
1655
- batchAfter.push({
1656
- id: final.id,
1657
- path,
1658
- entry: final,
1659
- fileText: void 0
1660
- });
1661
- batchBefore.push({
1662
- id: final.id,
1663
- path,
1664
- entry: pre,
1665
- fileText: void 0
1666
- });
2034
+ if (info === void 0 || info.type === "other") return {
2035
+ ok: true,
2036
+ value: {
2037
+ outcome: "missing",
2038
+ added: 0,
2039
+ duplicates: 0
1667
2040
  }
1668
- if (batchBefore.length > 0) pushUndo(sessionId, {
1669
- id: batchBefore[0].id,
1670
- path: batchBefore[0].path,
1671
- entry: void 0,
1672
- fileText: void 0,
1673
- batch: batchBefore
1674
- }, {
1675
- id: batchAfter[0].id,
1676
- path: batchAfter[0].path,
1677
- entry: void 0,
1678
- fileText: void 0,
1679
- batch: batchAfter
2041
+ };
2042
+ const isDirectory = info.type === "directory";
2043
+ const root = detectVcsRoot(workspace.path);
2044
+ if (root === void 0) return {
2045
+ ok: true,
2046
+ value: {
2047
+ outcome: "no-vcs",
2048
+ added: 0,
2049
+ duplicates: 0
2050
+ }
2051
+ };
2052
+ const shell = ctx.get("shell");
2053
+ if (shell === void 0) return rpcError("add unavailable: the deployment has no shell executor");
2054
+ let changes;
2055
+ try {
2056
+ changes = await listVcsChanges({
2057
+ kind: root.kind,
2058
+ root: root.root,
2059
+ workspaceRoot: workspace.path,
2060
+ includeUntracked: true,
2061
+ scope: absolute,
2062
+ shell,
2063
+ readText: (path) => readFile(path, "utf8").catch(() => void 0),
2064
+ signal
1680
2065
  });
2066
+ } catch (error) {
2067
+ return {
2068
+ ok: true,
2069
+ value: {
2070
+ outcome: "failed",
2071
+ added: 0,
2072
+ duplicates: 0,
2073
+ message: errorMessage(error)
2074
+ }
2075
+ };
1681
2076
  }
2077
+ const now = Date.now();
2078
+ const candidates = changes.map((change) => ({
2079
+ id: change.path,
2080
+ sessionId: target.sessionId,
2081
+ path: change.path,
2082
+ kind: change.kind,
2083
+ oldText: change.oldText,
2084
+ newText: change.newText,
2085
+ updatedAt: now,
2086
+ sessionIds: [target.sessionId]
2087
+ }));
2088
+ let truncated = false;
2089
+ if (target.includeUnchanged) {
2090
+ const scanned = new Set(candidates.map((entry) => pathIdentity(entry.path)));
2091
+ const found = isDirectory ? await collectFilesUnder(await ctx.fs.resolve(absolute, { signal }), absolute, signal) : {
2092
+ files: [{
2093
+ path: absolute,
2094
+ content: await readTextOrNone(absolute, signal)
2095
+ }],
2096
+ truncated: false
2097
+ };
2098
+ truncated = found.truncated;
2099
+ for (const file of found.files) {
2100
+ if (file.content === void 0) continue;
2101
+ if (scanned.has(pathIdentity(file.path))) continue;
2102
+ scanned.add(pathIdentity(file.path));
2103
+ candidates.push({
2104
+ id: file.path,
2105
+ sessionId: target.sessionId,
2106
+ path: file.path,
2107
+ kind: "edit",
2108
+ oldText: file.content,
2109
+ newText: file.content,
2110
+ updatedAt: now,
2111
+ sessionIds: [target.sessionId]
2112
+ });
2113
+ }
2114
+ }
2115
+ const listed = new Set(store.list(target.sessionId).map((entry) => pathIdentity(entry.path)));
2116
+ const fresh = candidates.filter((entry) => !listed.has(pathIdentity(entry.path)));
2117
+ const duplicates = candidates.length - fresh.length;
2118
+ const added = await foldBatch(target.sessionId, fresh, true);
2119
+ const outcome = added > 0 ? "added" : duplicates > 0 ? "duplicate" : isDirectory ? "empty" : "unchanged";
1682
2120
  return {
1683
2121
  ok: true,
1684
- value: {
1685
- imported,
1686
- detected: true
2122
+ value: truncated ? {
2123
+ outcome,
2124
+ added,
2125
+ duplicates,
2126
+ truncated
2127
+ } : {
2128
+ outcome,
2129
+ added,
2130
+ duplicates
1687
2131
  }
1688
2132
  };
1689
2133
  }
@@ -1845,6 +2289,24 @@ function previewImageTargetOf(payload) {
1845
2289
  path
1846
2290
  };
1847
2291
  }
2292
+ /** One payload's optional `path` field; absent (or not a string) is undefined. */
2293
+ function pathFieldOf(payload) {
2294
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return void 0;
2295
+ const path = payload.path;
2296
+ return typeof path === "string" ? path : void 0;
2297
+ }
2298
+ /** Narrow a wire payload to one hand-added path. */
2299
+ function addTargetOf(payload) {
2300
+ const sessionId = sessionOf(payload);
2301
+ if (sessionId === void 0) return void 0;
2302
+ const path = pathFieldOf(payload)?.trim();
2303
+ if (path === void 0 || path === "") return void 0;
2304
+ return {
2305
+ sessionId,
2306
+ path,
2307
+ includeUnchanged: payload.includeUnchanged === true
2308
+ };
2309
+ }
1848
2310
  /** Narrow a wire payload to one open target: the keep/revert pair plus the action. */
1849
2311
  function openTargetOf(payload) {
1850
2312
  const target = targetOf(payload);