critique 0.1.0 → 0.1.2

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/CHANGELOG.md CHANGED
@@ -1,3 +1,14 @@
1
+ # 0.1.2
2
+
3
+ - Branch comparison:
4
+ - Document two-ref comparison: `critique <base> <head>`
5
+ - Uses three-dot syntax (like GitHub PRs) to show what head added since diverging from base
6
+ - Syntax highlighting:
7
+ - Fix filetype detection to match available tree-sitter parsers
8
+ - Map all JS/TS/JSX/TSX to `typescript` parser (handles all as superset)
9
+ - Map JSON to `javascript` parser (JSON is valid JS)
10
+ - Return `undefined` for unsupported extensions instead of passing them through
11
+
1
12
  # 0.1.0
2
13
 
3
14
  - Diff view:
package/README.md CHANGED
@@ -31,6 +31,10 @@ critique --staged
31
31
  critique --commit HEAD~1
32
32
  critique abc1234
33
33
 
34
+ # Compare two branches (PR-style, shows what head added since diverging from base)
35
+ critique main feature-branch # what feature-branch added vs main
36
+ critique main HEAD # what current branch added vs main
37
+
34
38
  # Watch mode - auto-refresh on file changes
35
39
  critique --watch
36
40
  ```
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "critique",
3
3
  "module": "src/diff.tsx",
4
4
  "type": "module",
5
- "version": "0.1.0",
5
+ "version": "0.1.2",
6
6
  "private": false,
7
7
  "bin": "./src/cli.tsx",
8
8
  "scripts": {
package/src/cli.tsx CHANGED
@@ -60,16 +60,22 @@ function savePersistedState(state: PersistedState): void {
60
60
  const persistedState = loadPersistedState();
61
61
 
62
62
  // Detect filetype from filename for syntax highlighting
63
+ // Maps to tree-sitter parsers available in @opentui/core: typescript, javascript, markdown, zig
63
64
  function detectFiletype(filePath: string): string | undefined {
64
65
  const ext = filePath.split(".").pop()?.toLowerCase();
65
66
  switch (ext) {
67
+ // TypeScript parser handles TS, TSX, JS, JSX (it's a superset)
66
68
  case "ts":
67
- return "typescript";
68
69
  case "tsx":
69
70
  case "js":
70
71
  case "jsx":
71
72
  case "mjs":
72
73
  case "cjs":
74
+ case "mts":
75
+ case "cts":
76
+ return "typescript";
77
+ // JSON uses JavaScript parser (JSON is valid JS)
78
+ case "json":
73
79
  return "javascript";
74
80
  case "md":
75
81
  case "mdx":
@@ -77,7 +83,7 @@ function detectFiletype(filePath: string): string | undefined {
77
83
  case "zig":
78
84
  return "zig";
79
85
  default:
80
- return ext;
86
+ return undefined;
81
87
  }
82
88
  }
83
89
 
@@ -554,7 +560,7 @@ cli
554
560
  return `git diff --cached --no-prefix ${contextArg}`.trim();
555
561
  if (options.commit)
556
562
  return `git show ${options.commit} --no-prefix ${contextArg}`.trim();
557
- // Two refs: compare base...head (three-dot, shows changes since branches diverged)
563
+ // Two refs: compare base...head (three-dot, shows changes since branches diverged, like GitHub PRs)
558
564
  if (base && head)
559
565
  return `git diff ${base}...${head} --no-prefix ${contextArg}`.trim();
560
566
  // Single ref: show that commit's changes
package/src/diff.tsx DELETED
@@ -1,940 +0,0 @@
1
- import { RGBA, DiffRenderable, SyntaxStyle, parseColor, addDefaultParsers, getTreeSitterClient, type MouseEvent } from "@opentui/core";
2
- import { extend } from "@opentui/react";
3
- import { execSync } from "child_process";
4
- import { diffWords } from "diff";
5
-
6
- import * as React from "react";
7
-
8
- import { type StructuredPatchHunk as Hunk } from "diff";
9
- import {
10
- createHighlighter,
11
- type BundledLanguage,
12
- type GrammarState,
13
- type ThemedToken
14
- } from "shiki";
15
-
16
- // Register the diff component with opentui react
17
- extend({ diff: DiffRenderable });
18
-
19
- // Initialize tree-sitter client to ensure parsers are loaded
20
- getTreeSitterClient();
21
-
22
- // Declare the diff component type for JSX
23
- declare module "@opentui/react" {
24
- interface OpenTUIComponents {
25
- diff: typeof DiffRenderable;
26
- }
27
- }
28
-
29
- // GitHub Dark theme - copied exactly from opentui diff-demo.ts
30
- export const githubDarkSyntaxTheme = {
31
- keyword: { fg: parseColor("#FF7B72"), bold: true },
32
- "keyword.import": { fg: parseColor("#FF7B72"), bold: true },
33
- string: { fg: parseColor("#A5D6FF") },
34
- comment: { fg: parseColor("#8B949E"), italic: true },
35
- number: { fg: parseColor("#79C0FF") },
36
- boolean: { fg: parseColor("#79C0FF") },
37
- constant: { fg: parseColor("#79C0FF") },
38
- function: { fg: parseColor("#D2A8FF") },
39
- "function.call": { fg: parseColor("#D2A8FF") },
40
- constructor: { fg: parseColor("#FFA657") },
41
- type: { fg: parseColor("#FFA657") },
42
- operator: { fg: parseColor("#FF7B72") },
43
- variable: { fg: parseColor("#E6EDF3") },
44
- property: { fg: parseColor("#79C0FF") },
45
- bracket: { fg: parseColor("#F0F6FC") },
46
- punctuation: { fg: parseColor("#F0F6FC") },
47
- default: { fg: parseColor("#E6EDF3") },
48
- };
49
-
50
- export { SyntaxStyle };
51
-
52
- // Detect filetype from filename for syntax highlighting
53
- // Only returns filetypes that have parsers bundled in @opentui/core:
54
- // javascript, typescript, markdown, zig
55
- export function detectFiletype(filePath: string): string | undefined {
56
- const ext = filePath.split(".").pop()?.toLowerCase();
57
- switch (ext) {
58
- case "ts": case "tsx": return "typescript";
59
- case "js": case "jsx": case "mjs": case "cjs": return "javascript";
60
- case "md": case "mdx": return "markdown";
61
- case "zig": return "zig";
62
- default: return undefined;
63
- }
64
- }
65
-
66
- const UNCHANGED_CODE_BG = RGBA.fromInts(15, 15, 15, 255);
67
- const ADDED_BG_LIGHT = RGBA.fromInts(100, 250, 120, 12);
68
- const REMOVED_BG_LIGHT = RGBA.fromInts(255, 0, 0, 32);
69
-
70
- const LINE_NUMBER_BG = RGBA.fromInts(5, 5, 5, 255);
71
- const REMOVED_LINE_NUMBER_BG = RGBA.fromInts(60, 0, 0, 255);
72
- const ADDED_LINE_NUMBER_BG = RGBA.fromInts(0, 50, 0, 255);
73
- const LINE_NUMBER_FG_BRIGHT = RGBA.fromInts(255, 255, 255, 255);
74
- const LINE_NUMBER_FG_DIM = "brightBlack";
75
-
76
- function openInEditor(filePath: string, lineNumber: number) {
77
- const editor = process.env.REACT_EDITOR || "zed";
78
-
79
- execSync(`${editor} "${filePath}:${lineNumber}"`, { stdio: "ignore" });
80
- }
81
-
82
- const theme = "github-dark-default";
83
- const highlighterStart = performance.now();
84
- const highlighter = await createHighlighter({
85
- themes: [theme],
86
- langs: [
87
- "javascript",
88
- "typescript",
89
- "tsx",
90
- "jsx",
91
- "json",
92
- "markdown",
93
- "html",
94
- "css",
95
- "python",
96
- "rust",
97
- "go",
98
- "java",
99
- "c",
100
- "cpp",
101
- "yaml",
102
- "toml",
103
- "bash",
104
- "sh",
105
- "sql",
106
- ],
107
- });
108
- const highlighterDuration = performance.now() - highlighterStart;
109
-
110
- function detectLanguage(filePath: string): BundledLanguage {
111
- const ext = filePath.split(".").pop()?.toLowerCase();
112
- switch (ext) {
113
- case "ts":
114
- return "typescript";
115
- case "tsx":
116
- return "tsx";
117
- case "jsx":
118
- return "jsx";
119
- case "js":
120
- case "mjs":
121
- case "cjs":
122
- return "javascript";
123
- case "json":
124
- return "json";
125
- case "md":
126
- case "mdx":
127
- case "markdown":
128
- return "markdown";
129
- case "html":
130
- case "htm":
131
- return "html";
132
- case "css":
133
- return "css";
134
- case "py":
135
- return "python";
136
- case "rs":
137
- return "rust";
138
- case "go":
139
- return "go";
140
- case "java":
141
- return "java";
142
- case "c":
143
- case "h":
144
- return "c";
145
- case "cpp":
146
- case "cc":
147
- case "cxx":
148
- case "hpp":
149
- case "hxx":
150
- return "cpp";
151
- case "yaml":
152
- case "yml":
153
- return "yaml";
154
- case "toml":
155
- return "toml";
156
- case "sh":
157
- return "sh";
158
- case "bash":
159
- return "bash";
160
- case "sql":
161
- return "sql";
162
- default:
163
- return "javascript";
164
- }
165
- }
166
-
167
- function renderHighlightedTokens(tokens: ThemedToken[]) {
168
- return tokens.map((token, tokenIdx) => {
169
- const color = token.color;
170
- const fg = color ? RGBA.fromHex(color) : undefined;
171
-
172
- return (
173
- <span key={tokenIdx} fg={fg}>
174
- {token.content}
175
- </span>
176
- );
177
- });
178
- }
179
-
180
- // Custom error boundary class
181
- class ErrorBoundary extends React.Component<
182
- { children: React.ReactNode },
183
- { hasError: boolean; error: Error | null }
184
- > {
185
- constructor(props: { children: React.ReactNode }) {
186
- super(props);
187
- this.state = { hasError: false, error: null };
188
-
189
- // Bind methods
190
- this.componentDidCatch = this.componentDidCatch.bind(this);
191
- }
192
-
193
- static getDerivedStateFromError(error: Error): {
194
- hasError: boolean;
195
- error: Error;
196
- } {
197
- return { hasError: true, error };
198
- }
199
-
200
- override componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
201
- console.error("Error caught by boundary:", error);
202
- console.error("Component stack:", errorInfo.componentStack);
203
-
204
- // Copy stack trace to clipboard
205
- const stackTrace = `${error.message}\n\nStack trace:\n${error.stack}\n\nComponent stack:\n${errorInfo.componentStack}`;
206
- const { execSync } = require("child_process");
207
- try {
208
- execSync("pbcopy", { input: stackTrace });
209
- } catch (copyError) {
210
- console.error("Failed to copy to clipboard:", copyError);
211
- }
212
- }
213
-
214
- override render(): any {
215
- if (this.state.hasError && this.state.error) {
216
- return (
217
- <box style={{ flexDirection: "column", padding: 2 }}>
218
- <text fg="red">
219
- <strong>Error occurred:</strong>
220
- </text>
221
- <text>{this.state.error.message}</text>
222
- <text fg="brightBlack">Stack trace (copied to clipboard):</text>
223
- <text fg="white">{this.state.error.stack}</text>
224
- </box>
225
- );
226
- }
227
-
228
- return this.props.children;
229
- }
230
- }
231
-
232
- export const FileEditPreviewTitle = ({
233
- filePath,
234
- hunks,
235
- }: {
236
- filePath: string;
237
- hunks: Hunk[];
238
- }) => {
239
- const numAdditions = hunks.reduce(
240
- (count, hunk) => count + hunk.lines.filter((_) => _.startsWith("+")).length,
241
- 0,
242
- );
243
- const numRemovals = hunks.reduce(
244
- (count, hunk) => count + hunk.lines.filter((_) => _.startsWith("-")).length,
245
- 0,
246
- );
247
-
248
- const isNewFile = numAdditions > 0 && numRemovals === 0;
249
- const isDeleted = numRemovals > 0 && numAdditions === 0;
250
-
251
- return (
252
- <text>
253
- {isNewFile ? "Created" : isDeleted ? "Deleted" : "Updated"} <strong>{filePath}</strong>
254
- {numAdditions > 0 || numRemovals > 0 ? " with " : ""}
255
- {numAdditions > 0 ? (
256
- <>
257
- <strong>{numAdditions}</strong>{" "}
258
- {numAdditions > 1 ? "additions" : "addition"}
259
- </>
260
- ) : null}
261
- {numAdditions > 0 && numRemovals > 0 ? " and " : null}
262
- {numRemovals > 0 ? (
263
- <>
264
- <strong>{numRemovals}</strong>{" "}
265
- {numRemovals > 1 ? "removals" : "removal"}
266
- </>
267
- ) : null}
268
- </text>
269
- );
270
- };
271
-
272
- export const FileEditPreview = ({
273
- hunks,
274
- paddingLeft = 0,
275
- splitView = true,
276
- filePath = "",
277
- }: {
278
- hunks: Hunk[];
279
- paddingLeft?: number;
280
- splitView?: boolean;
281
- filePath?: string;
282
- }) => {
283
- React.useEffect(() => {
284
- console.log(
285
- `Highlighter initialized in ${highlighterDuration.toFixed(2)}ms`,
286
- );
287
- }, []);
288
-
289
- const allLines = hunks.flatMap((h) => h.lines);
290
- let oldLineNum = hunks[0]?.oldStart || 1;
291
- let newLineNum = hunks[0]?.newStart || 1;
292
-
293
- const maxOldLine = allLines.reduce((max, line) => {
294
- if (line.startsWith("-")) {
295
- return Math.max(max, oldLineNum++);
296
- } else if (line.startsWith("+")) {
297
- newLineNum++;
298
- return max;
299
- } else {
300
- oldLineNum++;
301
- newLineNum++;
302
- return Math.max(max, oldLineNum - 1);
303
- }
304
- }, 0);
305
-
306
- oldLineNum = hunks[0]?.oldStart || 1;
307
- newLineNum = hunks[0]?.newStart || 1;
308
- const maxNewLine = allLines.reduce((max, line) => {
309
- if (line.startsWith("-")) {
310
- oldLineNum++;
311
- return max;
312
- } else if (line.startsWith("+")) {
313
- return Math.max(max, newLineNum++);
314
- } else {
315
- oldLineNum++;
316
- newLineNum++;
317
- return Math.max(max, newLineNum - 1);
318
- }
319
- }, 0);
320
-
321
- const leftMaxWidth = maxOldLine.toString().length;
322
- const rightMaxWidth = maxNewLine.toString().length;
323
-
324
- return (
325
- <box style={{ flexDirection: "column" }}>
326
- {hunks.flatMap((patch, i) => {
327
- const elements = [
328
- <box
329
- style={{ flexDirection: "column", paddingLeft }}
330
- key={patch.newStart}
331
- >
332
- <StructuredDiff
333
- patch={patch}
334
- splitView={splitView}
335
- leftMaxWidth={leftMaxWidth}
336
- rightMaxWidth={rightMaxWidth}
337
- filePath={filePath}
338
- />
339
- </box>,
340
- ];
341
- if (i < hunks.length - 1) {
342
- elements.push(
343
- <box style={{ paddingLeft }} key={`ellipsis-${i}`}>
344
- <text fg="brightBlack">{" ".repeat(leftMaxWidth + 2)}…</text>
345
- </box>,
346
- );
347
- }
348
- return elements;
349
- })}
350
- </box>
351
- );
352
- };
353
-
354
- function calculateSimilarity(str1: string, str2: string): number {
355
- const longer = str1.length > str2.length ? str1 : str2;
356
- const shorter = str1.length > str2.length ? str2 : str1;
357
-
358
- if (longer.length === 0) return 1.0;
359
-
360
- const editDistance = levenshteinDistance(longer, shorter);
361
- return (longer.length - editDistance) / longer.length;
362
- }
363
-
364
- function levenshteinDistance(str1: string, str2: string): number {
365
- const len1 = str1.length;
366
- const len2 = str2.length;
367
- const matrix: number[][] = [];
368
-
369
- for (let i = 0; i <= len1; i++) {
370
- matrix[i] = [i];
371
- }
372
-
373
- for (let j = 0; j <= len2; j++) {
374
- matrix[0]![j] = j;
375
- }
376
-
377
- for (let i = 1; i <= len1; i++) {
378
- for (let j = 1; j <= len2; j++) {
379
- const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
380
- matrix[i]![j] = Math.min(
381
- matrix[i - 1]![j]! + 1,
382
- matrix[i]![j - 1]! + 1,
383
- matrix[i - 1]![j - 1]! + cost,
384
- );
385
- }
386
- }
387
-
388
- return matrix[len1]![len2]!;
389
- }
390
-
391
- const StructuredDiff = ({
392
- patch,
393
- splitView = true,
394
- leftMaxWidth = 0,
395
- rightMaxWidth = 0,
396
- filePath = "",
397
- }: {
398
- patch: Hunk;
399
- splitView?: boolean;
400
- leftMaxWidth?: number;
401
- rightMaxWidth?: number;
402
- filePath?: string;
403
- }) => {
404
- const formatDiff = (
405
- lines: string[],
406
- startingLineNumber: number,
407
- isSplitView: boolean,
408
- ) => {
409
- const processedLines = lines.map((code) => {
410
- if (code.startsWith("+")) {
411
- return { code: code.slice(1), type: "add", originalCode: code };
412
- }
413
- if (code.startsWith("-")) {
414
- return {
415
- code: code.slice(1),
416
- type: "remove",
417
- originalCode: code,
418
- };
419
- }
420
- return { code: code.slice(1), type: "nochange", originalCode: code };
421
- });
422
-
423
- const lang = detectLanguage(filePath);
424
-
425
- let beforeState: GrammarState | undefined;
426
- const beforeTokens: (ThemedToken[] | null)[] = [];
427
-
428
- for (let idx = 0; idx < processedLines.length; idx++) {
429
- const line = processedLines[idx];
430
- if (!line) continue;
431
-
432
- if (line.type === "remove" || line.type === "nochange") {
433
- const result = highlighter.codeToTokens(line.code, {
434
- lang,
435
- theme,
436
- grammarState: beforeState,
437
- });
438
- const tokens = result.tokens[0] || null;
439
-
440
- beforeTokens.push(tokens);
441
- beforeState = highlighter.getLastGrammarState(result.tokens);
442
- } else {
443
- beforeTokens.push(null);
444
- }
445
- }
446
-
447
- let afterState: GrammarState | undefined;
448
- const afterTokens: (ThemedToken[] | null)[] = [];
449
-
450
- for (const line of processedLines) {
451
- if (line.type === "add" || line.type === "nochange") {
452
- const result = highlighter.codeToTokens(line.code, {
453
- lang,
454
- theme,
455
- grammarState: afterState,
456
- });
457
- const tokens = result.tokens[0] || null;
458
- afterTokens.push(tokens);
459
- afterState = highlighter.getLastGrammarState(result.tokens);
460
- } else {
461
- afterTokens.push(null);
462
- }
463
- }
464
-
465
- // Check if hunk is fully additions or fully deletions
466
- const hasRemovals = processedLines.some((line) => line.type === "remove");
467
- const hasAdditions = processedLines.some((line) => line.type === "add");
468
- const shouldShowWordDiff = hasRemovals && hasAdditions;
469
-
470
- // Find pairs of removed/added lines for word-level diff (only if hunk has both)
471
- const linePairs: Array<{ remove?: number; add?: number }> = [];
472
- if (shouldShowWordDiff) {
473
- let i = 0;
474
- while (i < processedLines.length) {
475
- if (processedLines[i]?.type === "remove") {
476
- // Collect all consecutive removes
477
- const removes: number[] = [];
478
- let j = i;
479
- while (
480
- j < processedLines.length &&
481
- processedLines[j]?.type === "remove"
482
- ) {
483
- removes.push(j);
484
- j++;
485
- }
486
-
487
- // Collect all consecutive adds that follow
488
- const adds: number[] = [];
489
- while (
490
- j < processedLines.length &&
491
- processedLines[j]?.type === "add"
492
- ) {
493
- adds.push(j);
494
- j++;
495
- }
496
-
497
- // Pair them up
498
- const minLength = Math.min(removes.length, adds.length);
499
- for (let k = 0; k < minLength; k++) {
500
- linePairs.push({ remove: removes[k], add: adds[k] });
501
- }
502
-
503
- i = j;
504
- } else {
505
- i++;
506
- }
507
- }
508
- }
509
-
510
- let oldLineNumber = startingLineNumber;
511
- let newLineNumber = startingLineNumber;
512
- const result: Array<{
513
- code: any;
514
- type: string;
515
- oldLineNumber: number;
516
- newLineNumber: number;
517
- pairedWith?: number;
518
- }> = [];
519
-
520
- for (let i = 0; i < processedLines.length; i++) {
521
- const processedLine = processedLines[i];
522
- if (!processedLine) continue;
523
-
524
- const { code, type, originalCode } = processedLine;
525
-
526
- // Check if this line is part of a word-diff pair
527
- const pair = linePairs.find((p) => p.remove === i || p.add === i);
528
-
529
- if (pair && pair.remove === i && pair.add !== undefined) {
530
- // This is a removed line with a corresponding added line
531
- const removedText = processedLines[i]?.code;
532
- const addedLine = processedLines[pair.add];
533
- if (!removedText || !addedLine) continue;
534
-
535
- const addedText = addedLine.code;
536
-
537
- const similarity = calculateSimilarity(removedText, addedText);
538
- const shouldSkipWordDiff = similarity < 0.5;
539
-
540
- if (shouldSkipWordDiff) {
541
- const tokens = beforeTokens[i];
542
- const removedContent = tokens ? (
543
- <text>{renderHighlightedTokens(tokens)}</text>
544
- ) : (
545
- <text>{removedText}</text>
546
- );
547
- result.push({
548
- code: removedContent,
549
- type,
550
- oldLineNumber,
551
- newLineNumber,
552
- pairedWith: pair.add,
553
- });
554
- oldLineNumber++;
555
- continue;
556
- }
557
-
558
- const wordDiff = diffWords(removedText, addedText);
559
-
560
- const removedContent = (
561
- <text>
562
- {wordDiff.map((part, idx) => {
563
- if (part.removed) {
564
- return (
565
- <span key={idx} bg={RGBA.fromInts(255, 50, 50, 100)}>
566
- {part.value}
567
- </span>
568
- );
569
- }
570
- if (!part.added) {
571
- return <span key={idx}>{part.value}</span>;
572
- }
573
- return null;
574
- })}
575
- </text>
576
- );
577
-
578
- result.push({
579
- code: removedContent,
580
- type,
581
- oldLineNumber,
582
- newLineNumber,
583
- pairedWith: pair.add,
584
- });
585
- oldLineNumber++;
586
- } else if (pair && pair.add === i && pair.remove !== undefined) {
587
- // This is an added line with a corresponding removed line
588
- const removedLine = processedLines[pair.remove];
589
- const addedLine = processedLines[i];
590
- if (!removedLine || !addedLine) continue;
591
-
592
- const removedText = removedLine.code;
593
- const addedText = addedLine.code;
594
-
595
- const similarity = calculateSimilarity(removedText, addedText);
596
- const shouldSkipWordDiff = similarity < 0.5;
597
-
598
- if (shouldSkipWordDiff) {
599
- const tokens = afterTokens[i];
600
- const addedContent = tokens ? (
601
- <text>{renderHighlightedTokens(tokens)}</text>
602
- ) : (
603
- <text>{addedText}</text>
604
- );
605
- result.push({
606
- code: addedContent,
607
- type,
608
- oldLineNumber,
609
- newLineNumber,
610
- pairedWith: pair.remove,
611
- });
612
- newLineNumber++;
613
- continue;
614
- }
615
-
616
- const wordDiff = diffWords(removedText, addedText);
617
-
618
- const addedContent = (
619
- <text>
620
- {wordDiff.map((part, idx) => {
621
- if (part.added) {
622
- return (
623
- <span key={idx} bg={RGBA.fromInts(0, 200, 0, 100)}>
624
- {part.value}
625
- </span>
626
- );
627
- }
628
- if (!part.removed) {
629
- return <span key={idx}>{part.value}</span>;
630
- }
631
- return null;
632
- })}
633
- </text>
634
- );
635
-
636
- result.push({
637
- code: addedContent,
638
- type,
639
- oldLineNumber,
640
- newLineNumber,
641
- pairedWith: pair.remove,
642
- });
643
- newLineNumber++;
644
- } else {
645
- const tokens =
646
- type === "remove"
647
- ? beforeTokens[i]
648
- : type === "add"
649
- ? afterTokens[i]
650
- : beforeTokens[i] || afterTokens[i];
651
-
652
- const content =
653
- tokens && tokens.length > 0 ? (
654
- <text>{renderHighlightedTokens(tokens)}</text>
655
- ) : (
656
- <text>{code}</text>
657
- );
658
-
659
- result.push({
660
- code: content,
661
- type,
662
- oldLineNumber,
663
- newLineNumber,
664
- });
665
- }
666
-
667
- if (type === "remove") {
668
- oldLineNumber++;
669
- } else if (type === "add") {
670
- newLineNumber++;
671
- } else {
672
- oldLineNumber++;
673
- newLineNumber++;
674
- }
675
- }
676
-
677
- return result.map(
678
- ({ type, code, oldLineNumber, newLineNumber, pairedWith }, index) => {
679
- return {
680
- oldLineNumber: oldLineNumber.toString(),
681
- newLineNumber: newLineNumber.toString(),
682
- code,
683
- type,
684
- pairedWith,
685
- key: `line-${index}`,
686
- };
687
- },
688
- );
689
- };
690
-
691
- const diff = formatDiff(patch.lines, patch.oldStart, splitView);
692
-
693
- const maxWidth = Math.max(leftMaxWidth, rightMaxWidth);
694
-
695
- if (!splitView) {
696
- const paddedDiff = diff.map((item) => ({
697
- ...item,
698
- lineNumber:
699
- item.newLineNumber && item.newLineNumber !== "0"
700
- ? item.newLineNumber.padStart(maxWidth)
701
- : " ".repeat(maxWidth),
702
- }));
703
- return (
704
- <>
705
- {paddedDiff.map(({ lineNumber, code, type, key, newLineNumber }) => (
706
- <box key={key} style={{ flexDirection: "row" }}>
707
- <box
708
- style={{
709
- flexShrink: 0,
710
- alignSelf: "stretch",
711
- backgroundColor:
712
- type === "add"
713
- ? ADDED_LINE_NUMBER_BG
714
- : type === "remove"
715
- ? REMOVED_LINE_NUMBER_BG
716
- : LINE_NUMBER_BG,
717
- }}
718
- onMouse={(event: MouseEvent) => {
719
- if (event.type === "down") {
720
- openInEditor(filePath, parseInt(newLineNumber));
721
- }
722
- }}
723
- >
724
- <text
725
- selectable={false}
726
- fg={
727
- type === "add" || type === "remove"
728
- ? LINE_NUMBER_FG_BRIGHT
729
- : LINE_NUMBER_FG_DIM
730
- }
731
- style={{ width: maxWidth + 2 }}
732
- >
733
- {" "}
734
- {lineNumber}{" "}
735
- </text>
736
- </box>
737
- <box
738
- style={{
739
- flexGrow: 1,
740
- paddingLeft: 1,
741
- backgroundColor:
742
- type === "add"
743
- ? ADDED_BG_LIGHT
744
- : type === "remove"
745
- ? REMOVED_BG_LIGHT
746
- : UNCHANGED_CODE_BG,
747
- }}
748
- >
749
- {code}
750
- </box>
751
- </box>
752
- ))}
753
- </>
754
- );
755
- }
756
-
757
- // Split view: separate left (removals) and right (additions)
758
- // Build rows by pairing deletions with additions
759
- const splitLines: Array<{
760
- left: any;
761
- right: any;
762
- }> = [];
763
- const processedIndices = new Set<number>();
764
-
765
- for (let i = 0; i < diff.length; i++) {
766
- if (processedIndices.has(i)) continue;
767
-
768
- const line = diff[i];
769
- if (!line) continue;
770
-
771
- if (line.type === "remove" && line.pairedWith !== undefined) {
772
- // This removal is paired with an addition
773
- const pairedLine = diff[line.pairedWith];
774
- if (pairedLine) {
775
- splitLines.push({
776
- left: {
777
- ...line,
778
- lineNumber: line.oldLineNumber.padStart(leftMaxWidth),
779
- },
780
- right: {
781
- ...pairedLine,
782
- lineNumber: pairedLine.newLineNumber.padStart(rightMaxWidth),
783
- },
784
- });
785
- processedIndices.add(i);
786
- processedIndices.add(line.pairedWith);
787
- }
788
- } else if (line.type === "add" && line.pairedWith !== undefined) {
789
- // This addition is paired with a removal (already processed above)
790
- continue;
791
- } else if (line.type === "remove") {
792
- // Unpaired removal
793
- splitLines.push({
794
- left: {
795
- ...line,
796
- lineNumber: line.oldLineNumber.padStart(leftMaxWidth),
797
- },
798
- right: {
799
- lineNumber: " ".repeat(rightMaxWidth),
800
- code: <text></text>,
801
- type: "empty",
802
- key: `${line.key}-empty-right`,
803
- },
804
- });
805
- processedIndices.add(i);
806
- } else if (line.type === "add") {
807
- // Unpaired addition
808
- splitLines.push({
809
- left: {
810
- lineNumber: " ".repeat(leftMaxWidth),
811
- code: <text></text>,
812
- type: "empty",
813
- key: `${line.key}-empty-left`,
814
- },
815
- right: {
816
- ...line,
817
- lineNumber: line.newLineNumber.padStart(rightMaxWidth),
818
- },
819
- });
820
- processedIndices.add(i);
821
- } else {
822
- // Unchanged line
823
- splitLines.push({
824
- left: {
825
- ...line,
826
- lineNumber: line.oldLineNumber.padStart(leftMaxWidth),
827
- },
828
- right: {
829
- ...line,
830
- lineNumber: line.newLineNumber.padStart(rightMaxWidth),
831
- },
832
- });
833
- processedIndices.add(i);
834
- }
835
- }
836
-
837
- return (
838
- <>
839
- {splitLines.map(({ left: leftLine, right: rightLine }) => (
840
- <box key={leftLine.key} style={{ flexDirection: "row" }}>
841
- {/* Left side (removals) */}
842
- <box style={{ flexDirection: "row", width: "50%" }}>
843
- <box
844
- style={{
845
- flexShrink: 0,
846
- minWidth: leftMaxWidth + 2,
847
- alignSelf: "stretch",
848
- backgroundColor:
849
- leftLine.type === "remove"
850
- ? REMOVED_LINE_NUMBER_BG
851
- : LINE_NUMBER_BG,
852
- }}
853
- onMouse={(event: MouseEvent) => {
854
- if (
855
- event.type === "down" &&
856
- leftLine.oldLineNumber &&
857
- leftLine.oldLineNumber !== "0"
858
- ) {
859
- openInEditor(filePath, parseInt(leftLine.oldLineNumber));
860
- }
861
- }}
862
- >
863
- <text
864
- selectable={false}
865
- fg={
866
- leftLine.type === "remove"
867
- ? LINE_NUMBER_FG_BRIGHT
868
- : LINE_NUMBER_FG_DIM
869
- }
870
- >
871
- {" "}
872
- {leftLine.lineNumber}{" "}
873
- </text>
874
- </box>
875
- <box
876
- style={{
877
- flexGrow: 1,
878
- paddingLeft: 1,
879
- minWidth: 0,
880
- backgroundColor:
881
- leftLine.type === "remove"
882
- ? REMOVED_BG_LIGHT
883
- : UNCHANGED_CODE_BG,
884
- }}
885
- >
886
- {leftLine.code}
887
- </box>
888
- </box>
889
-
890
- {/* Right side (additions) */}
891
- <box style={{ flexDirection: "row", width: "50%" }}>
892
- <box
893
- style={{
894
- flexShrink: 0,
895
- minWidth: leftMaxWidth + 2,
896
- alignSelf: "stretch",
897
- backgroundColor:
898
- rightLine.type === "add"
899
- ? ADDED_LINE_NUMBER_BG
900
- : LINE_NUMBER_BG,
901
- }}
902
- onMouse={(event: MouseEvent) => {
903
- if (event.type === "down") {
904
- openInEditor(filePath, parseInt(rightLine.newLineNumber));
905
- }
906
- }}
907
- >
908
- <text
909
- selectable={false}
910
- fg={
911
- rightLine.type === "add"
912
- ? LINE_NUMBER_FG_BRIGHT
913
- : LINE_NUMBER_FG_DIM
914
- }
915
- >
916
- {" "}
917
- {rightLine.lineNumber}{" "}
918
- </text>
919
- </box>
920
- <box
921
- style={{
922
- flexGrow: 1,
923
- minWidth: 0,
924
- paddingLeft: 1,
925
- backgroundColor:
926
- rightLine.type === "add"
927
- ? ADDED_BG_LIGHT
928
- : UNCHANGED_CODE_BG,
929
- }}
930
- >
931
- {rightLine.code}
932
- </box>
933
- </box>
934
- </box>
935
- ))}
936
- </>
937
- );
938
- };
939
-
940
- export { ErrorBoundary };