@djangocfg/widget-code 0.1.1

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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +38 -0
  3. package/package.json +119 -0
  4. package/src/diff-viewer/DiffViewer.tsx +168 -0
  5. package/src/diff-viewer/README.md +33 -0
  6. package/src/diff-viewer/components/CopyButton.tsx +49 -0
  7. package/src/diff-viewer/components/DiffLineContent.tsx +48 -0
  8. package/src/diff-viewer/components/SplitView.tsx +270 -0
  9. package/src/diff-viewer/components/UnifiedView.tsx +189 -0
  10. package/src/diff-viewer/diff-viewer.stories.tsx +168 -0
  11. package/src/diff-viewer/hooks/useDiff.ts +47 -0
  12. package/src/diff-viewer/hooks/useHighlighter.ts +54 -0
  13. package/src/diff-viewer/index.ts +22 -0
  14. package/src/diff-viewer/lazy.tsx +25 -0
  15. package/src/diff-viewer/types.ts +125 -0
  16. package/src/diff-viewer/utils/computeDiff.ts +171 -0
  17. package/src/editor/README.md +187 -0
  18. package/src/editor/components/DiffEditor.tsx +158 -0
  19. package/src/editor/components/Editor.tsx +293 -0
  20. package/src/editor/components/index.ts +2 -0
  21. package/src/editor/context/EditorProvider.tsx +212 -0
  22. package/src/editor/context/index.ts +1 -0
  23. package/src/editor/editor.stories.tsx +397 -0
  24. package/src/editor/hooks/index.ts +4 -0
  25. package/src/editor/hooks/useEditor.ts +36 -0
  26. package/src/editor/hooks/useEditorTheme.ts +91 -0
  27. package/src/editor/hooks/useLanguage.ts +29 -0
  28. package/src/editor/hooks/useMonaco.ts +79 -0
  29. package/src/editor/index.ts +16 -0
  30. package/src/editor/lazy.tsx +76 -0
  31. package/src/editor/lib/index.ts +2 -0
  32. package/src/editor/lib/languages.ts +227 -0
  33. package/src/editor/lib/themes.ts +42 -0
  34. package/src/editor/types/index.ts +137 -0
  35. package/src/editor/workers/index.ts +1 -0
  36. package/src/editor/workers/setup.ts +58 -0
  37. package/src/index.ts +10 -0
  38. package/src/json-editor/JsonEditor.tsx +115 -0
  39. package/src/json-editor/index.ts +1 -0
  40. package/src/json-editor/lazy.tsx +24 -0
  41. package/src/markdown-message/ActionRow.tsx +53 -0
  42. package/src/markdown-message/ChatMessageRow.tsx +103 -0
  43. package/src/markdown-message/CodeBlock.tsx +137 -0
  44. package/src/markdown-message/CollapseToggle.tsx +69 -0
  45. package/src/markdown-message/MarkdownMessage.tsx +376 -0
  46. package/src/markdown-message/README.md +221 -0
  47. package/src/markdown-message/builtinRules.tsx +92 -0
  48. package/src/markdown-message/components.tsx +224 -0
  49. package/src/markdown-message/index.ts +22 -0
  50. package/src/markdown-message/linkRules.ts +83 -0
  51. package/src/markdown-message/plainText.ts +83 -0
  52. package/src/markdown-message/sanitize.ts +88 -0
  53. package/src/markdown-message/streamingBlocks.ts +168 -0
  54. package/src/markdown-message/types.ts +168 -0
  55. package/src/panel-header/CodePanelHeader.tsx +121 -0
  56. package/src/panel-header/index.ts +1 -0
  57. package/src/pretty-code/PrettyCode.client.tsx +402 -0
  58. package/src/pretty-code/README.md +81 -0
  59. package/src/pretty-code/index.tsx +72 -0
  60. package/src/pretty-code/lazy.tsx +69 -0
  61. package/src/pretty-code/pretty-code.stories.tsx +353 -0
  62. package/src/pretty-code/registerPrismLanguages.ts +116 -0
  63. package/src/role-tokens.ts +39 -0
  64. package/src/use-collapsible-content.ts +236 -0
