dsh-diff-approval 0.19.3 → 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/client.js +2046 -601
- package/lib/index.js +386 -59
- package/lib/types/client/PathPicker.d.ts +43 -0
- package/lib/types/client/PendingPanel.d.ts +9 -17
- package/lib/types/client/highlight.d.ts +51 -8
- package/lib/types/client/lang.d.ts +8 -0
- package/lib/types/client/locales.d.ts +43 -0
- package/lib/types/client/port.d.ts +5 -1
- package/lib/types/client/settings.d.ts +16 -0
- package/lib/types/client/slots.d.ts +5 -1
- package/lib/types/client/store.d.ts +5 -1
- package/lib/types/client/whole-file-diff.d.ts +21 -0
- package/lib/types/client/windowed-highlight.d.ts +66 -0
- package/lib/types/pending.d.ts +10 -0
- package/lib/types/types.d.ts +51 -0
- package/package.json +1 -1
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).
|
|
@@ -820,6 +832,50 @@ function writeOutcomeOf(value) {
|
|
|
820
832
|
function errorMessage(error) {
|
|
821
833
|
return error instanceof Error ? error.message : String(error);
|
|
822
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
|
+
}
|
|
823
879
|
/** Narrow a tool-execution-shaped value to its name, call id, and agent. */
|
|
824
880
|
function actorOf(value) {
|
|
825
881
|
if (typeof value !== "object" || value === null) return void 0;
|
|
@@ -1103,6 +1159,138 @@ function apply(ctx, config) {
|
|
|
1103
1159
|
};
|
|
1104
1160
|
}
|
|
1105
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
|
+
/**
|
|
1106
1294
|
* The workspace whose session account holds `sessionId`. Web sessions are
|
|
1107
1295
|
* attached to a workspace at creation, so an unowned session is the
|
|
1108
1296
|
* memory-only edge (its entries never persist).
|
|
@@ -1690,66 +1878,19 @@ function apply(ctx, config) {
|
|
|
1690
1878
|
} catch (error) {
|
|
1691
1879
|
return rpcError(`import failed: ${errorMessage(error)}`);
|
|
1692
1880
|
}
|
|
1693
|
-
await ensureLoaded();
|
|
1694
|
-
const before = new Map(store.list(sessionId).map((entry) => [entry.path, entry]));
|
|
1695
|
-
let imported = 0;
|
|
1696
|
-
const changedPaths = [];
|
|
1697
|
-
for (const change of changes) {
|
|
1698
|
-
const entry = {
|
|
1699
|
-
id: change.path,
|
|
1700
|
-
sessionId,
|
|
1701
|
-
path: change.path,
|
|
1702
|
-
kind: change.kind,
|
|
1703
|
-
oldText: change.oldText,
|
|
1704
|
-
newText: change.newText,
|
|
1705
|
-
updatedAt: Date.now(),
|
|
1706
|
-
sessionIds: [sessionId]
|
|
1707
|
-
};
|
|
1708
|
-
if (store.fold(entry)) {
|
|
1709
|
-
imported += 1;
|
|
1710
|
-
changedPaths.push(change.path);
|
|
1711
|
-
}
|
|
1712
|
-
}
|
|
1713
|
-
if (imported > 0) {
|
|
1714
|
-
persistSession();
|
|
1715
|
-
const after = new Map(store.list(sessionId).map((entry) => [entry.path, entry]));
|
|
1716
|
-
const batchBefore = [];
|
|
1717
|
-
const batchAfter = [];
|
|
1718
|
-
for (const path of changedPaths) {
|
|
1719
|
-
const final = after.get(path);
|
|
1720
|
-
if (final === void 0) continue;
|
|
1721
|
-
const pre = before.get(path);
|
|
1722
|
-
batchAfter.push({
|
|
1723
|
-
id: final.id,
|
|
1724
|
-
path,
|
|
1725
|
-
entry: final,
|
|
1726
|
-
fileText: void 0
|
|
1727
|
-
});
|
|
1728
|
-
batchBefore.push({
|
|
1729
|
-
id: final.id,
|
|
1730
|
-
path,
|
|
1731
|
-
entry: pre,
|
|
1732
|
-
fileText: void 0
|
|
1733
|
-
});
|
|
1734
|
-
}
|
|
1735
|
-
if (batchBefore.length > 0) pushUndo(sessionId, {
|
|
1736
|
-
id: batchBefore[0].id,
|
|
1737
|
-
path: batchBefore[0].path,
|
|
1738
|
-
entry: void 0,
|
|
1739
|
-
fileText: void 0,
|
|
1740
|
-
batch: batchBefore
|
|
1741
|
-
}, {
|
|
1742
|
-
id: batchAfter[0].id,
|
|
1743
|
-
path: batchAfter[0].path,
|
|
1744
|
-
entry: void 0,
|
|
1745
|
-
fileText: void 0,
|
|
1746
|
-
batch: batchAfter
|
|
1747
|
-
});
|
|
1748
|
-
}
|
|
1749
1881
|
return {
|
|
1750
1882
|
ok: true,
|
|
1751
1883
|
value: {
|
|
1752
|
-
imported,
|
|
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
|
+
}))),
|
|
1753
1894
|
detected: true
|
|
1754
1895
|
}
|
|
1755
1896
|
};
|
|
@@ -1787,8 +1928,7 @@ function apply(ctx, config) {
|
|
|
1787
1928
|
} catch (error) {
|
|
1788
1929
|
return rpcError(`refresh failed: ${errorMessage(error)}`);
|
|
1789
1930
|
}
|
|
1790
|
-
const
|
|
1791
|
-
const change = changes.find((candidate) => folded(candidate.path) === folded(entry.path));
|
|
1931
|
+
const change = changes.find((candidate) => pathIdentity(candidate.path) === pathIdentity(entry.path));
|
|
1792
1932
|
if (change === void 0) return {
|
|
1793
1933
|
ok: true,
|
|
1794
1934
|
value: { outcome: "no-change" }
|
|
@@ -1822,6 +1962,175 @@ function apply(ctx, config) {
|
|
|
1822
1962
|
value: { outcome: "refreshed" }
|
|
1823
1963
|
};
|
|
1824
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
|
|
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)}`);
|
|
2033
|
+
}
|
|
2034
|
+
if (info === void 0 || info.type === "other") return {
|
|
2035
|
+
ok: true,
|
|
2036
|
+
value: {
|
|
2037
|
+
outcome: "missing",
|
|
2038
|
+
added: 0,
|
|
2039
|
+
duplicates: 0
|
|
2040
|
+
}
|
|
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
|
|
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
|
+
};
|
|
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";
|
|
2120
|
+
return {
|
|
2121
|
+
ok: true,
|
|
2122
|
+
value: truncated ? {
|
|
2123
|
+
outcome,
|
|
2124
|
+
added,
|
|
2125
|
+
duplicates,
|
|
2126
|
+
truncated
|
|
2127
|
+
} : {
|
|
2128
|
+
outcome,
|
|
2129
|
+
added,
|
|
2130
|
+
duplicates
|
|
2131
|
+
}
|
|
2132
|
+
};
|
|
2133
|
+
}
|
|
1825
2134
|
case "open": {
|
|
1826
2135
|
const target = openTargetOf(payload);
|
|
1827
2136
|
if (target === void 0) return rpcError("sessionId, id, and action must be valid");
|
|
@@ -1980,6 +2289,24 @@ function previewImageTargetOf(payload) {
|
|
|
1980
2289
|
path
|
|
1981
2290
|
};
|
|
1982
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
|
+
}
|
|
1983
2310
|
/** Narrow a wire payload to one open target: the keep/revert pair plus the action. */
|
|
1984
2311
|
function openTargetOf(payload) {
|
|
1985
2312
|
const target = targetOf(payload);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The add-path dialog: pick one path in a lazily-loaded workspace tree — or type
|
|
3
|
+
* it outright — then press Add. Nothing is added by pointing at it: the path and
|
|
4
|
+
* the no-change box are prepared first, and the host judges them once the button
|
|
5
|
+
* is pressed.
|
|
6
|
+
*
|
|
7
|
+
* The path box is the single source of truth for the selection: a tree row is
|
|
8
|
+
* highlighted while its path is the box's text, and editing the text by hand
|
|
9
|
+
* simply leaves no row highlighted.
|
|
10
|
+
*
|
|
11
|
+
* The dialog is a modal inside the panel: it owns Escape while it is on screen
|
|
12
|
+
* (the panel's own chords stand down, see `pathPickerOpen`).
|
|
13
|
+
* @module dsh-diff-approval/client/PathPicker
|
|
14
|
+
*/
|
|
15
|
+
import type { ReactElement } from 'react';
|
|
16
|
+
import type { DiffApprovalAddValue, DiffApprovalBrowseEntry } from '../types.ts';
|
|
17
|
+
import type { Translator } from './locales.ts';
|
|
18
|
+
/** Whether this panel's add-path dialog is on screen. The panel's global chords
|
|
19
|
+
* consult this so the modal owns the keyboard while it is open. */
|
|
20
|
+
export declare function pathPickerOpen(): boolean;
|
|
21
|
+
/** Full props of the add-path dialog. */
|
|
22
|
+
export interface PathPickerProps {
|
|
23
|
+
/** The session's workspace root (absolute), for the tree's top row. */
|
|
24
|
+
rootPath?: string | undefined;
|
|
25
|
+
/** List one workspace directory level (absolute path; undefined is the root). */
|
|
26
|
+
onBrowse: (path?: string) => Promise<{
|
|
27
|
+
path: string;
|
|
28
|
+
entries: DiffApprovalBrowseEntry[];
|
|
29
|
+
truncated: boolean;
|
|
30
|
+
}>;
|
|
31
|
+
/** Add the chosen path; resolves to what the host's scan did. */
|
|
32
|
+
onAdd: (path: string, includeUnchanged: boolean) => Promise<DiffApprovalAddValue>;
|
|
33
|
+
/** Transient banner for an outcome the dialog does not stay open for. */
|
|
34
|
+
onToast: (text: string) => void;
|
|
35
|
+
onClose: () => void;
|
|
36
|
+
t: Translator;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The add-path dialog.
|
|
40
|
+
* @param props - the workspace root, the host calls, and the close/toast sinks.
|
|
41
|
+
* @returns the modal (rendered only while open by its parent).
|
|
42
|
+
*/
|
|
43
|
+
export declare function PathPicker({ rootPath, onBrowse, onAdd, onToast, onClose, t }: PathPickerProps): ReactElement;
|
|
@@ -3,10 +3,11 @@ import type { SessionId } from '@deepseek-ai/dsh-client-connection/client';
|
|
|
3
3
|
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
|
|
4
4
|
import type { DiffApprovalBlockRange, PendingFileDiff } from '../types.ts';
|
|
5
5
|
import type { PendingPanelFace } from './slots.ts';
|
|
6
|
-
import type {
|
|
6
|
+
import type { Translator } from './locales.ts';
|
|
7
7
|
import { computeWholeFileDiff } from './whole-file-diff.ts';
|
|
8
|
-
import type { IntraRun } from './whole-file-diff.ts';
|
|
9
|
-
import {
|
|
8
|
+
import type { ChangeBlock, IntraRun } from './whole-file-diff.ts';
|
|
9
|
+
import type { HighlightSides } from './highlight.ts';
|
|
10
|
+
import type { VisibleLines } from './windowed-highlight.ts';
|
|
10
11
|
/** Normalize a path for comparison: forward slashes, no trailing slash. */
|
|
11
12
|
export declare function normalizeDiffPath(p: string): string;
|
|
12
13
|
/** Whether a produced-file chip path and a pending file path refer to the same
|
|
@@ -23,8 +24,6 @@ export declare const SIDEBAR_AUTO_COLLAPSE_PX = 1024;
|
|
|
23
24
|
export declare function wrapInto(text: string, widthPx: number, measure: (t: string) => number, tabPx: number): string[];
|
|
24
25
|
/** Full panel props composed by the sidebar footer-action slot. */
|
|
25
26
|
export type PendingPanelProps = PropsRuntime<'sidebar.footer.action'> & InjectFace<PendingPanelFace> & PropsLocale<'diff-approval'>;
|
|
26
|
-
/** Locale translator used by the panel and its rows. */
|
|
27
|
-
type Translator = (key: DiffApprovalKey, params?: Record<string, unknown>) => string;
|
|
28
27
|
/**
|
|
29
28
|
* Open the DSH settings dialog and switch to this plugin's section. The
|
|
30
29
|
* settings shell keeps its open state and the active section id as
|
|
@@ -43,16 +42,6 @@ interface RowModel {
|
|
|
43
42
|
/** Intra-line runs keyed by row index, present only for annotated del/add rows. */
|
|
44
43
|
intra: Map<number, IntraRun[]>;
|
|
45
44
|
}
|
|
46
|
-
/** One file's deferred syntax-highlight runs, one entry per side. */
|
|
47
|
-
interface HighlightRuns {
|
|
48
|
-
oldRuns: ReturnType<typeof highlightLines>;
|
|
49
|
-
newRuns: ReturnType<typeof highlightLines>;
|
|
50
|
-
}
|
|
51
|
-
/** One contiguous run of changed rows, treated as a single modification. */
|
|
52
|
-
interface ChangeBlock {
|
|
53
|
-
start: number;
|
|
54
|
-
end: number;
|
|
55
|
-
}
|
|
56
45
|
/** One selected line range in row indices, normalized low-to-high. */
|
|
57
46
|
interface RowRange {
|
|
58
47
|
start: number;
|
|
@@ -76,7 +65,7 @@ export interface SplitDiffHandle {
|
|
|
76
65
|
export declare const SplitDiff: import("react").ForwardRefExoticComponent<{
|
|
77
66
|
file: PendingFileDiff;
|
|
78
67
|
model: RowModel;
|
|
79
|
-
runs:
|
|
68
|
+
runs: HighlightSides | undefined;
|
|
80
69
|
langWrap: boolean;
|
|
81
70
|
tabWidthSpaces: number;
|
|
82
71
|
busy: boolean;
|
|
@@ -87,6 +76,9 @@ export declare const SplitDiff: import("react").ForwardRefExoticComponent<{
|
|
|
87
76
|
onBlockRevert: (sessionId: SessionId, id: string, block: DiffApprovalBlockRange) => Promise<void>;
|
|
88
77
|
/** Notify the parent to toast a block-wrap boundary / single-block (Ctrl+Up/Down). */
|
|
89
78
|
onWrapToast: (text: string) => void;
|
|
79
|
+
/** Report which source lines this view is showing, so the parent's windowed
|
|
80
|
+
* highlighter follows this view's own scroller (it has its own virtual window). */
|
|
81
|
+
onVisibleLines: (visible: VisibleLines) => void;
|
|
90
82
|
} & import("react").RefAttributes<SplitDiffHandle>>;
|
|
91
83
|
/**
|
|
92
84
|
* Reconstruct the plain text of the current selection so auto-wrap's visual
|
|
@@ -97,5 +89,5 @@ export declare const SplitDiff: import("react").ForwardRefExoticComponent<{
|
|
|
97
89
|
*/
|
|
98
90
|
export declare function selectedPlainText(): string | undefined;
|
|
99
91
|
/** Render the pending-edit review panel and its unified footer action. */
|
|
100
|
-
export declare function PendingPanel({ wide, useSessions, usePending, onRefresh, onKeep, onRevert, onBlockKeep, onBlockRevert, onOpen, onPreviewImage, onPasteReference, onUndo, onRedo, onImportVcs, onRefreshVcs, onKeepAll, onRevertAll, onAckRedoCleared, collapseSidebar, t, }: PendingPanelProps): import("react").JSX.Element;
|
|
92
|
+
export declare function PendingPanel({ wide, useSessions, usePending, onRefresh, onKeep, onRevert, onBlockKeep, onBlockRevert, onOpen, onPreviewImage, onPasteReference, onUndo, onRedo, onImportVcs, onRefreshVcs, onBrowse, onAddPath, onKeepAll, onRevertAll, onAckRedoCleared, collapseSidebar, t, }: PendingPanelProps): import("react").JSX.Element;
|
|
101
93
|
export {};
|
|
@@ -10,11 +10,42 @@
|
|
|
10
10
|
* @module dsh-diff-approval/client/highlight
|
|
11
11
|
*/
|
|
12
12
|
import type { CSSProperties } from 'react';
|
|
13
|
+
import type { HighlighterCore } from 'shiki/core';
|
|
13
14
|
/** One highlighted run of a line: literal text plus a color style. */
|
|
14
15
|
export interface HighlightSpan {
|
|
15
16
|
text: string;
|
|
16
17
|
style: CSSProperties;
|
|
17
18
|
}
|
|
19
|
+
/** One file's highlight runs, one entry per side and line (index = line - 1). A
|
|
20
|
+
* hole means that line has not been highlighted — the viewer renders it plain,
|
|
21
|
+
* which is the honest state of a windowed highlighter: only what has been looked
|
|
22
|
+
* at is tokenized. */
|
|
23
|
+
export interface HighlightSides {
|
|
24
|
+
oldRuns: HighlightSpan[][];
|
|
25
|
+
newRuns: HighlightSpan[][];
|
|
26
|
+
}
|
|
27
|
+
/** A saved grammar state: resuming from one continues tokenization *exactly*
|
|
28
|
+
* where it stopped, instead of assuming the window's first line starts the file.
|
|
29
|
+
* Opaque to callers — only `highlightWindow` produces and consumes it, and
|
|
30
|
+
* `undefined` means "no saved state" (the file's first line is top-level). */
|
|
31
|
+
export type HighlightState = Parameters<HighlighterCore['codeToTokens']>[1]['grammarState'];
|
|
32
|
+
/** One highlighted window: the requested lines' runs and the grammar state after
|
|
33
|
+
* the last of them, for an exact continuation by the next window. */
|
|
34
|
+
export interface HighlightWindow {
|
|
35
|
+
runs: HighlightSpan[][];
|
|
36
|
+
state: HighlightState;
|
|
37
|
+
}
|
|
38
|
+
/** Options for {@link highlightWindow}. */
|
|
39
|
+
export interface HighlightWindowOptions {
|
|
40
|
+
/** Lines of preceding context to run the grammar over without returning them,
|
|
41
|
+
* so a window starting inside a multi-line construct (a block comment, a
|
|
42
|
+
* template literal, a fenced code block) still colours correctly. Ignored when
|
|
43
|
+
* `state` is given: that one is exact. */
|
|
44
|
+
context?: number;
|
|
45
|
+
/** An exact state saved at this window's first line (a previous window's
|
|
46
|
+
* `state`). */
|
|
47
|
+
state?: HighlightState;
|
|
48
|
+
}
|
|
18
49
|
/**
|
|
19
50
|
* Primary grammar ids offered in the viewer's language selector, in picker
|
|
20
51
|
* order (alphabetical). Kept explicit instead of deriving from `LANGS`: some
|
|
@@ -28,13 +59,25 @@ export declare const HIGHLIGHT_LANGS: string[];
|
|
|
28
59
|
/** Conventional display name for a grammar id, falling back to the id itself. */
|
|
29
60
|
export declare function languageDisplayName(id: string): string;
|
|
30
61
|
/**
|
|
31
|
-
* Tokenize `
|
|
32
|
-
* registered grammar; `undefined` means the caller renders its plain
|
|
33
|
-
* Each run's color is a `--shiki-*` custom property, keeping token
|
|
34
|
-
* the harness theme's sheets. The trailing newline shiki appends as a
|
|
35
|
-
* empty line is dropped so the run count matches the caller's own
|
|
36
|
-
*
|
|
62
|
+
* Tokenize the lines `[from, to)` into per-line highlighted runs when `lang`
|
|
63
|
+
* names a registered grammar; `undefined` means the caller renders its plain
|
|
64
|
+
* fallback. Each run's color is a `--shiki-*` custom property, keeping token
|
|
65
|
+
* colors on the harness theme's sheets. The trailing newline shiki appends as a
|
|
66
|
+
* final empty line is dropped so the run count matches the caller's own array.
|
|
67
|
+
*
|
|
68
|
+
* This is the viewer's only entry point, and it is deliberately windowed: the
|
|
69
|
+
* code view renders a virtual window of rows, so highlighting a whole file to
|
|
70
|
+
* show one screenful is almost all waste (measured on a 3818-line file: ~945 ms
|
|
71
|
+
* for both sides whole-file against ~8 ms for one window). A window is continued
|
|
72
|
+
* *exactly* from a previous one's `state`, or approximately from `context` lines
|
|
73
|
+
* when no state is known yet — which is what lets a jump straight to the middle
|
|
74
|
+
* of a file colour correctly without tokenizing anything above it.
|
|
75
|
+
* @param lines - the side's lines, indexed from 0 as the caller's line numbers are.
|
|
37
76
|
* @param lang - the Shiki grammar id, or `undefined` for plain text.
|
|
38
|
-
* @
|
|
77
|
+
* @param from - the first line index to highlight (inclusive, clamped).
|
|
78
|
+
* @param to - one past the last line index to highlight (clamped).
|
|
79
|
+
* @param options - the grammar state to resume from and/or context lines for the grammar.
|
|
80
|
+
* @returns the window's runs and its end state, or `undefined` when the language
|
|
81
|
+
* is unknown, the range is empty, or the window's own text is too large.
|
|
39
82
|
*/
|
|
40
|
-
export declare function
|
|
83
|
+
export declare function highlightWindow(lines: readonly string[], lang: string | undefined, from: number, to: number, options?: HighlightWindowOptions): HighlightWindow | undefined;
|