dsh-diff-approval 0.14.0 → 0.15.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
@@ -424,6 +424,11 @@ function defaultOpenPath(path, action) {
424
424
  /** Cap on one VCS command's runtime; scans (git status, svn status) can be slow
425
425
  * on large trees but must not hang the import. */
426
426
  const VCS_COMMAND_TIMEOUT_MS = 6e4;
427
+ /** Blobs up to this size are read straight off the executor's buffered stdout
428
+ * (the small-blob path, no temp file); larger blobs go through the checkout-index
429
+ * temp-file path because the executor cap truncates its stdout. Kept below the
430
+ * cap so the small path never truncates. */
431
+ const GIT_BLOB_STDOUT_SAFE = 6e4;
427
432
  /** Quote one argument for a POSIX-ish shell, so paths with spaces or special
428
433
  * characters survive interpolation into VCS command lines. */
429
434
  function shq(value) {
@@ -528,7 +533,24 @@ async function gitChanges(input) {
528
533
  if (worktree !== "M" && worktree !== "D") continue;
529
534
  let oldText = "";
530
535
  try {
531
- oldText = await runShell(shell, `git show :0:${shq(rel)}`, root, signal);
536
+ let size = 0;
537
+ try {
538
+ size = Number.parseInt((await runShell(shell, `git cat-file -s :0:${shq(rel)}`, root, signal)).trim(), 10);
539
+ } catch {
540
+ size = 0;
541
+ }
542
+ if (Number.isFinite(size) && size > 0 && size <= GIT_BLOB_STDOUT_SAFE) oldText = await runShell(shell, `git show :0:${shq(rel)}`, root, signal);
543
+ else {
544
+ const tempRel = (await runShell(shell, `git checkout-index --temp --force -- ${shq(rel)}`, root, signal)).trim().split(/\s+/)[0] ?? "";
545
+ if (tempRel !== "") {
546
+ const tempFile = resolve(root, tempRel);
547
+ try {
548
+ oldText = await readText(tempFile) ?? "";
549
+ } finally {
550
+ await rm(tempFile, { force: true }).catch(() => {});
551
+ }
552
+ }
553
+ }
532
554
  } catch {
533
555
  oldText = "";
534
556
  }
@@ -912,7 +934,7 @@ function apply(ctx, config) {
912
934
  sessionIds: [sessionId]
913
935
  };
914
936
  await ensureLoaded();
915
- if (store.fold(entry)) await persistSession();
937
+ if (store.fold(entry)) persistSession();
916
938
  }
917
939
  /** The stable `FsError.code`, when the thrown value carries one. */
918
940
  function fsErrorCodeOf(error) {
@@ -998,13 +1020,13 @@ function apply(ctx, config) {
998
1020
  entry: void 0,
999
1021
  fileText: void 0
1000
1022
  });
1001
- await persistSession();
1023
+ persistSession();
1002
1024
  continue;
1003
1025
  }
1004
1026
  if (live.kind === "unavailable") {
1005
1027
  store.remove(entry.path);
1006
1028
  purgeForEntry(entry.path);
1007
- await persistSession();
1029
+ persistSession();
1008
1030
  continue;
1009
1031
  }
1010
1032
  const content = live.content;
@@ -1030,7 +1052,7 @@ function apply(ctx, config) {
1030
1052
  },
1031
1053
  fileText: content
1032
1054
  });
1033
- await persistSession();
1055
+ persistSession();
1034
1056
  if (redoWasPresent) redoCleared = true;
1035
1057
  }
