@dowel-ui/react 0.3.0 → 0.4.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.
Files changed (71) hide show
  1. package/README.md +9 -8
  2. package/dist/components/ai-disclosure/ai-disclosure.d.ts +92 -0
  3. package/dist/components/ai-disclosure/ai-disclosure.d.ts.map +1 -0
  4. package/dist/components/ai-disclosure/ai-disclosure.js +122 -0
  5. package/dist/components/ai-disclosure/ai-disclosure.js.map +1 -0
  6. package/dist/components/ai-disclosure/index.d.ts +2 -0
  7. package/dist/components/ai-disclosure/index.js +2 -0
  8. package/dist/components/ai-disclosure/meta.js +17 -0
  9. package/dist/components/ai-disclosure/meta.js.map +1 -0
  10. package/dist/components/avatar/avatar.d.ts +1 -1
  11. package/dist/components/date-picker/date-picker.js +1 -1
  12. package/dist/components/diff-viewer/diff-model.d.ts +69 -0
  13. package/dist/components/diff-viewer/diff-model.d.ts.map +1 -0
  14. package/dist/components/diff-viewer/diff-model.js +180 -0
  15. package/dist/components/diff-viewer/diff-model.js.map +1 -0
  16. package/dist/components/diff-viewer/diff-viewer.d.ts +42 -0
  17. package/dist/components/diff-viewer/diff-viewer.d.ts.map +1 -0
  18. package/dist/components/diff-viewer/diff-viewer.js +212 -0
  19. package/dist/components/diff-viewer/diff-viewer.js.map +1 -0
  20. package/dist/components/diff-viewer/index.d.ts +3 -0
  21. package/dist/components/diff-viewer/index.js +3 -0
  22. package/dist/components/diff-viewer/meta.js +17 -0
  23. package/dist/components/diff-viewer/meta.js.map +1 -0
  24. package/dist/components/log-viewer/index.d.ts +3 -0
  25. package/dist/components/log-viewer/index.js +3 -0
  26. package/dist/components/log-viewer/log-stream.d.ts +68 -0
  27. package/dist/components/log-viewer/log-stream.d.ts.map +1 -0
  28. package/dist/components/log-viewer/log-stream.js +120 -0
  29. package/dist/components/log-viewer/log-stream.js.map +1 -0
  30. package/dist/components/log-viewer/log-viewer.d.ts +51 -0
  31. package/dist/components/log-viewer/log-viewer.d.ts.map +1 -0
  32. package/dist/components/log-viewer/log-viewer.js +284 -0
  33. package/dist/components/log-viewer/log-viewer.js.map +1 -0
  34. package/dist/components/log-viewer/meta.js +17 -0
  35. package/dist/components/log-viewer/meta.js.map +1 -0
  36. package/dist/components/spinner/spinner.d.ts +1 -1
  37. package/dist/components/time-range-picker/index.d.ts +3 -0
  38. package/dist/components/time-range-picker/index.js +3 -0
  39. package/dist/components/time-range-picker/meta.js +22 -0
  40. package/dist/components/time-range-picker/meta.js.map +1 -0
  41. package/dist/components/time-range-picker/time-expression.d.ts +88 -0
  42. package/dist/components/time-range-picker/time-expression.d.ts.map +1 -0
  43. package/dist/components/time-range-picker/time-expression.js +335 -0
  44. package/dist/components/time-range-picker/time-expression.js.map +1 -0
  45. package/dist/components/time-range-picker/time-range-picker.d.ts +65 -0
  46. package/dist/components/time-range-picker/time-range-picker.d.ts.map +1 -0
  47. package/dist/components/time-range-picker/time-range-picker.js +377 -0
  48. package/dist/components/time-range-picker/time-range-picker.js.map +1 -0
  49. package/dist/index.d.ts +12 -1
  50. package/dist/index.js +9 -2
  51. package/dist/registry/components.d.ts.map +1 -1
  52. package/dist/registry/components.js +58 -50
  53. package/dist/registry/components.js.map +1 -1
  54. package/package.json +6 -4
  55. package/src/components/ai-disclosure/ai-disclosure.tsx +244 -0
  56. package/src/components/ai-disclosure/index.ts +9 -0
  57. package/src/components/ai-disclosure/meta.ts +23 -0
  58. package/src/components/diff-viewer/diff-model.ts +256 -0
  59. package/src/components/diff-viewer/diff-viewer.tsx +339 -0
  60. package/src/components/diff-viewer/index.ts +17 -0
  61. package/src/components/diff-viewer/meta.ts +20 -0
  62. package/src/components/log-viewer/index.ts +21 -0
  63. package/src/components/log-viewer/log-stream.ts +176 -0
  64. package/src/components/log-viewer/log-viewer.tsx +424 -0
  65. package/src/components/log-viewer/meta.ts +21 -0
  66. package/src/components/time-range-picker/index.ts +25 -0
  67. package/src/components/time-range-picker/meta.ts +22 -0
  68. package/src/components/time-range-picker/time-expression.ts +491 -0
  69. package/src/components/time-range-picker/time-range-picker.tsx +522 -0
  70. package/src/index.ts +4 -0
  71. package/src/registry/components.ts +8 -0
