dsh-diff-approval 0.10.0 → 0.12.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
@@ -694,6 +694,35 @@ const inject = [
694
694
  ];
695
695
  /** The connection RPC channel this plugin serves. */
696
696
  const DIFF_APPROVAL_CHANNEL = "/diff-approval";
697
+ /** The dominant line ending of `text`: CRLF when `\r\n` meets or beats lone
698
+ * `\n`, else LF; a text with no line breaks falls back to LF.
699
+ * @param text - the content to inspect.
700
+ * @returns the dominant line-ending sequence.
701
+ */
702
+ function detectEol(text) {
703
+ let crlf = 0;
704
+ let lf = 0;
705
+ for (let i = 0; i < text.length; i++) {
706
+ const c = text.charCodeAt(i);
707
+ if (c === 13) {
708
+ if (i + 1 < text.length && text.charCodeAt(i + 1) === 10) {
709
+ crlf++;
710
+ i++;
711
+ }
712
+ } else if (c === 10) lf++;
713
+ }
714
+ if (crlf === 0 && lf === 0) return "\n";
715
+ return crlf >= lf ? "\r\n" : "\n";
716
+ }
717
+ /** Normalize any line ending (`\r\n`, `\r`) to `\n`. */
718
+ function normalizeEol(text) {
719
+ return text.replace(/\r\n?/g, "\n");
720
+ }
721
+ /** Re-encode `text`'s line endings to `eol` (its content is unchanged). */
722
+ function reencodeEol(text, eol) {
723
+ const normalized = normalizeEol(text);
724
+ return eol === "\n" ? normalized : normalized.replace(/\n/g, "\r\n");
725
+ }
697
726
  /**
698
727
  * Narrow a successful `edit` result value to an operation outcome. The edit
699
728
  * tool's output schema declares exactly `{ path, before, after }`; anything
@@ -1275,7 +1304,8 @@ function apply(ctx, config) {
1275
1304
  if (entry.kind === "create") await rm(ctx.fs.processPath(resolved), { force: true });
1276
1305
  else {
1277
1306
  const preWrite = await ctx.fs.readText(resolved, void 0) ?? entry.newText;
1278
- await writeRevert(resolved, entry.oldText, target.sessionId, signal);
1307
+ const content = reencodeEol(entry.oldText, detectEol(entry.newText));
1308
+ await writeRevert(resolved, content, target.sessionId, signal);
1279
1309
  undo = {
1280
1310
  before: {
1281
1311
  id: entry.id,
@@ -1287,7 +1317,7 @@ function apply(ctx, config) {
1287
1317
  id: entry.id,
1288
1318
  path: entry.path,
1289
1319
  entry: void 0,
1290
- fileText: entry.oldText
1320
+ fileText: content
1291
1321
  }
1292
1322
  };
1293
1323
  }
@@ -1353,25 +1383,26 @@ function apply(ctx, config) {
1353
1383
  };
1354
1384
  const restored = contentRangeOf(entry.oldText, blockTarget.block.oldStart, blockTarget.block.oldEnd);
1355
1385
  const updatedNew = replaceContentLines(entry.newText, blockTarget.block.newStart, blockTarget.block.newEnd, restored);
1386
+ const content = reencodeEol(updatedNew, detectEol(entry.newText));
1356
1387
  let afterEntry;
1357
- if (updatedNew === entry.oldText) {
1388
+ if (normalizeEol(updatedNew) === normalizeEol(entry.oldText)) {
1358
1389
  store.remove(blockTarget.sessionId, blockTarget.id);
1359
1390
  afterEntry = void 0;
1360
1391
  } else {
1361
- store.update(blockTarget.sessionId, blockTarget.id, { newText: updatedNew });
1392
+ store.update(blockTarget.sessionId, blockTarget.id, { newText: content });
1362
1393
  afterEntry = {
1363
1394
  ...entry,
1364
- newText: updatedNew,
1395
+ newText: content,
1365
1396
  updatedAt: Date.now()
1366
1397
  };
1367
1398
  }
1368
1399
  let undo;
1369
1400
  try {
1370
1401
  const resolved = await ctx.fs.resolve(entry.path, { signal });
1371
- if (entry.kind === "create" && updatedNew === "") await rm(ctx.fs.processPath(resolved), { force: true });
1402
+ if (entry.kind === "create" && content === "") await rm(ctx.fs.processPath(resolved), { force: true });
1372
1403
  else {
1373
1404
  const preWrite = await ctx.fs.readText(resolved, void 0) ?? entry.newText;
1374
- await writeRevert(resolved, updatedNew, blockTarget.sessionId, signal);
1405
+ await writeRevert(resolved, content, blockTarget.sessionId, signal);
1375
1406
  undo = {
1376
1407
  before: {
1377
1408
  id: entry.id,
@@ -1383,7 +1414,7 @@ function apply(ctx, config) {
1383
1414
  id: entry.id,
1384
1415
  path: entry.path,
1385
1416
  entry: afterEntry,
1386
- fileText: updatedNew
1417
+ fileText: content
1387
1418
  }
1388
1419
  };
1389
1420
  }
@@ -1,9 +1,16 @@
1
1
  /** Sidebar-foot pending-edit review action and the split review panel it opens. */