1036
1058
  const state = {
@@ -1135,14 +1157,47 @@ function apply(ctx, config) {
1135
1157
  * file.
1136
1158
  * @returns resolution after the write settles (successful or logged).
1137
1159
  */
1138
- async function persistSession() {
1139
- await ensureLoaded();
1160
+ const PERSIST_THROTTLE_MS = 1e3;
1161
+ let persistDirty = false;
1162
+ let persistScheduled = false;
1163
+ let lastPersistAt = 0;
1164
+ let persistTimer;
1165
+ /** Actually write the (dirty) store to disk; one coalesced write. */
1166
+ async function flushPersist() {
1167
+ if (!persistDirty) return;
1168
+ persistDirty = false;
1169
+ lastPersistAt = Date.now();
1140
1170
  try {
1171
+ await ensureLoaded();
1141
1172
  await persistence.save(store.all());
1142
1173
  } catch (error) {
1143
1174
  ctx.logger.warn(`diff-approval: persisting pending changes failed: ${errorMessage(error)}`);
1144
1175
  }
1145
1176
  }
1177
+ /** Mark the store dirty and schedule one throttled, coalesced write. Pass
1178
+ * `force` to write immediately (user actions need durable, immediate results). */
1179
+ function persistSession(force = false) {
1180
+ persistDirty = true;
1181
+ if (force) {
1182
+ if (persistScheduled) {
1183
+ clearTimeout(persistTimer);
1184
+ persistScheduled = false;
1185
+ }
1186
+ flushPersist();
1187
+ return;
1188
+ }
1189
+ if (persistScheduled) return;
1190
+ const delay = PERSIST_THROTTLE_MS - (Date.now() - lastPersistAt);
1191
+ if (delay <= 0) flushPersist();
1192
+ else {
1193
+ persistScheduled = true;
1194
+ persistTimer = setTimeout(() => {
1195
+ persistScheduled = false;
1196
+ flushPersist();
1197
+ }, delay);
1198
+ persistTimer.unref?.();
1199
+ }
1200
+ }
1146
1201
  ctx.on("tools/result", (exec, result) => {
1147
1202
  if (exec.name === "str_replace_editor") {
1148
1203
  captureEditorMutation(exec, result);
@@ -1201,7 +1256,7 @@ function apply(ctx, config) {
1201
1256
  entry: void 0,
1202
1257
  fileText: void 0
1203
1258
  });
1204
- await persistSession();
1259
+ persistSession(true);
1205
1260
  return {
1206
1261
  ok: true,
1207
1262
  value: { outcome: "kept" }
@@ -1247,7 +1302,7 @@ function apply(ctx, config) {
1247
1302
  }
1248
1303
  store.remove(target.id);
1249
1304
  if (undo !== void 0) pushUndo(target.sessionId, undo.before, undo.after);
1250
- await persistSession();
1305
+ persistSession(true);
1251
1306
  return {
1252
1307
  ok: true,
1253
1308
  value: { outcome: "reverted" }
@@ -1281,7 +1336,7 @@ function apply(ctx, config) {
1281
1336
  entry: afterEntry,
1282
1337
  fileText: void 0
1283
1338
  });
1284
- await persistSession();
1339
+ persistSession();
1285
1340
  return {
1286
1341
  ok: true,
1287
1342
  value: updatedOld === entry.newText ? {
@@ -1334,7 +1389,7 @@ function apply(ctx, config) {
1334
1389
  return rpcError(`block revert failed: ${errorMessage(error)}`);
1335
1390
  }
1336
1391
  if (undo !== void 0) pushUndo(blockTarget.sessionId, undo.before, undo.after);
1337
- await persistSession();
1392
+ persistSession();
1338
1393
  return {
1339
1394
  ok: true,
1340
1395
  value: normalizeEol(updatedNew) === normalizeEol(entry.oldText) ? {
@@ -1358,7 +1413,7 @@ function apply(ctx, config) {
1358
1413
  return rpcError(`undo failed: ${errorMessage(error)}`);
1359
1414
  }
1360
1415
  redoStack.push(pair);
1361
- await persistSession();
1416
+ persistSession(true);
1362
1417
  return {
1363
1418
  ok: true,
1364
1419
  value: {
@@ -1382,7 +1437,7 @@ function apply(ctx, config) {
1382
1437
  return rpcError(`redo failed: ${errorMessage(error)}`);
1383
1438
  }
1384
1439
  undoStack.push(pair);
1385
- await persistSession();
1440
+ persistSession(true);
1386
1441
  return {
1387
1442
  ok: true,
1388
1443
  value: {
@@ -1443,7 +1498,7 @@ function apply(ctx, config) {
1443
1498
  }
1444
1499
  }
1445
1500
  if (imported > 0) {
1446
- await persistSession();
1501
+ persistSession();
1447
1502
  const after = new Map(store.list(sessionId).map((entry) => [entry.path, entry]));
1448
1503
  const batchBefore = [];
1449
1504
  const batchAfter = [];
@@ -5,6 +5,7 @@ import type { DiffApprovalBlockRange, PendingFileDiff } from '../types.ts';
5
5
  import type { PendingPanelFace } from './slots.ts';
6
6
  import type { DiffApprovalKey } from './locales.ts';
7
7
  import { computeWholeFileDiff } from './whole-file-diff.ts';
8
+ import type { IntraRun } from './whole-file-diff.ts';
8
9
  import { highlightLines } from './highlight.ts';
9
10
  /** The dsh shell's sidebar auto-collapse breakpoint (ui-layout columns.ts):
10
11
  * below it the sidebar auto-collapses, and the file list floats on the same
@@ -30,6 +31,8 @@ interface RowModel {
30
31
  diff: ReturnType<typeof computeWholeFileDiff>;
31
32
  /** Maximal runs of changed rows (inclusive row indices); one per modification. */
32
33
  blocks: ChangeBlock[];
34
+ /** Intra-line runs keyed by row index, present only for annotated del/add rows. */
35
+ intra: Map<number, IntraRun[]>;
33
36
  }
34
37
  /** One file's deferred syntax-highlight runs, one entry per side. */
35
38
  interface HighlightRuns {
@@ -41,6 +44,14 @@ interface ChangeBlock {
41
44
  start: number;
42
45
  end: number;
43
46
  }
47
+ /** One selected line range in row indices, normalized low-to-high. */
48
+ interface RowRange {
49
+ start: number;
50
+ end: number;
51
+ /** Which file's lines a split selection references: 'old' (left column), 'new'
52
+ * (right column); undefined in single column (always the new file). */
53
+ side?: 'old' | 'new';
54
+ }
44
55
  /** Imperative surface the parent uses to drive block navigation from the
45
56
  * shared toolbar/keyboard in split mode (its own `focus` is private here). */
46
57
  export interface SplitDiffHandle {
@@ -56,9 +67,19 @@ export declare const SplitDiff: import("react").ForwardRefExoticComponent<{
56
67
  tabWidthSpaces: number;
57
68
  busy: boolean;
58
69
  t: Translator;
70
+ selection: RowRange | undefined;
71
+ leadRows: number;
59
72
  onBlockKeep: (sessionId: SessionId, id: string, block: DiffApprovalBlockRange) => Promise<void>;
60
73
  onBlockRevert: (sessionId: SessionId, id: string, block: DiffApprovalBlockRange) => Promise<void>;
61
74
  } & import("react").RefAttributes<SplitDiffHandle>>;
75
+ /**
76
+ * Reconstruct the plain text of the current selection so auto-wrap's visual
77
+ * line breaks never leak into the clipboard. A wrapped row renders its code as
78
+ * several `.subline` block elements, and the browser's default copy inserts a
79
+ * newline between them; those segments form one logical line, so they are joined
80
+ * without a newline while the real newline between diff rows is kept.
81
+ */
82
+ export declare function selectedPlainText(): string | undefined;
62
83
  /** Render the pending-edit review panel and its unified footer action. */
63
84
  export declare function PendingPanel({ wide, useSessions, usePending, onRefresh, onKeep, onRevert, onBlockKeep, onBlockRevert, onOpen, onPasteReference, onUndo, onRedo, onImportVcs, onAckRedoCleared, onAckJustResolved, collapseSidebar, t, }: PendingPanelProps): import("react").JSX.Element;
64
85
  export {};
@@ -17,7 +17,6 @@ export declare const zh: {
17
17
  'panel.missingHint': string;
18
18
  'panel.externalChanged': string;
19
19
  'panel.dismiss': string;
20
- 'panel.createHint': string;
21
20
  'panel.pasteOnCopy': string;
22
21
  'panel.pasteOnCopyDesc': string;
23
22
  'panel.importUntracked': string;
@@ -26,6 +25,8 @@ export declare const zh: {
26
25
  'panel.tabWidthDesc': string;
27
26
  'panel.splitMode': string;
28
27
  'panel.splitModeDesc': string;
28
+ 'panel.navLeadRows': string;
29
+ 'panel.navLeadRowsDesc': string;
29
30
  'panel.quickSummon': string;
30
31
  'panel.quickSummonDesc': string;
31
32
  'panel.recordShortcut': string;
@@ -37,6 +38,7 @@ export declare const zh: {
37
38
  'row.removed': string;
38
39
  'action.keep': string;
39
40
  'action.revert': string;
41
+ 'action.delete': string;
40
42
  'action.keepAll': string;
41
43
  'action.revertAll': string;
42
44
  'action.openFile': string;
@@ -54,6 +56,8 @@ export declare const zh: {
54
56
  'action.wrap': string;
55
57
  'action.toggleOn': string;
56
58
  'action.toggleOff': string;
59
+ 'action.decrease': string;
60
+ 'action.increase': string;
57
61
  'action.importVcs': string;
58
62
  'action.importVcsBusy': string;
59
63
  'panel.importNone': string;
@@ -96,7 +100,6 @@ export declare const en: {
96
100
  'panel.missingHint': string;
97
101
  'panel.externalChanged': string;
98
102
  'panel.dismiss': string;
99
- 'panel.createHint': string;
100
103
  'panel.pasteOnCopy': string;
101
104
  'panel.pasteOnCopyDesc': string;
102
105
  'panel.importUntracked': string;
@@ -105,6 +108,8 @@ export declare const en: {
105
108
  'panel.tabWidthDesc': string;
106
109
  'panel.splitMode': string;
107
110
  'panel.splitModeDesc': string;
111
+ 'panel.navLeadRows': string;
112
+ 'panel.navLeadRowsDesc': string;
108
113
  'panel.quickSummon': string;
109
114
  'panel.quickSummonDesc': string;
110
115
  'panel.recordShortcut': string;
@@ -116,6 +121,7 @@ export declare const en: {
116
121
  'row.removed': string;
117
122
  'action.keep': string;
118
123
  'action.revert': string;
124
+ 'action.delete': string;
119
125
  'action.keepAll': string;
120
126
  'action.revertAll': string;
121
127
  'action.openFile': string;
@@ -133,6 +139,8 @@ export declare const en: {
133
139
  'action.wrap': string;
134
140
  'action.toggleOn': string;
135
141
  'action.toggleOff': string;
142
+ 'action.decrease': string;
143
+ 'action.increase': string;
136
144
  'action.importVcs': string;
137
145
  'action.importVcsBusy': string;
138
146
  'panel.importNone': string;
@@ -1,4 +1,8 @@
1
1
  /** Client preferences for the review panel, persisted in localStorage. */
2
+ /** Default lead rows above a jumped-to diff block (kept small and bounded). */
3
+ export declare const NAV_LEAD_ROWS_DEFAULT = 2;
4
+ export declare const NAV_LEAD_ROWS_MIN = 0;
5
+ export declare const NAV_LEAD_ROWS_MAX = 10;
2
6
  /**
3
7
  * Whether copying a reference should also paste it into the chat input and
4
8
  * focus it. Defaults to on; only an explicit `'0'` disables it.
@@ -45,6 +49,15 @@ export declare function setTabWidth(value: number): void;
45
49
  export declare function splitMode(): boolean;
46
50
  /** Persist the split-view preference. */
47
51
  export declare function setSplitMode(value: boolean): void;
52
+ /**
53
+ * How many rows of lead the diff block jump leaves above the jumped-to block,
54
+ * and how far the anchored navigation scans. Defaults to 2; an out-of-range or
55
+ * non-integer value falls back to the default.
56
+ * @returns the lead row count.
57
+ */
58
+ export declare function navLeadRows(): number;
59
+ /** Persist the block-jump lead row count. */
60
+ export declare function setNavLeadRows(value: number): void;
48
61
  /** Default quick-summon chord (toggle the review panel open/closed). */
49
62
  export declare const DEFAULT_QUICK_SUMMON = "Ctrl+D";
50
63
  /**
@@ -32,13 +32,16 @@ export interface SplitDiff {
32
32
  pairOfRow: ReadonlyMap<number, number>;
33
33
  }
34
34
  /**
35
- * Regroup the whole-file rows into aligned split pairs, pairing a deletion run
36
- * with a following addition run line-by-line (so a replaced line is one pair),
37
- * and leaving a stray deletion or addition as a one-sided pair.
35
+ * Regroup the whole-file rows into aligned split pairs. By order, a deletion
36
+ * run pairs line-by-line with a following addition run; with similarity
37
+ * alignment, each deletion pairs with its most-similar addition (order
38
+ * preserved, threshold-bounded) so a mixed insert/delete block does not force a
39
+ * wrong line together. A stray deletion or addition stays a one-sided pair.
38
40
  * @param rows - the whole-file diff rows.
41
+ * @param alignBySimilarity - align a change block by similarity instead of order.
39
42
  * @returns the split pairs plus the row→pair index map.
40
43
  */
41
- export declare function computeSideBySideDiff(rows: readonly WholeFileDiffRow[]): SplitDiff;
44
+ export declare function computeSideBySideDiff(rows: readonly WholeFileDiffRow[], alignBySimilarity?: boolean): SplitDiff;
42
45
  /**
43
46
  * Pair indices whose left or right text contains the query (case-insensitive).
44
47
  * A pair counts once however many times the query appears, so split search
@@ -47,3 +47,61 @@ export interface WholeFileDiff {
47
47
  */
48
48
  export declare function contentKey(text: string): string;
49
49
  export declare function computeWholeFileDiff(oldText: string, newText: string): WholeFileDiff;
50
+ /**
51
+ * One intra-line segment of a changed line, used to highlight which characters
52
+ * within a paired del/add line differ. A run is either unmarked context
53
+ * (`same`) or the characters that were removed (`del`) /
54
+ * added (`add`). Reassembling the runs of a del row in order yields the old
55
+ * line's text; the runs of the paired add row yield the new line's text.
56
+ */
57
+ export interface IntraRun {
58
+ text: string;
59
+ kind: 'same' | 'del' | 'add';
60
+ }
61
+ /** One matched del→add row pair within a change block. */
62
+ interface BlockPair {
63
+ delIndex: number;
64
+ addIndex: number;
65
+ }
66
+ /** The alignment of one del/add change block: matched pairs and excess sides. */
67
+ export interface BlockAlignment {
68
+ /** Matched del→add pairs, in file order. */
69
+ pairs: BlockPair[];
70
+ /** Row indices of unmatched deletions (shown as del-only in the split view). */
71
+ delOnly: number[];
72
+ /** Row indices of unmatched additions (shown as add-only in the split view). */
73
+ addOnly: number[];
74
+ /** True when similarity matching actually ran. False for a by-order block or a
75
+ * block too large to match — the caller then skips the intra-line highlight so
76
+ * alignment and highlighting are all-or-nothing (either both or neither). */
77
+ usedSimilarity: boolean;
78
+ }
79
+ /**
80
+ * Align a del run with the following add run. By order (the baseline) pairs
81
+ * `del[i]`↔`add[i]`. With similarity alignment, an order-preserving best match
82
+ * pairs each del with the add that maximises total similarity, keeping a pair
83
+ * only when it clears the similarity threshold — so a lone insert or delete in
84
+ * a mixed block stays unaligned instead of being forced onto a wrong line.
85
+ * @param delRows - the run's del rows (index + text).
86
+ * @param addRows - the run's add rows (index + text).
87
+ * @param alignBySimilarity - whether to use the similarity-based matching.
88
+ * @returns the aligned pairs and the unmatched sides.
89
+ */
90
+ export declare function alignChangedBlock(delRows: readonly {
91
+ index: number;
92
+ text: string;
93
+ }[], addRows: readonly {
94
+ index: number;
95
+ text: string;
96
+ }[], alignBySimilarity: boolean): BlockAlignment;
97
+ /**
98
+ * Derive intra-line runs for a whole-file row list. Each del/add block is
99
+ * aligned (by order, or by similarity when `alignBySimilarity`), and each
100
+ * matched pair is compared by `intraRunsOf`. Returns a map keyed by the row's
101
+ * index in `rows`, present only for rows that carry a highlight.
102
+ * @param rows - the unified `WholeFileDiff` row list.
103
+ * @param alignBySimilarity - align blocks by similarity instead of by order.
104
+ * @returns per-row-index intra-line runs for annotated del/add rows.
105
+ */
106
+ export declare function computeIntraLineDiff(rows: readonly WholeFileDiffRow[], alignBySimilarity?: boolean): Map<number, IntraRun[]>;
107
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-diff-approval",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "DeepSeek Harness plugin: pending-edit review with whole-file diff and Keep/Revert",
5
5
  "packageManager": "pnpm@11.21.0",
6
6
  "author": "Wu Zhiwei",