@@ -0,0 +1,256 @@
1
+ import { diffLines, diffWordsWithSpace } from "diff";
2
+
3
+ /**
4
+ * Turning two versions of a file into rows a diff can render.
5
+ *
6
+ * The algorithm is not the interesting part and is not reimplemented here —
7
+ * jsdiff does Myers properly, is BSD-licensed, has no dependencies of its own,
8
+ * and getting an O(ND) diff subtly wrong is a poor use of anybody's afternoon.
9
+ * What every packaged *viewer* welds on is a styling strategy: emotion in
10
+ * react-diff-viewer-continued, HTML strings and a stylesheet in diff2html. That
11
+ * is what cannot be reached by design tokens, and it is why this exists.
12
+ *
13
+ * Everything below is pure, so the grouping, the context collapsing and the
14
+ * word-level pairing can be tested without rendering anything.
15
+ */
16
+
17
+ export type RowKind = "context" | "added" | "removed";
18
+
19
+ export interface WordSegment {
20
+ text: string;
21
+ changed: boolean;
22
+ }
23
+
24
+ export interface DiffRow {
25
+ kind: RowKind;
26
+ /** 1-based line number in the original. Absent on an added line. */
27
+ before?: number;
28
+ /** 1-based line number in the result. Absent on a removed line. */
29
+ after?: number;
30
+ content: string;
31
+ /**
32
+ * Word-level split, present only where a removed line pairs with an added
33
+ * one. Without a pair there is nothing to compare against, and highlighting
34
+ * the whole line as "changed" would be noise.
35
+ */
36
+ segments?: WordSegment[];
37
+ }
38
+
39
+ export interface DiffHunk {
40
+ id: string;
41
+ rows: DiffRow[];
42
+ /** Context lines hidden before this hunk, if any. */
43
+ skippedBefore: number;
44
+ }
45
+
46
+ export interface BuildDiffOptions {
47
+ /** Unchanged lines kept either side of a change. */
48
+ context?: number;
49
+ /**
50
+ * Compare word by word inside a changed line. Off for very large diffs,
51
+ * where the extra pass costs more than it explains.
52
+ */
53
+ words?: boolean;
54
+ }
55
+
56
+ function splitLines(text: string): string[] {
57
+ const lines = text.split("\n");
58
+ // A trailing newline produces a final empty element that is not a line.
59
+ if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
60
+ return lines;
61
+ }
62
+
63
+ /**
64
+ * Gives both sides a trailing newline before they are compared.
65
+ *
66
+ * jsdiff's line tokens carry their own newline, so a final line written as `a`
67
+ * and one written as `a\n` are different tokens. Appending a line to a file
68
+ * that did not end in a newline would otherwise be reported as the last line
69
+ * being removed and re-added — a change the author did not make, sitting on
70
+ * top of the one they did.
71
+ */
72
+ function withTrailingNewline(text: string): string {
73
+ return text.endsWith("\n") ? text : `${text}\n`;
74
+ }
75
+
76
+ /**
77
+ * Pairs removed lines with added ones inside a single change block.
78
+ *
79
+ * jsdiff reports a change as a run of removals followed by a run of additions.
80
+ * Comparing the first removal against the first addition is what turns "this
81
+ * line went away and another arrived" into "this word changed" — but only while
82
+ * the runs line up. Beyond the shorter run there is no counterpart, and
83
+ * inventing one produces confident nonsense.
84
+ */
85
+ function pairWords(removed: DiffRow[], added: DiffRow[]): void {
86
+ const pairs = Math.min(removed.length, added.length);
87
+
88
+ for (let index = 0; index < pairs; index += 1) {
89
+ const from = removed[index];
90
+ const to = added[index];
91
+ if (!from || !to) continue;
92
+
93
+ const parts = diffWordsWithSpace(from.content, to.content);
94
+
95
+ from.segments = parts
96
+ .filter((part) => !part.added)
97
+ .map((part) => ({ text: part.value, changed: Boolean(part.removed) }));
98
+
99
+ to.segments = parts
100
+ .filter((part) => !part.removed)
101
+ .map((part) => ({ text: part.value, changed: Boolean(part.added) }));
102
+ }
103
+ }
104
+
105
+ export function buildDiff(
106
+ before: string,
107
+ after: string,
108
+ options: BuildDiffOptions = {},
109
+ ): DiffHunk[] {
110
+ const { context = 3, words = true } = options;
111
+
112
+ const changes = diffLines(withTrailingNewline(before), withTrailingNewline(after));
113
+ const rows: DiffRow[] = [];
114
+ let beforeLine = 1;
115
+ let afterLine = 1;
116
+
117
+ // Flat rows first. Grouping into hunks is a separate concern and mixing the
118
+ // two is how these implementations become unreadable.
119
+ let pendingRemoved: DiffRow[] = [];
120
+ let pendingAdded: DiffRow[] = [];
121
+
122
+ const flushPair = () => {
123
+ if (words && pendingRemoved.length > 0 && pendingAdded.length > 0) {
124
+ pairWords(pendingRemoved, pendingAdded);
125
+ }
126
+ pendingRemoved = [];
127
+ pendingAdded = [];
128
+ };
129
+
130
+ for (const change of changes) {
131
+ const lines = splitLines(change.value);
132
+
133
+ if (change.added) {
134
+ for (const content of lines) {
135
+ const row: DiffRow = { kind: "added", after: afterLine, content };
136
+ afterLine += 1;
137
+ rows.push(row);
138
+ pendingAdded.push(row);
139
+ }
140
+ continue;
141
+ }
142
+
143
+ if (change.removed) {
144
+ // A removal run that follows an addition run starts a new pairing.
145
+ if (pendingAdded.length > 0) flushPair();
146
+ for (const content of lines) {
147
+ const row: DiffRow = { kind: "removed", before: beforeLine, content };
148
+ beforeLine += 1;
149
+ rows.push(row);
150
+ pendingRemoved.push(row);
151
+ }
152
+ continue;
153
+ }
154
+
155
+ flushPair();
156
+ for (const content of lines) {
157
+ rows.push({ kind: "context", before: beforeLine, after: afterLine, content });
158
+ beforeLine += 1;
159
+ afterLine += 1;
160
+ }
161
+ }
162
+
163
+ flushPair();
164
+
165
+ return groupIntoHunks(rows, context);
166
+ }
167
+
168
+ /**
169
+ * Groups rows into hunks, dropping context beyond `context` lines.
170
+ *
171
+ * A file with one changed line is otherwise thousands of rows of identical
172
+ * text, and the reader has to find the change in it.
173
+ */
174
+ export function groupIntoHunks(rows: DiffRow[], context: number): DiffHunk[] {
175
+ const changedIndexes = rows
176
+ .map((row, index) => (row.kind === "context" ? -1 : index))
177
+ .filter((index) => index >= 0);
178
+
179
+ if (changedIndexes.length === 0) return [];
180
+
181
+ // Ranges of rows to keep, then merged where their context overlaps —
182
+ // otherwise two nearby changes produce two hunks separated by nothing.
183
+ const ranges: [number, number][] = [];
184
+ for (const index of changedIndexes) {
185
+ const start = Math.max(0, index - context);
186
+ const end = Math.min(rows.length - 1, index + context);
187
+ const last = ranges[ranges.length - 1];
188
+ if (last && start <= last[1] + 1) last[1] = Math.max(last[1], end);
189
+ else ranges.push([start, end]);
190
+ }
191
+
192
+ let previousEnd = -1;
193
+ return ranges.map(([start, end], index) => {
194
+ const skippedBefore = start - previousEnd - 1;
195
+ previousEnd = end;
196
+ return {
197
+ id: `hunk-${String(index)}`,
198
+ rows: rows.slice(start, end + 1),
199
+ skippedBefore: Math.max(0, skippedBefore),
200
+ };
201
+ });
202
+ }
203
+
204
+ /** Row pairs for a side-by-side view, aligning removals against additions. */
205
+ export function toSplitRows(
206
+ rows: DiffRow[],
207
+ ): { left: DiffRow | null; right: DiffRow | null }[] {
208
+ const pairs: { left: DiffRow | null; right: DiffRow | null }[] = [];
209
+ let index = 0;
210
+
211
+ while (index < rows.length) {
212
+ const row = rows[index];
213
+ if (!row) break;
214
+
215
+ if (row.kind === "context") {
216
+ pairs.push({ left: row, right: row });
217
+ index += 1;
218
+ continue;
219
+ }
220
+
221
+ // Take the whole removal run and the whole addition run that follows, then
222
+ // lay them alongside each other. Emitting them in document order instead
223
+ // would put every removal above every addition, which is the unified view
224
+ // wearing a two-column costume.
225
+ const removed: DiffRow[] = [];
226
+ while (rows[index]?.kind === "removed") {
227
+ removed.push(rows[index] as DiffRow);
228
+ index += 1;
229
+ }
230
+ const added: DiffRow[] = [];
231
+ while (rows[index]?.kind === "added") {
232
+ added.push(rows[index] as DiffRow);
233
+ index += 1;
234
+ }
235
+
236
+ const height = Math.max(removed.length, added.length);
237
+ for (let offset = 0; offset < height; offset += 1) {
238
+ pairs.push({ left: removed[offset] ?? null, right: added[offset] ?? null });
239
+ }
240
+ }
241
+
242
+ return pairs;
243
+ }
244
+
245
+ /** Counts, for a summary that says what the diff does before it is read. */
246
+ export function countChanges(hunks: DiffHunk[]): { added: number; removed: number } {
247
+ let added = 0;
248
+ let removed = 0;
249
+ for (const hunk of hunks) {
250
+ for (const row of hunk.rows) {
251
+ if (row.kind === "added") added += 1;
252
+ if (row.kind === "removed") removed += 1;
253
+ }
254
+ }
255
+ return { added, removed };
256
+ }
@@ -0,0 +1,339 @@
1
+ "use client";
2
+
3
+ import { useMemo, type ComponentPropsWithRef, type ReactNode } from "react";
4
+
5
+ import { disabledStyles, focusRing } from "@/lib/styles";
6
+ import { cn } from "@/lib/utils";
7
+
8
+ import { countChanges, toSplitRows, type DiffHunk, type DiffRow } from "./diff-model";
9
+
10
+ /**
11
+ * A diff, and a decision about it.
12
+ *
13
+ * The rendering half exists because every packaged viewer brings its own
14
+ * styling system — emotion, or HTML strings plus a stylesheet — which design
15
+ * tokens cannot reach and which is awkward under RSC. The deciding half exists
16
+ * because Dowel is for AI products, and an agent proposing a change to a file is
17
+ * the case that needs it: what every coding agent ships, and no component
18
+ * library does.
19
+ *
20
+ * Decisions are controlled. The component never mutates the diff or applies
21
+ * anything; it reports which hunks were accepted and rejected and leaves the
22
+ * consequences to the application, the same way ai-action-ledger does.
23
+ *
24
+ * Not a grid. A code listing is a table of text, and role="grid" would promise
25
+ * cell-by-cell arrow navigation that neither exists here nor makes sense for
26
+ * reading code. Line kind is carried in text, because a plus sign and a green
27
+ * background are not information.
28
+ */
29
+
30
+ export type HunkDecision = "accepted" | "rejected";
31
+
32
+ const KIND_LABEL: Record<DiffRow["kind"], string> = {
33
+ added: "Added",
34
+ removed: "Removed",
35
+ context: "Unchanged",
36
+ };
37
+
38
+ export interface DiffViewerProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
39
+ hunks: DiffHunk[];
40
+ /** Names the diff — usually the path of the file being changed. */
41
+ label: string;
42
+ view?: "unified" | "split";
43
+ /** Per-hunk decisions, keyed by hunk id. Controlled. */
44
+ decisions?: Record<string, HunkDecision>;
45
+ onDecision?: (hunkId: string, decision: HunkDecision) => void;
46
+ children?: ReactNode;
47
+ }
48
+
49
+ export function DiffViewer({
50
+ className,
51
+ hunks,
52
+ label,
53
+ view = "unified",
54
+ decisions,
55
+ onDecision,
56
+ children,
57
+ ...props
58
+ }: DiffViewerProps) {
59
+ const counts = useMemo(() => countChanges(hunks), [hunks]);
60
+
61
+ return (
62
+ <div
63
+ data-slot="diff-viewer"
64
+ data-view={view}
65
+ className={cn("flex flex-col gap-2", className)}
66
+ {...props}
67
+ >
68
+ <div className="flex flex-wrap items-baseline justify-between gap-2">
69
+ <span className="font-mono text-sm font-medium">{label}</span>
70
+ {/* Said in words as well as coloured, and before the diff rather than
71
+ after it: a reader deserves to know the size of the change before
72
+ they start reading it. */}
73
+ <span data-slot="diff-viewer-summary" className="text-xs text-muted-foreground">
74
+ <span className="text-success">+{counts.added}</span>{" "}
75
+ <span className="text-destructive">−{counts.removed}</span>
76
+ <span className="sr-only">
77
+ {` — ${String(counts.added)} lines added, ${String(counts.removed)} removed`}
78
+ </span>
79
+ </span>
80
+ </div>
81
+
82
+ {children}
83
+
84
+ {hunks.length === 0 ? (
85
+ <p className="rounded-lg border border-border bg-muted/30 p-3 text-sm text-muted-foreground">
86
+ No changes.
87
+ </p>
88
+ ) : (
89
+ <div className="overflow-hidden rounded-lg border border-border">
90
+ {hunks.map((hunk) => (
91
+ <DiffHunkView
92
+ key={hunk.id}
93
+ hunk={hunk}
94
+ label={label}
95
+ view={view}
96
+ decision={decisions?.[hunk.id]}
97
+ onDecision={onDecision}
98
+ />
99
+ ))}
100
+ </div>
101
+ )}
102
+ </div>
103
+ );
104
+ }
105
+
106
+ function DiffHunkView({
107
+ hunk,
108
+ label,
109
+ view,
110
+ decision,
111
+ onDecision,
112
+ }: {
113
+ hunk: DiffHunk;
114
+ label: string;
115
+ view: "unified" | "split";
116
+ decision?: HunkDecision;
117
+ onDecision?: (hunkId: string, decision: HunkDecision) => void;
118
+ }) {
119
+ const splitRows = useMemo(
120
+ () => (view === "split" ? toSplitRows(hunk.rows) : []),
121
+ [view, hunk.rows],
122
+ );
123
+
124
+ return (
125
+ <section
126
+ data-slot="diff-hunk"
127
+ data-decision={decision}
128
+ aria-label={`${label}, hunk ${hunk.id}`}
129
+ className={cn(
130
+ "border-b border-border last:border-b-0",
131
+ decision === "rejected" && "opacity-55",
132
+ )}
133
+ >
134
+ {hunk.skippedBefore > 0 ? (
135
+ <p
136
+ data-slot="diff-hunk-skipped"
137
+ className="border-b border-border bg-muted/40 px-3 py-1 font-mono text-2xs text-muted-foreground"
138
+ >
139
+ {hunk.skippedBefore} unchanged {hunk.skippedBefore === 1 ? "line" : "lines"} hidden
140
+ </p>
141
+ ) : null}
142
+
143
+ {onDecision ? (
144
+ <div className="flex flex-wrap items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5">
145
+ <span className="flex-1 text-xs text-muted-foreground">
146
+ {decision === "accepted"
147
+ ? "Accepted"
148
+ : decision === "rejected"
149
+ ? "Rejected"
150
+ : "Not decided"}
151
+ </span>
152
+ <HunkButton
153
+ pressed={decision === "accepted"}
154
+ onClick={() => {
155
+ onDecision(hunk.id, "accepted");
156
+ }}
157
+ >
158
+ Accept
159
+ </HunkButton>
160
+ <HunkButton
161
+ pressed={decision === "rejected"}
162
+ onClick={() => {
163
+ onDecision(hunk.id, "rejected");
164
+ }}
165
+ >
166
+ Reject
167
+ </HunkButton>
168
+ </div>
169
+ ) : null}
170
+
171
+ <div className="overflow-x-auto">
172
+ <table data-slot="diff-table" className="w-full border-collapse font-mono text-xs">
173
+ <tbody>
174
+ {view === "split"
175
+ ? splitRows.map((pair, index) => (
176
+ <tr key={index} data-slot="diff-row">
177
+ <SplitCell row={pair.left} side="before" />
178
+ <SplitCell row={pair.right} side="after" />
179
+ </tr>
180
+ ))
181
+ : hunk.rows.map((row, index) => <UnifiedRow key={index} row={row} />)}
182
+ </tbody>
183
+ </table>
184
+ </div>
185
+ </section>
186
+ );
187
+ }
188
+
189
+ const ROW_STYLES: Record<DiffRow["kind"], string> = {
190
+ added: "bg-success/10",
191
+ removed: "bg-destructive/10",
192
+ context: "",
193
+ };
194
+
195
+ function UnifiedRow({ row }: { row: DiffRow }) {
196
+ return (
197
+ <tr data-slot="diff-row" data-kind={row.kind} className={ROW_STYLES[row.kind]}>
198
+ <LineNumber value={row.before} />
199
+ <LineNumber value={row.after} />
200
+ <td className="w-4 pr-1 text-center text-muted-foreground select-none" aria-hidden="true">
201
+ {row.kind === "added" ? "+" : row.kind === "removed" ? "−" : ""}
202
+ </td>
203
+ <td className="w-full py-0.5 pr-3 break-all whitespace-pre-wrap">
204
+ {/* The kind, for anyone who cannot see the sign or the tint. Reading a
205
+ diff aloud without it is reading the same file twice. */}
206
+ <span className="sr-only">{KIND_LABEL[row.kind]}: </span>
207
+ <RowContent row={row} />
208
+ </td>
209
+ </tr>
210
+ );
211
+ }
212
+
213
+ function SplitCell({ row, side }: { row: DiffRow | null; side: "before" | "after" }) {
214
+ if (!row) {
215
+ // An empty half of a pair, not a blank line of code. Hidden from assistive
216
+ // technology so a reader is not read padding.
217
+ return (
218
+ <>
219
+ <td aria-hidden="true" className="w-10 bg-muted/40" />
220
+ <td aria-hidden="true" className="w-1/2 bg-muted/20" />
221
+ </>
222
+ );
223
+ }
224
+
225
+ return (
226
+ <>
227
+ <LineNumber value={side === "before" ? row.before : row.after} />
228
+ <td
229
+ className={cn("w-1/2 py-0.5 pr-3 break-all whitespace-pre-wrap", ROW_STYLES[row.kind])}
230
+ >
231
+ <span className="sr-only">{KIND_LABEL[row.kind]}: </span>
232
+ <RowContent row={row} />
233
+ </td>
234
+ </>
235
+ );
236
+ }
237
+
238
+ function RowContent({ row }: { row: DiffRow }) {
239
+ if (!row.segments) return <>{row.content}</>;
240
+
241
+ return (
242
+ <>
243
+ {row.segments.map((segment, index) =>
244
+ segment.changed ? (
245
+ // A mark element, so the changed words survive as structure rather
246
+ // than existing only as a slightly stronger background.
247
+ <mark
248
+ key={index}
249
+ className={cn(
250
+ "rounded-[2px] text-inherit",
251
+ row.kind === "added" ? "bg-success/30" : "bg-destructive/30",
252
+ )}
253
+ >
254
+ {segment.text}
255
+ </mark>
256
+ ) : (
257
+ <span key={index}>{segment.text}</span>
258
+ ),
259
+ )}
260
+ </>
261
+ );
262
+ }
263
+
264
+ function LineNumber({ value }: { value?: number }) {
265
+ return (
266
+ <td
267
+ // Decorative: the line number is orientation for a sighted reader, and
268
+ // announcing two numbers before every line makes the diff unlistenable.
269
+ aria-hidden="true"
270
+ className="w-10 border-r border-border bg-muted/40 px-2 py-0.5 text-right text-muted-foreground tabular-nums select-none"
271
+ >
272
+ {value ?? ""}
273
+ </td>
274
+ );
275
+ }
276
+
277
+ function HunkButton({
278
+ className,
279
+ pressed,
280
+ ...props
281
+ }: ComponentPropsWithRef<"button"> & { pressed: boolean }) {
282
+ return (
283
+ <button
284
+ type="button"
285
+ aria-pressed={pressed}
286
+ className={cn(
287
+ "rounded-md border px-2 py-0.5 text-xs font-medium transition-colors",
288
+ pressed
289
+ ? "border-primary bg-primary text-primary-foreground"
290
+ : "border-input bg-background hover:bg-accent hover:text-accent-foreground",
291
+ focusRing,
292
+ disabledStyles,
293
+ className,
294
+ )}
295
+ {...props}
296
+ />
297
+ );
298
+ }
299
+
300
+ /** Switches between unified and side-by-side. */
301
+ export function DiffViewerToolbar({
302
+ className,
303
+ view,
304
+ onViewChange,
305
+ ...props
306
+ }: Omit<ComponentPropsWithRef<"div">, "onChange"> & {
307
+ view: "unified" | "split";
308
+ onViewChange: (view: "unified" | "split") => void;
309
+ }) {
310
+ return (
311
+ <div
312
+ data-slot="diff-viewer-toolbar"
313
+ role="group"
314
+ aria-label="Diff layout"
315
+ className={cn("flex items-center gap-1", className)}
316
+ {...props}
317
+ >
318
+ {(["unified", "split"] as const).map((option) => (
319
+ <button
320
+ key={option}
321
+ type="button"
322
+ aria-pressed={view === option}
323
+ onClick={() => {
324
+ onViewChange(option);
325
+ }}
326
+ className={cn(
327
+ "rounded-md border px-2 py-0.5 text-xs font-medium capitalize transition-colors",
328
+ view === option
329
+ ? "border-transparent bg-secondary text-secondary-foreground"
330
+ : "border-input bg-background text-muted-foreground hover:text-foreground",
331
+ focusRing,
332
+ )}
333
+ >
334
+ {option}
335
+ </button>
336
+ ))}
337
+ </div>
338
+ );
339
+ }
@@ -0,0 +1,17 @@
1
+ export {
2
+ DiffViewer,
3
+ DiffViewerToolbar,
4
+ type DiffViewerProps,
5
+ type HunkDecision,
6
+ } from "./diff-viewer";
7
+ export {
8
+ buildDiff,
9
+ countChanges,
10
+ groupIntoHunks,
11
+ toSplitRows,
12
+ type BuildDiffOptions,
13
+ type DiffHunk,
14
+ type DiffRow,
15
+ type RowKind,
16
+ type WordSegment,
17
+ } from "./diff-model";
@@ -0,0 +1,20 @@
1
+ import { defineMeta } from "@/registry/schema";
2
+
3
+ export const meta = defineMeta({
4
+ name: "diff-viewer",
5
+ title: "Diff Viewer",
6
+ description: "A diff you can style, and accept or reject hunk by hunk.",
7
+ category: "data",
8
+ status: "stable",
9
+ dependencies: ["diff"],
10
+ registryDependencies: [],
11
+ files: ["diff-model.ts", "diff-viewer.tsx"],
12
+ a11y:
13
+ 'A semantic table, not a grid. role="grid" would promise cell-by-cell arrow navigation that ' +
14
+ "does not exist here and makes no sense for reading code. Every row states added, removed or " +
15
+ "unchanged in text, because a plus sign and a green tint are not information — a diff read " +
16
+ "aloud without it is just the same file twice. Line numbers are aria-hidden: they orient a " +
17
+ "sighted reader, and announcing two numbers before every line makes the diff unlistenable. " +
18
+ "Changed words are marked with mark elements so they survive as structure, and the empty half " +
19
+ "of a split pair is hidden rather than read as a blank line of code.",
20
+ });
@@ -0,0 +1,21 @@
1
+ export {
2
+ LogViewer,
3
+ LogViewerRow,
4
+ LogViewerToolbar,
5
+ type LogViewerProps,
6
+ type LogViewerRowProps,
7
+ type LogViewerToolbarProps,
8
+ } from "./log-viewer";
9
+ export {
10
+ LOG_LEVELS,
11
+ compileQuery,
12
+ findMatches,
13
+ segment,
14
+ useLogStream,
15
+ type FilterState,
16
+ type LogLevel,
17
+ type LogLine,
18
+ type MatchRange,
19
+ type UseLogStreamOptions,
20
+ type VisibleLine,
21
+ } from "./log-stream";