2
+ import type { SessionId } from '@deepseek-ai/dsh-client-connection/client';
2
3
  import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
4
+ import type { DiffApprovalBlockRange, PendingFileDiff } from '../types.ts';
3
5
  import type { PendingPanelFace } from './slots.ts';
6
+ import type { DiffApprovalKey } from './locales.ts';
7
+ import { computeWholeFileDiff } from './whole-file-diff.ts';
8
+ import { highlightLines } from './highlight.ts';
4
9
  export declare function wrapInto(text: string, widthPx: number, measure: (t: string) => number, tabPx: number): string[];
5
10
  /** Full panel props composed by the sidebar footer-action slot. */
6
11
  export type PendingPanelProps = PropsRuntime<'sidebar.footer.action'> & InjectFace<PendingPanelFace> & PropsLocale<'diff-approval'>;
12
+ /** Locale translator used by the panel and its rows. */
13
+ type Translator = (key: DiffApprovalKey, params?: Record<string, unknown>) => string;
7
14
  /**
8
15
  * Open the DSH settings dialog and switch to this plugin's section. The
9
16
  * settings shell keeps its open state and the active section id as
@@ -14,5 +21,40 @@ export type PendingPanelProps = PropsRuntime<'sidebar.footer.action'> & InjectFa
14
21
  * @param sectionLabel - the nav label of this plugin's settings section.
15
22
  */
16
23
  export declare function openSettingsSection(sectionLabel: string): void;
24
+ /** One file's synchronous view: diff rows and change blocks. */
25
+ interface RowModel {
26
+ diff: ReturnType<typeof computeWholeFileDiff>;
27
+ /** Maximal runs of changed rows (inclusive row indices); one per modification. */
28
+ blocks: ChangeBlock[];
29
+ }
30
+ /** One file's deferred syntax-highlight runs, one entry per side. */
31
+ interface HighlightRuns {
32
+ oldRuns: ReturnType<typeof highlightLines>;
33
+ newRuns: ReturnType<typeof highlightLines>;
34
+ }
35
+ /** One contiguous run of changed rows, treated as a single modification. */
36
+ interface ChangeBlock {
37
+ start: number;
38
+ end: number;
39
+ }
40
+ /** Imperative surface the parent uses to drive block navigation from the
41
+ * shared toolbar/keyboard in split mode (its own `focus` is private here). */
42
+ export interface SplitDiffHandle {
43
+ jump: (direction: -1 | 1) => void;
44
+ openSearch: () => void;
45
+ }
46
+ /** The two-column (side-by-side) whole-file diff view. */
47
+ export declare const SplitDiff: import("react").ForwardRefExoticComponent<{
48
+ file: PendingFileDiff;
49
+ model: RowModel;
50
+ runs: HighlightRuns | undefined;
51
+ langWrap: boolean;
52
+ tabWidthSpaces: number;
53
+ busy: boolean;
54
+ t: Translator;
55
+ onBlockKeep: (sessionId: SessionId, id: string, block: DiffApprovalBlockRange) => Promise<void>;
56
+ onBlockRevert: (sessionId: SessionId, id: string, block: DiffApprovalBlockRange) => Promise<void>;
57
+ } & import("react").RefAttributes<SplitDiffHandle>>;
17
58
  /** Render the pending-edit review panel and its unified footer action. */
18
59
  export declare function PendingPanel({ wide, useSessions, usePending, onRefresh, onKeep, onRevert, onBlockKeep, onBlockRevert, onOpen, onPasteReference, onUndo, onRedo, onImportVcs, onAckRedoCleared, t, }: PendingPanelProps): import("react").JSX.Element;
