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/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 folded = (value) => process.platform === "win32" ? resolve(value).toLowerCase() : resolve(value);
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 { DiffApprovalKey } from './locales.ts';
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 { highlightLines } from './highlight.ts';
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: HighlightRuns | undefined;
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 `code` into per-line highlighted runs when `lang` names a
32
- * registered grammar; `undefined` means the caller renders its plain fallback.
33
- * Each run's color is a `--shiki-*` custom property, keeping token colors on
34
- * the harness theme's sheets. The trailing newline shiki appends as a final
35
- * empty line is dropped so the run count matches the caller's own line array.
36
- * @param code - the source text.
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
- * @returns one entry per source line (each an array of runs), or `undefined` when unhighlightable.
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 highlightLines(code: string, lang: string | undefined): HighlightSpan[][] | undefined;
83
+ export declare function highlightWindow(lines: readonly string[], lang: string | undefined, from: number, to: number, options?: HighlightWindowOptions): HighlightWindow | undefined;