@@ -0,0 +1,270 @@
1
+ // Adapted from jalcoui (MIT) — github.com/jal-co/ui
2
+
3
+ 'use client';
4
+
5
+ import * as React from 'react';
6
+ import type { Language, PrismTheme } from 'prism-react-renderer';
7
+ import { Virtuoso } from 'react-virtuoso';
8
+ import { cn } from '@djangocfg/ui-core/lib';
9
+ import { DIFF_TONE_CLASSES, type DiffLine } from '../types';
10
+ import { DiffLineContent } from './DiffLineContent';
11
+
12
+ interface SplitViewProps {
13
+ lines: DiffLine[];
14
+ numWidth: number;
15
+ /** Longest content line in ch (global, from the viewer root) — the shared
16
+ * minimum width of every content cell, so all rows are equally wide and the
17
+ * OUTER `overflow-x-auto` scrolls the whole block as one unit. Before this,
18
+ * each content cell carried its own `overflow-x-auto` and every line
19
+ * scrolled independently. */
20
+ maxContentCh: number;
21
+ language: Language | null;
22
+ prismTheme: PrismTheme;
23
+ /** The host already caps this block's height and owns the scroller, so the
24
+ * virtualized path must not open a second one inside it. */
25
+ hostScrolls?: boolean;
26
+ }
27
+
28
+ // See UnifiedView for context — same threshold + viewport size keeps both
29
+ // layouts behaviorally aligned.
30
+ const VIRTUALIZE_THRESHOLD = 100;
31
+ const VIRTUAL_VIEWPORT_HEIGHT = 480;
32
+
33
+ /**
34
+ * Align consecutive `removed` / `added` runs into a pair of columns.
35
+ * Pure-add rows leave the left column empty; pure-remove rows leave the
36
+ * right column empty. Context rows duplicate on both sides.
37
+ *
38
+ * The pairing is the same shape jalcoui ships — we keep it so downstream
39
+ * consumers' visual baseline doesn't move.
40
+ */
41
+ function pairLines(lines: DiffLine[]): Array<{
42
+ left: DiffLine | null;
43
+ right: DiffLine | null;
44
+ }> {
45
+ const pairs: Array<{ left: DiffLine | null; right: DiffLine | null }> = [];
46
+
47
+ let i = 0;
48
+ while (i < lines.length) {
49
+ const line = lines[i];
50
+ if (line === undefined) {
51
+ i++;
52
+ continue;
53
+ }
54
+ if (line.type === 'context') {
55
+ pairs.push({ left: line, right: line });
56
+ i++;
57
+ continue;
58
+ }
59
+ if (line.type === 'removed') {
60
+ const removed: DiffLine[] = [];
61
+ for (let next = lines[i]; next !== undefined && next.type === 'removed'; next = lines[i]) {
62
+ removed.push(next);
63
+ i++;
64
+ }
65
+ const added: DiffLine[] = [];
66
+ for (let next = lines[i]; next !== undefined && next.type === 'added'; next = lines[i]) {
67
+ added.push(next);
68
+ i++;
69
+ }
70
+ const maxLen = Math.max(removed.length, added.length);
71
+ for (let j = 0; j < maxLen; j++) {
72
+ pairs.push({
73
+ left: removed[j] ?? null,
74
+ right: added[j] ?? null,
75
+ });
76
+ }
77
+ continue;
78
+ }
79
+ // line.type === 'added' with no preceding removed run
80
+ pairs.push({ left: null, right: line });
81
+ i++;
82
+ }
83
+
84
+ return pairs;
85
+ }
86
+
87
+ function sidePrefix(side: 'old' | 'new', type: DiffLine['type'] | undefined) {
88
+ if (!type || type === 'context') return ' ';
89
+ if (side === 'old' && type === 'removed') return '-';
90
+ if (side === 'new' && type === 'added') return '+';
91
+ return ' ';
92
+ }
93
+
94
+ interface HalfProps {
95
+ line: DiffLine | null;
96
+ side: 'old' | 'new';
97
+ numWidth: number;
98
+ maxContentCh: number;
99
+ language: Language | null;
100
+ prismTheme: PrismTheme;
101
+ }
102
+
103
+ function Half({ line, side, numWidth, maxContentCh, language, prismTheme }: HalfProps) {
104
+ const tone = line ? DIFF_TONE_CLASSES[line.type] : DIFF_TONE_CLASSES.context;
105
+ const gutterCh = `calc(${numWidth}ch + 1rem)`;
106
+ // The content track's MINIMUM is the longest line in the whole diff (+ the
107
+ // cell's own 1.5rem horizontal padding). Every row therefore renders at the
108
+ // same width: short diffs still stretch to fill (1fr), long diffs push the
109
+ // row past the container so the viewer-level `overflow-x-auto` scrolls the
110
+ // whole block — never one line at a time.
111
+ const contentCh = `minmax(calc(${maxContentCh}ch + 1.5rem), 1fr)`;
112
+ return (
113
+ <div
114
+ className={cn('grid items-start', tone.row)}
115
+ style={{
116
+ gridTemplateColumns: `${gutterCh} 1.5rem ${contentCh}`,
117
+ }}
118
+ >
119
+ <div
120
+ className={cn(
121
+ 'select-none px-2 text-right tabular-nums whitespace-nowrap',
122
+ tone.num,
123
+ )}
124
+ >
125
+ {(side === 'old' ? line?.oldNumber : line?.newNumber) ?? ''}
126
+ </div>
127
+ <div
128
+ className={cn(
129
+ 'select-none text-center whitespace-nowrap',
130
+ tone.num,
131
+ )}
132
+ >
133
+ {sidePrefix(side, line?.type)}
134
+ </div>
135
+ <div
136
+ className={cn(
137
+ // overflow-x-clip: the width math below reserves the longest line,
138
+ // so this never cuts real content — it only stops estimation-error
139
+ // glyph runs (CJK/emoji render wider than their code-unit count)
140
+ // from bleeding under the neighbouring column.
141
+ 'whitespace-pre px-3 overflow-x-clip',
142
+ line ? tone.text : 'text-muted-foreground/40',
143
+ )}
144
+ >
145
+ {line ? (
146
+ <DiffLineContent
147
+ content={line.content}
148
+ language={language}
149
+ prismTheme={prismTheme}
150
+ />
151
+ ) : (
152
+ ' '
153
+ )}
154
+ </div>
155
+ </div>
156
+ );
157
+ }
158
+
159
+ interface PairRowProps {
160
+ pair: { left: DiffLine | null; right: DiffLine | null };
161
+ numWidth: number;
162
+ maxContentCh: number;
163
+ language: Language | null;
164
+ prismTheme: PrismTheme;
165
+ }
166
+
167
+ function PairRow({ pair, numWidth, maxContentCh, language, prismTheme }: PairRowProps) {
168
+ // Tailwind's `grid-cols-2` is repeat(2, minmax(0, 1fr)) — the 0 minimum let
169
+ // each column shrink below its Half's reserved width, so the inner grids
170
+ // silently overflowed into each other while the row itself never exceeded
171
+ // the container (the viewer-level scroller never engaged). The columns must
172
+ // carry the SAME deterministic minimum the Half reserves inside: gutter
173
+ // (numWidth ch + 1rem) + 1.5rem prefix + content (maxContentCh ch + 1.5rem
174
+ // padding). Short diffs still split 50/50 (1fr); long diffs push the row
175
+ // wider than the container and the outer `overflow-x-auto` scrolls the
176
+ // whole block.
177
+ const halfMin = `calc(${numWidth}ch + 1rem + 1.5rem + ${maxContentCh}ch + 1.5rem)`;
178
+ return (
179
+ <div
180
+ className="grid font-mono text-[13px] leading-relaxed"
181
+ style={{ gridTemplateColumns: `repeat(2, minmax(${halfMin}, 1fr))` }}
182
+ >
183
+ <div className="border-r border-border/40">
184
+ <Half
185
+ line={pair.left}
186
+ side="old"
187
+ numWidth={numWidth}
188
+ maxContentCh={maxContentCh}
189
+ language={language}
190
+ prismTheme={prismTheme}
191
+ />
192
+ </div>
193
+ <div>
194
+ <Half
195
+ line={pair.right}
196
+ side="new"
197
+ numWidth={numWidth}
198
+ maxContentCh={maxContentCh}
199
+ language={language}
200
+ prismTheme={prismTheme}
201
+ />
202
+ </div>
203
+ </div>
204
+ );
205
+ }
206
+
207
+ /**
208
+ * Side-by-side diff. Left half = old, right half = new. Each row is a
209
+ * single grid wrapping two `<Half>` halves so a `<Virtuoso>` list can
210
+ * virtualize the whole stack with naturally synchronized scrolling.
211
+ *
212
+ * Both halves receive the SAME globally-computed `numWidth` so the two
213
+ * gutter columns are pixel-identical regardless of which side has the
214
+ * longer line numbers.
215
+ */
216
+ export function SplitView({
217
+ lines,
218
+ numWidth,
219
+ maxContentCh,
220
+ language,
221
+ prismTheme,
222
+ hostScrolls = false,
223
+ }: SplitViewProps) {
224
+ const pairs = React.useMemo(() => pairLines(lines), [lines]);
225
+
226
+ // See UnifiedView: a host that caps the height owns the scroller, and nesting
227
+ // a second one of the same height is what leaves blank bands mid-list.
228
+ if (pairs.length > VIRTUALIZE_THRESHOLD && !hostScrolls) {
229
+ // Virtualized rows share the same global `maxContentCh`, so scroll width
230
+ // stays stable as rows mount/unmount. The Virtuoso scroller owns BOTH
231
+ // axes here (CSS can't mix visible-x with auto-y), which is still
232
+ // whole-block scrolling — just inside the fixed-height viewport.
233
+ return (
234
+ <div data-slot="diff-viewer-split">
235
+ <Virtuoso
236
+ data={pairs}
237
+ style={{ height: VIRTUAL_VIEWPORT_HEIGHT }}
238
+ // See UnifiedView for why `initialItemCount` is required —
239
+ // without it, embedded iframes start with a zero-height list.
240
+ initialItemCount={Math.min(pairs.length, 30)}
241
+ itemContent={(_, pair) => (
242
+ <PairRow
243
+ pair={pair}
244
+ numWidth={numWidth}
245
+ maxContentCh={maxContentCh}
246
+ language={language}
247
+ prismTheme={prismTheme}
248
+ />
249
+ )}
250
+ increaseViewportBy={{ top: 200, bottom: 400 }}
251
+ />
252
+ </div>
253
+ );
254
+ }
255
+
256
+ return (
257
+ <div data-slot="diff-viewer-split">
258
+ {pairs.map((pair, idx) => (
259
+ <PairRow
260
+ key={idx}
261
+ pair={pair}
262
+ numWidth={numWidth}
263
+ maxContentCh={maxContentCh}
264
+ language={language}
265
+ prismTheme={prismTheme}
266
+ />
267
+ ))}
268
+ </div>
269
+ );
270
+ }
@@ -0,0 +1,189 @@
1
+ // Adapted from jalcoui (MIT) — github.com/jal-co/ui
2
+
3
+ 'use client';
4
+
5
+ import * as React from 'react';
6
+ import type { Language, PrismTheme } from 'prism-react-renderer';
7
+ import { Virtuoso } from 'react-virtuoso';
8
+ import { cn } from '@djangocfg/ui-core/lib';
9
+ import { DIFF_TONE_CLASSES, type DiffLine } from '../types';
10
+ import { DiffLineContent } from './DiffLineContent';
11
+
12
+ interface UnifiedViewProps {
13
+ lines: DiffLine[];
14
+ numWidth: number;
15
+ /** Longest content line in ch (global, from the viewer root) — shared
16
+ * minimum width of the content column, so every row is equally wide and the
17
+ * outer `overflow-x-auto` scrolls the block with full-bleed row tints. */
18
+ maxContentCh: number;
19
+ language: Language | null;
20
+ prismTheme: PrismTheme;
21
+ /** The host already caps this block's height and owns the scroller, so the
22
+ * virtualized path must not open a second one inside it. */
23
+ hostScrolls?: boolean;
24
+ }
25
+
26
+ function linePrefix(type: DiffLine['type']) {
27
+ if (type === 'added') return '+';
28
+ if (type === 'removed') return '-';
29
+ return ' ';
30
+ }
31
+
32
+ // Threshold above which we switch from a plain DOM table to a virtualized
33
+ // list. Mirrors LogViewer — under this size the DOM stays debuggable;
34
+ // above it tokenization + layout of thousands of rows starts to dominate
35
+ // scroll.
36
+ const VIRTUALIZE_THRESHOLD = 100;
37
+ // Height of the scrolling viewport when virtualized. Kept here (not a prop)
38
+ // because every existing consumer expects the viewer to expand to its
39
+ // content; only the >100-line path needs a fixed window to virtualize.
40
+ //
41
+ // A HOST THAT ALREADY CAPS THE HEIGHT MUST SAY SO (`hostScrolls`). Chat used
42
+ // to wrap this in its own 480px `overflowY:auto` box while Virtuoso opened a
43
+ // second 480px scroller inside it — two scrollers of identical height, neither
44
+ // owning the scroll position Virtuoso measures against. Virtuoso then sized its
45
+ // spacers against a viewport it did not control and left ~250px of blank list
46
+ // between two consecutively-numbered rows, which reads as "the diff lost some
47
+ // lines" when nothing was lost at all.
48
+ const VIRTUAL_VIEWPORT_HEIGHT = 480;
49
+
50
+ interface RowProps {
51
+ line: DiffLine;
52
+ numWidth: number;
53
+ maxContentCh: number;
54
+ language: Language | null;
55
+ prismTheme: PrismTheme;
56
+ }
57
+
58
+ /**
59
+ * One unified-diff row. CSS grid (not a table cell) so a `<Virtuoso>`
60
+ * list can virtualize identical rows without a host `<table>` (the
61
+ * `display: table-row-group` virtual scrollers force on `<tbody>` doesn't
62
+ * play with arbitrary row heights).
63
+ */
64
+ function Row({ line, numWidth, maxContentCh, language, prismTheme }: RowProps) {
65
+ const tone = DIFF_TONE_CLASSES[line.type];
66
+ const gutterCh = `calc(${numWidth}ch + 1rem)`;
67
+ // Same global minimum as SplitView: every row renders at the width of the
68
+ // longest line, so row background tints stay full-bleed while the outer
69
+ // `overflow-x-auto` scrolls the whole block.
70
+ const contentCh = `minmax(calc(${maxContentCh}ch + 1.5rem), 1fr)`;
71
+ return (
72
+ <div
73
+ className={cn(
74
+ 'grid items-start font-mono text-[13px] leading-relaxed',
75
+ tone.row,
76
+ )}
77
+ style={{
78
+ gridTemplateColumns: `${gutterCh} ${gutterCh} 1.5rem ${contentCh}`,
79
+ }}
80
+ >
81
+ <div
82
+ className={cn(
83
+ 'select-none px-2 text-right tabular-nums whitespace-nowrap',
84
+ tone.num,
85
+ )}
86
+ >
87
+ {line.oldNumber ?? ''}
88
+ </div>
89
+ <div
90
+ className={cn(
91
+ 'select-none px-2 text-right tabular-nums whitespace-nowrap',
92
+ tone.num,
93
+ )}
94
+ >
95
+ {line.newNumber ?? ''}
96
+ </div>
97
+ <div
98
+ className={cn(
99
+ 'select-none text-center whitespace-nowrap',
100
+ tone.num,
101
+ )}
102
+ >
103
+ {linePrefix(line.type)}
104
+ </div>
105
+ <div className={cn('whitespace-pre px-3', tone.text)}>
106
+ <DiffLineContent
107
+ content={line.content}
108
+ language={language}
109
+ prismTheme={prismTheme}
110
+ />
111
+ </div>
112
+ </div>
113
+ );
114
+ }
115
+
116
+ /**
117
+ * Single-column unified diff. Two gutters on the left carry the old / new
118
+ * line numbers, the third (narrow) column is the +/-/space prefix, then
119
+ * the content. Background tint per row signals the change kind.
120
+ *
121
+ * The gutter widths come from the globally-computed `numWidth` (passed
122
+ * down from the viewer root). Using CSS grid with the same explicit
123
+ * `gridTemplateColumns` on every row guarantees pixel-identical gutters
124
+ * across all rows, even when content sizes vary.
125
+ *
126
+ * Diffs above {@link VIRTUALIZE_THRESHOLD} lines render through
127
+ * `<Virtuoso>` so 1k–10k-line diffs don't lock the main thread.
128
+ */
129
+ export function UnifiedView({
130
+ lines,
131
+ numWidth,
132
+ maxContentCh,
133
+ language,
134
+ prismTheme,
135
+ hostScrolls = false,
136
+ }: UnifiedViewProps) {
137
+ // A host that caps the height owns the scroller; virtualizing inside it would
138
+ // nest a second one of the same size (see VIRTUAL_VIEWPORT_HEIGHT). Render
139
+ // every row plainly instead.
140
+ //
141
+ // The DOM cost is bounded by the PRODUCER, not by hope: cmdop's agent caps a
142
+ // patch at 400 changed lines (`diffcompute.go: maxChangedLines`) and sends
143
+ // stats-only above that, so a chat diff tops out around a thousand rows
144
+ // including context — an order of magnitude below where virtualization earns
145
+ // its keep. What it buys back is what GitHub publicly lost when it
146
+ // virtualized its own diff view: Ctrl+F, select-all and printing. diff2html
147
+ // and react-diff-view ship without virtualization for the same reason.
148
+ if (lines.length > VIRTUALIZE_THRESHOLD && !hostScrolls) {
149
+ return (
150
+ <div data-slot="diff-viewer-unified">
151
+ <Virtuoso
152
+ data={lines}
153
+ style={{ height: VIRTUAL_VIEWPORT_HEIGHT }}
154
+ // `initialItemCount` forces SSR-style initial render so rows
155
+ // appear immediately on first paint instead of waiting for
156
+ // ResizeObserver — without it, embedded iframes (Storybook,
157
+ // docs sites) sometimes start at zero height and the list
158
+ // stays empty until a window resize.
159
+ initialItemCount={Math.min(lines.length, 30)}
160
+ itemContent={(_, line) => (
161
+ <Row
162
+ line={line}
163
+ numWidth={numWidth}
164
+ maxContentCh={maxContentCh}
165
+ language={language}
166
+ prismTheme={prismTheme}
167
+ />
168
+ )}
169
+ increaseViewportBy={{ top: 200, bottom: 400 }}
170
+ />
171
+ </div>
172
+ );
173
+ }
174
+
175
+ return (
176
+ <div data-slot="diff-viewer-unified">
177
+ {lines.map((line, i) => (
178
+ <Row
179
+ key={i}
180
+ line={line}
181
+ numWidth={numWidth}
182
+ maxContentCh={maxContentCh}
183
+ language={language}
184
+ prismTheme={prismTheme}
185
+ />
186
+ ))}
187
+ </div>
188
+ );
189
+ }
@@ -0,0 +1,168 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { DiffViewer } from '.';
3
+
4
+ const meta = {
5
+ title: 'Widgets/Code/DiffViewer',
6
+ component: DiffViewer,
7
+ tags: ['autodocs'],
8
+ parameters: {
9
+ docs: {
10
+ description: {
11
+ component:
12
+ 'Line-by-line code diff. Pass `oldCode` + `newCode` to let the viewer run a tiny LCS, or pass `patch` for a pre-computed unified-diff. `layout="split"` switches to a side-by-side render. Optional `language` reuses the same `prism-react-renderer` instance as the `PrettyCode` tool — no second highlighter is loaded.',
13
+ },
14
+ },
15
+ },
16
+ } satisfies Meta<typeof DiffViewer>;
17
+
18
+ export default meta;
19
+ type Story = StoryObj<typeof meta>;
20
+
21
+ const SMALL_OLD = `function greet(name) {
22
+ console.log('hi ' + name);
23
+ }
24
+
25
+ greet('world');
26
+ `;
27
+
28
+ const SMALL_NEW = `function greet(name: string) {
29
+ // greet politely
30
+ console.log(\`hello, \${name}!\`);
31
+ }
32
+
33
+ greet('world');
34
+ `;
35
+
36
+ const SQL_OLD = `SELECT id, name
37
+ FROM users
38
+ WHERE active = true
39
+ ORDER BY id;`;
40
+
41
+ const SQL_NEW = `SELECT id, name, email
42
+ FROM users
43
+ WHERE active = true
44
+ AND deleted_at IS NULL
45
+ ORDER BY id DESC
46
+ LIMIT 100;`;
47
+
48
+ const PYTHON_OLD = `def total(items):
49
+ s = 0
50
+ for i in items:
51
+ s = s + i
52
+ return s
53
+ `;
54
+
55
+ const PYTHON_NEW = `from functools import reduce
56
+
57
+ def total(items):
58
+ return reduce(lambda acc, i: acc + i, items, 0)
59
+ `;
60
+
61
+ function buildLargeDiff(): { oldCode: string; newCode: string } {
62
+ // 1200 lines on each side, ~15% changed — exercises the LCS path.
63
+ const oldLines: string[] = [];
64
+ const newLines: string[] = [];
65
+ for (let i = 0; i < 1200; i++) {
66
+ const base = `const value_${i} = compute(${i});`;
67
+ oldLines.push(base);
68
+ if (i % 7 === 0) {
69
+ newLines.push(`const value_${i} = compute(${i}, { strict: true });`);
70
+ } else if (i % 11 === 0) {
71
+ newLines.push(`// dropped value_${i}`);
72
+ } else {
73
+ newLines.push(base);
74
+ }
75
+ }
76
+ return { oldCode: oldLines.join('\n'), newCode: newLines.join('\n') };
77
+ }
78
+
79
+ export const Unified: Story = {
80
+ args: {
81
+ oldCode: SMALL_OLD,
82
+ newCode: SMALL_NEW,
83
+ layout: 'unified',
84
+ oldTitle: 'greet.js',
85
+ newTitle: 'greet.ts',
86
+ language: 'typescript',
87
+ },
88
+ };
89
+
90
+ export const Split: Story = {
91
+ args: {
92
+ oldCode: SMALL_OLD,
93
+ newCode: SMALL_NEW,
94
+ layout: 'split',
95
+ oldTitle: 'greet.js',
96
+ newTitle: 'greet.ts',
97
+ language: 'typescript',
98
+ },
99
+ };
100
+
101
+ export const SmallChange: Story = {
102
+ args: {
103
+ oldCode: 'console.log("hello");',
104
+ newCode: 'console.log("hello, world");',
105
+ layout: 'unified',
106
+ },
107
+ };
108
+
109
+ export const SyntaxHighlightedSQL: Story = {
110
+ args: {
111
+ oldCode: SQL_OLD,
112
+ newCode: SQL_NEW,
113
+ language: 'sql',
114
+ layout: 'split',
115
+ oldTitle: 'query.v1.sql',
116
+ newTitle: 'query.v2.sql',
117
+ },
118
+ };
119
+
120
+ export const SyntaxHighlightedPython: Story = {
121
+ args: {
122
+ oldCode: PYTHON_OLD,
123
+ newCode: PYTHON_NEW,
124
+ language: 'python',
125
+ layout: 'unified',
126
+ oldTitle: 'total.py (loop)',
127
+ newTitle: 'total.py (reduce)',
128
+ },
129
+ };
130
+
131
+ export const FromUnifiedPatch: Story = {
132
+ args: {
133
+ patch: `--- a/config.ts\n+++ b/config.ts\n@@ -1,4 +1,5 @@\n const config = {\n- debug: false,\n+ debug: true,\n+ verbose: true,\n port: 3000,\n };\n`,
134
+ layout: 'unified',
135
+ oldTitle: 'config.ts',
136
+ newTitle: 'config.ts',
137
+ language: 'typescript',
138
+ },
139
+ };
140
+
141
+ export const LargeChange: Story = {
142
+ args: {
143
+ ...buildLargeDiff(),
144
+ layout: 'unified',
145
+ oldTitle: 'large.before.ts',
146
+ newTitle: 'large.after.ts',
147
+ language: 'typescript',
148
+ // Keep context small so the rendered list stays manageable; the LCS
149
+ // still runs over all 1200 lines.
150
+ context: 2,
151
+ },
152
+ parameters: {
153
+ docs: {
154
+ description: {
155
+ story:
156
+ 'Stresses the LCS path with ~1200 lines per side and ~15% changes. The LCS runs once (memoized) and the context window keeps the rendered row count practical.',
157
+ },
158
+ },
159
+ },
160
+ };
161
+
162
+ export const Empty: Story = {
163
+ args: {
164
+ oldCode: '',
165
+ newCode: '',
166
+ layout: 'unified',
167
+ },
168
+ };
@@ -0,0 +1,47 @@
1
+ // Adapted from jalcoui (MIT) — github.com/jal-co/ui
2
+
3
+ import { useMemo } from 'react';
4
+ import {
5
+ computeStats,
6
+ diffLines,
7
+ lineNumberWidth,
8
+ parseUnifiedPatch,
9
+ } from '../utils/computeDiff';
10
+ import type { DiffInput, DiffLine, DiffStats } from '../types';
11
+
12
+ interface UseDiffOptions {
13
+ input: DiffInput;
14
+ /** Context lines around each change-hunk. @default 3 */
15
+ context?: number;
16
+ }
17
+
18
+ interface UseDiffResult {
19
+ lines: DiffLine[];
20
+ numWidth: number;
21
+ stats: DiffStats;
22
+ }
23
+
24
+ /**
25
+ * Memoize the diff computation so a re-render of the viewer (e.g. tab
26
+ * switch, header copy click) doesn't re-run the LCS. The dependency
27
+ * surface intentionally narrows to the two input strings / patch — the
28
+ * `context` knob too, since a different window emits a different line
29
+ * list.
30
+ */
31
+ export function useDiff({ input, context = 3 }: UseDiffOptions): UseDiffResult {
32
+ const isPatch = 'patch' in input && typeof input.patch === 'string';
33
+ const oldCode = isPatch ? '' : input.oldCode;
34
+ const newCode = isPatch ? '' : input.newCode;
35
+ const patch = isPatch ? (input as { patch: string }).patch : '';
36
+
37
+ return useMemo(() => {
38
+ const lines = isPatch
39
+ ? parseUnifiedPatch(patch)
40
+ : diffLines(oldCode, newCode, context);
41
+ return {
42
+ lines,
43
+ numWidth: lineNumberWidth(lines),
44
+ stats: computeStats(lines),
45
+ };
46
+ }, [isPatch, patch, oldCode, newCode, context]);
47
+ }