60
+ export {};
@@ -24,6 +24,8 @@ export declare const zh: {
24
24
  'panel.importUntrackedDesc': string;
25
25
  'panel.tabWidth': string;
26
26
  'panel.tabWidthDesc': string;
27
+ 'panel.splitMode': string;
28
+ 'panel.splitModeDesc': string;
27
29
  'settings.tabLabel': string;
28
30
  'row.create': string;
29
31
  'row.failed': string;
@@ -95,6 +97,8 @@ export declare const en: {
95
97
  'panel.importUntrackedDesc': string;
96
98
  'panel.tabWidth': string;
97
99
  'panel.tabWidthDesc': string;
100
+ 'panel.splitMode': string;
101
+ 'panel.splitModeDesc': string;
98
102
  'settings.tabLabel': string;
99
103
  'row.create': string;
100
104
  'row.failed': string;
@@ -36,3 +36,12 @@ export declare function setWrapEnabled(lang: string, value: boolean): void;
36
36
  export declare function tabWidth(): number;
37
37
  /** Persist the diff's tab width (in spaces). */
38
38
  export declare function setTabWidth(value: number): void;
39
+ /**
40
+ * Whether the whole-file diff view uses the two-column (side-by-side) layout.
41
+ * Default off (single column): the unified diff. Only an explicit `'1'` enables
42
+ * split mode.
43
+ * @returns whether the split (two-column) diff view is used.
44
+ */
45
+ export declare function splitMode(): boolean;
46
+ /** Persist the split-view preference. */
47
+ export declare function setSplitMode(value: boolean): void;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Side-by-side (split) diff model: the unified whole-file diff rows are
3
+ * regrouped into line-aligned pairs for a two-column "before | current" view.
4
+ * Pure derivation; the view owns rendering. A context row becomes a pair with
5
+ * both sides, a deletion a left-only pair, an addition a right-only pair, and
6
+ * an adjacent deletion/addition run is paired line-by-line into a single
7
+ * replacement pair so the two columns line up.
8
+ * @module dsh-diff-approval/client/split-diff
9
+ */
10
+ import type { WholeFileDiffRow } from './whole-file-diff.ts';
11
+ /** One side of a split pair: text plus its 1-based line number (absent on a pure add). */
12
+ export interface SplitSide {
13
+ /** The side's source line content, without the terminating newline. */
14
+ text: string;
15
+ /** 1-based line number on this side, or undefined. */
16
+ line: number | undefined;
17
+ }
18
+ /** One aligned left/right pair of the split view. */
19
+ export interface SplitPair {
20
+ /** What the pair is: unchanged, deleted (left only), added (right only), or a replaced line. */
21
+ kind: 'context' | 'del' | 'add' | 'replace';
22
+ /** The old (left) side; absent on a pure addition. */
23
+ left: SplitSide | undefined;
24
+ /** The new (right) side; absent on a pure deletion. */
25
+ right: SplitSide | undefined;
26
+ }
27
+ /** The derived split view: aligned pairs plus the row→pair index map. */
28
+ export interface SplitDiff {
29
+ /** Aligned pairs in file order. */
30
+ pairs: SplitPair[];
31
+ /** Original whole-file row index → pair index, so block ranges keep working. */
32
+ pairOfRow: ReadonlyMap<number, number>;
33
+ }
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.
38
+ * @param rows - the whole-file diff rows.
39
+ * @returns the split pairs plus the row→pair index map.
40
+ */
41
+ export declare function computeSideBySideDiff(rows: readonly WholeFileDiffRow[]): SplitDiff;
42
+ /**
43
+ * Pair indices whose left or right text contains the query (case-insensitive).
44
+ * A pair counts once however many times the query appears, so split search
45
+ * highlights the whole pair on both columns and a single "current" pair is
46
+ * stepped through — not each individual left/right occurrence.
47
+ * @param pairs - the split pairs.
48
+ * @param query - the query text; an empty query matches nothing.
49
+ * @returns matching pair indices, in file order.
50
+ */
51
+ export declare function searchPairs(pairs: readonly SplitPair[], query: string): number[];
@@ -29,7 +29,10 @@ export interface WholeFileDiff {
29
29
  * Compute the whole-file view between two contents. The context budget is the
30
30
  * side lengths, so every hunk covers the file and unchanged lines survive as
31
31
  * context rows; the `` patch marker is annotation
32
- * and never becomes a row.
32
+ * and never becomes a row. Both sides are line-ending normalized (`\r\n?` →
33
+ * `\n`) first, so a repo baseline stored with one EOL and a worktree with
34
+ * another never show as a whole-file delete+add — the diff is about approved
35
+ * content, not line-ending noise.
33
36
  * @param oldText - the file content before the pending change.
34
37
  * @param newText - the file content after the pending change.
35
38
  * @returns the complete row list with totals.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-diff-approval",
3
- "version": "0.10.0",
3
+ "version": "0.12.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",