@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.
- package/LICENSE +21 -0
- package/README.md +38 -0
- package/package.json +119 -0
- package/src/diff-viewer/DiffViewer.tsx +168 -0
- package/src/diff-viewer/README.md +33 -0
- package/src/diff-viewer/components/CopyButton.tsx +49 -0
- package/src/diff-viewer/components/DiffLineContent.tsx +48 -0
- package/src/diff-viewer/components/SplitView.tsx +270 -0
- package/src/diff-viewer/components/UnifiedView.tsx +189 -0
- package/src/diff-viewer/diff-viewer.stories.tsx +168 -0
- package/src/diff-viewer/hooks/useDiff.ts +47 -0
- package/src/diff-viewer/hooks/useHighlighter.ts +54 -0
- package/src/diff-viewer/index.ts +22 -0
- package/src/diff-viewer/lazy.tsx +25 -0
- package/src/diff-viewer/types.ts +125 -0
- package/src/diff-viewer/utils/computeDiff.ts +171 -0
- package/src/editor/README.md +187 -0
- package/src/editor/components/DiffEditor.tsx +158 -0
- package/src/editor/components/Editor.tsx +293 -0
- package/src/editor/components/index.ts +2 -0
- package/src/editor/context/EditorProvider.tsx +212 -0
- package/src/editor/context/index.ts +1 -0
- package/src/editor/editor.stories.tsx +397 -0
- package/src/editor/hooks/index.ts +4 -0
- package/src/editor/hooks/useEditor.ts +36 -0
- package/src/editor/hooks/useEditorTheme.ts +91 -0
- package/src/editor/hooks/useLanguage.ts +29 -0
- package/src/editor/hooks/useMonaco.ts +79 -0
- package/src/editor/index.ts +16 -0
- package/src/editor/lazy.tsx +76 -0
- package/src/editor/lib/index.ts +2 -0
- package/src/editor/lib/languages.ts +227 -0
- package/src/editor/lib/themes.ts +42 -0
- package/src/editor/types/index.ts +137 -0
- package/src/editor/workers/index.ts +1 -0
- package/src/editor/workers/setup.ts +58 -0
- package/src/index.ts +10 -0
- package/src/json-editor/JsonEditor.tsx +115 -0
- package/src/json-editor/index.ts +1 -0
- package/src/json-editor/lazy.tsx +24 -0
- package/src/markdown-message/ActionRow.tsx +53 -0
- package/src/markdown-message/ChatMessageRow.tsx +103 -0
- package/src/markdown-message/CodeBlock.tsx +137 -0
- package/src/markdown-message/CollapseToggle.tsx +69 -0
- package/src/markdown-message/MarkdownMessage.tsx +376 -0
- package/src/markdown-message/README.md +221 -0
- package/src/markdown-message/builtinRules.tsx +92 -0
- package/src/markdown-message/components.tsx +224 -0
- package/src/markdown-message/index.ts +22 -0
- package/src/markdown-message/linkRules.ts +83 -0
- package/src/markdown-message/plainText.ts +83 -0
- package/src/markdown-message/sanitize.ts +88 -0
- package/src/markdown-message/streamingBlocks.ts +168 -0
- package/src/markdown-message/types.ts +168 -0
- package/src/panel-header/CodePanelHeader.tsx +121 -0
- package/src/panel-header/index.ts +1 -0
- package/src/pretty-code/PrettyCode.client.tsx +402 -0
- package/src/pretty-code/README.md +81 -0
- package/src/pretty-code/index.tsx +72 -0
- package/src/pretty-code/lazy.tsx +69 -0
- package/src/pretty-code/pretty-code.stories.tsx +353 -0
- package/src/pretty-code/registerPrismLanguages.ts +116 -0
- package/src/role-tokens.ts +39 -0
- 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
|
+
}
|