@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,42 @@
1
+ import { DiffHunk } from "./diff-model.js";
2
+ import { ComponentPropsWithRef, ReactNode } from "react";
3
+ //#region src/components/diff-viewer/diff-viewer.d.ts
4
+ /**
5
+ * A diff, and a decision about it.
6
+ *
7
+ * The rendering half exists because every packaged viewer brings its own
8
+ * styling system — emotion, or HTML strings plus a stylesheet — which design
9
+ * tokens cannot reach and which is awkward under RSC. The deciding half exists
10
+ * because Dowel is for AI products, and an agent proposing a change to a file is
11
+ * the case that needs it: what every coding agent ships, and no component
12
+ * library does.
13
+ *
14
+ * Decisions are controlled. The component never mutates the diff or applies
15
+ * anything; it reports which hunks were accepted and rejected and leaves the
16
+ * consequences to the application, the same way ai-action-ledger does.
17
+ *
18
+ * Not a grid. A code listing is a table of text, and role="grid" would promise
19
+ * cell-by-cell arrow navigation that neither exists here nor makes sense for
20
+ * reading code. Line kind is carried in text, because a plus sign and a green
21
+ * background are not information.
22
+ */
23
+ type HunkDecision = "accepted" | "rejected";
24
+ interface DiffViewerProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
25
+ hunks: DiffHunk[];
26
+ /** Names the diff — usually the path of the file being changed. */
27
+ label: string;
28
+ view?: "unified" | "split";
29
+ /** Per-hunk decisions, keyed by hunk id. Controlled. */
30
+ decisions?: Record<string, HunkDecision>;
31
+ onDecision?: (hunkId: string, decision: HunkDecision) => void;
32
+ children?: ReactNode;
33
+ }
34
+ declare function DiffViewer({ className, hunks, label, view, decisions, onDecision, children, ...props }: DiffViewerProps): import("react").JSX.Element;
35
+ /** Switches between unified and side-by-side. */
36
+ declare function DiffViewerToolbar({ className, view, onViewChange, ...props }: Omit<ComponentPropsWithRef<"div">, "onChange"> & {
37
+ view: "unified" | "split";
38
+ onViewChange: (view: "unified" | "split") => void;
39
+ }): import("react").JSX.Element;
40
+ //#endregion
41
+ export { DiffViewer, DiffViewerProps, DiffViewerToolbar, HunkDecision };
42
+ //# sourceMappingURL=diff-viewer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"diff-viewer.d.ts","names":[],"sources":["../../../src/components/diff-viewer/diff-viewer.tsx"],"mappings":";;;;;;;;;;;;;;;;;;;;;;KA6BY;UAQK,wBAAwB,KAAK;EAC5C,OAAO;;EAEP;EACA;;EAEA,YAAY,eAAe;EAC3B,cAAc,gBAAgB,UAAU;EACxC,WAAW;;iBAGG,aACd,WACA,OACA,OACA,MACA,WACA,YACA,aACG,SACF,kCAAe,IAAA;;iBAmPF,oBACd,WACA,MACA,iBACG,SACF,KAAK;EACN;EACA,eAAe;oBAChB,IAAA"}
@@ -0,0 +1,212 @@
1
+ "use client";
2
+ import { disabledStyles, focusRing } from "../../lib/styles.js";
3
+ import { cn } from "../../lib/utils.js";
4
+ import { countChanges, toSplitRows } from "./diff-model.js";
5
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
+ import { useMemo } from "react";
7
+ //#region src/components/diff-viewer/diff-viewer.tsx
8
+ const KIND_LABEL = {
9
+ added: "Added",
10
+ removed: "Removed",
11
+ context: "Unchanged"
12
+ };
13
+ function DiffViewer({ className, hunks, label, view = "unified", decisions, onDecision, children, ...props }) {
14
+ const counts = useMemo(() => countChanges(hunks), [hunks]);
15
+ return /* @__PURE__ */ jsxs("div", {
16
+ "data-slot": "diff-viewer",
17
+ "data-view": view,
18
+ className: cn("flex flex-col gap-2", className),
19
+ ...props,
20
+ children: [
21
+ /* @__PURE__ */ jsxs("div", {
22
+ className: "flex flex-wrap items-baseline justify-between gap-2",
23
+ children: [/* @__PURE__ */ jsx("span", {
24
+ className: "font-mono text-sm font-medium",
25
+ children: label
26
+ }), /* @__PURE__ */ jsxs("span", {
27
+ "data-slot": "diff-viewer-summary",
28
+ className: "text-xs text-muted-foreground",
29
+ children: [
30
+ /* @__PURE__ */ jsxs("span", {
31
+ className: "text-success",
32
+ children: ["+", counts.added]
33
+ }),
34
+ " ",
35
+ /* @__PURE__ */ jsxs("span", {
36
+ className: "text-destructive",
37
+ children: ["−", counts.removed]
38
+ }),
39
+ /* @__PURE__ */ jsx("span", {
40
+ className: "sr-only",
41
+ children: ` — ${String(counts.added)} lines added, ${String(counts.removed)} removed`
42
+ })
43
+ ]
44
+ })]
45
+ }),
46
+ children,
47
+ hunks.length === 0 ? /* @__PURE__ */ jsx("p", {
48
+ className: "rounded-lg border border-border bg-muted/30 p-3 text-sm text-muted-foreground",
49
+ children: "No changes."
50
+ }) : /* @__PURE__ */ jsx("div", {
51
+ className: "overflow-hidden rounded-lg border border-border",
52
+ children: hunks.map((hunk) => /* @__PURE__ */ jsx(DiffHunkView, {
53
+ hunk,
54
+ label,
55
+ view,
56
+ decision: decisions?.[hunk.id],
57
+ onDecision
58
+ }, hunk.id))
59
+ })
60
+ ]
61
+ });
62
+ }
63
+ function DiffHunkView({ hunk, label, view, decision, onDecision }) {
64
+ const splitRows = useMemo(() => view === "split" ? toSplitRows(hunk.rows) : [], [view, hunk.rows]);
65
+ return /* @__PURE__ */ jsxs("section", {
66
+ "data-slot": "diff-hunk",
67
+ "data-decision": decision,
68
+ "aria-label": `${label}, hunk ${hunk.id}`,
69
+ className: cn("border-b border-border last:border-b-0", decision === "rejected" && "opacity-55"),
70
+ children: [
71
+ hunk.skippedBefore > 0 ? /* @__PURE__ */ jsxs("p", {
72
+ "data-slot": "diff-hunk-skipped",
73
+ className: "border-b border-border bg-muted/40 px-3 py-1 font-mono text-2xs text-muted-foreground",
74
+ children: [
75
+ hunk.skippedBefore,
76
+ " unchanged ",
77
+ hunk.skippedBefore === 1 ? "line" : "lines",
78
+ " hidden"
79
+ ]
80
+ }) : null,
81
+ onDecision ? /* @__PURE__ */ jsxs("div", {
82
+ className: "flex flex-wrap items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5",
83
+ children: [
84
+ /* @__PURE__ */ jsx("span", {
85
+ className: "flex-1 text-xs text-muted-foreground",
86
+ children: decision === "accepted" ? "Accepted" : decision === "rejected" ? "Rejected" : "Not decided"
87
+ }),
88
+ /* @__PURE__ */ jsx(HunkButton, {
89
+ pressed: decision === "accepted",
90
+ onClick: () => {
91
+ onDecision(hunk.id, "accepted");
92
+ },
93
+ children: "Accept"
94
+ }),
95
+ /* @__PURE__ */ jsx(HunkButton, {
96
+ pressed: decision === "rejected",
97
+ onClick: () => {
98
+ onDecision(hunk.id, "rejected");
99
+ },
100
+ children: "Reject"
101
+ })
102
+ ]
103
+ }) : null,
104
+ /* @__PURE__ */ jsx("div", {
105
+ className: "overflow-x-auto",
106
+ children: /* @__PURE__ */ jsx("table", {
107
+ "data-slot": "diff-table",
108
+ className: "w-full border-collapse font-mono text-xs",
109
+ children: /* @__PURE__ */ jsx("tbody", { children: view === "split" ? splitRows.map((pair, index) => /* @__PURE__ */ jsxs("tr", {
110
+ "data-slot": "diff-row",
111
+ children: [/* @__PURE__ */ jsx(SplitCell, {
112
+ row: pair.left,
113
+ side: "before"
114
+ }), /* @__PURE__ */ jsx(SplitCell, {
115
+ row: pair.right,
116
+ side: "after"
117
+ })]
118
+ }, index)) : hunk.rows.map((row, index) => /* @__PURE__ */ jsx(UnifiedRow, { row }, index)) })
119
+ })
120
+ })
121
+ ]
122
+ });
123
+ }
124
+ const ROW_STYLES = {
125
+ added: "bg-success/10",
126
+ removed: "bg-destructive/10",
127
+ context: ""
128
+ };
129
+ function UnifiedRow({ row }) {
130
+ return /* @__PURE__ */ jsxs("tr", {
131
+ "data-slot": "diff-row",
132
+ "data-kind": row.kind,
133
+ className: ROW_STYLES[row.kind],
134
+ children: [
135
+ /* @__PURE__ */ jsx(LineNumber, { value: row.before }),
136
+ /* @__PURE__ */ jsx(LineNumber, { value: row.after }),
137
+ /* @__PURE__ */ jsx("td", {
138
+ className: "w-4 pr-1 text-center text-muted-foreground select-none",
139
+ "aria-hidden": "true",
140
+ children: row.kind === "added" ? "+" : row.kind === "removed" ? "−" : ""
141
+ }),
142
+ /* @__PURE__ */ jsxs("td", {
143
+ className: "w-full py-0.5 pr-3 break-all whitespace-pre-wrap",
144
+ children: [/* @__PURE__ */ jsxs("span", {
145
+ className: "sr-only",
146
+ children: [KIND_LABEL[row.kind], ": "]
147
+ }), /* @__PURE__ */ jsx(RowContent, { row })]
148
+ })
149
+ ]
150
+ });
151
+ }
152
+ function SplitCell({ row, side }) {
153
+ if (!row) return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("td", {
154
+ "aria-hidden": "true",
155
+ className: "w-10 bg-muted/40"
156
+ }), /* @__PURE__ */ jsx("td", {
157
+ "aria-hidden": "true",
158
+ className: "w-1/2 bg-muted/20"
159
+ })] });
160
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(LineNumber, { value: side === "before" ? row.before : row.after }), /* @__PURE__ */ jsxs("td", {
161
+ className: cn("w-1/2 py-0.5 pr-3 break-all whitespace-pre-wrap", ROW_STYLES[row.kind]),
162
+ children: [/* @__PURE__ */ jsxs("span", {
163
+ className: "sr-only",
164
+ children: [KIND_LABEL[row.kind], ": "]
165
+ }), /* @__PURE__ */ jsx(RowContent, { row })]
166
+ })] });
167
+ }
168
+ function RowContent({ row }) {
169
+ if (!row.segments) return /* @__PURE__ */ jsx(Fragment, { children: row.content });
170
+ return /* @__PURE__ */ jsx(Fragment, { children: row.segments.map((segment, index) => segment.changed ? /* @__PURE__ */ jsx("mark", {
171
+ className: cn("rounded-[2px] text-inherit", row.kind === "added" ? "bg-success/30" : "bg-destructive/30"),
172
+ children: segment.text
173
+ }, index) : /* @__PURE__ */ jsx("span", { children: segment.text }, index)) });
174
+ }
175
+ function LineNumber({ value }) {
176
+ return /* @__PURE__ */ jsx("td", {
177
+ "aria-hidden": "true",
178
+ className: "w-10 border-r border-border bg-muted/40 px-2 py-0.5 text-right text-muted-foreground tabular-nums select-none",
179
+ children: value ?? ""
180
+ });
181
+ }
182
+ function HunkButton({ className, pressed, ...props }) {
183
+ return /* @__PURE__ */ jsx("button", {
184
+ type: "button",
185
+ "aria-pressed": pressed,
186
+ className: cn("rounded-md border px-2 py-0.5 text-xs font-medium transition-colors", pressed ? "border-primary bg-primary text-primary-foreground" : "border-input bg-background hover:bg-accent hover:text-accent-foreground", focusRing, disabledStyles, className),
187
+ ...props
188
+ });
189
+ }
190
+ /** Switches between unified and side-by-side. */
191
+ function DiffViewerToolbar({ className, view, onViewChange, ...props }) {
192
+ return /* @__PURE__ */ jsx("div", {
193
+ "data-slot": "diff-viewer-toolbar",
194
+ role: "group",
195
+ "aria-label": "Diff layout",
196
+ className: cn("flex items-center gap-1", className),
197
+ ...props,
198
+ children: ["unified", "split"].map((option) => /* @__PURE__ */ jsx("button", {
199
+ type: "button",
200
+ "aria-pressed": view === option,
201
+ onClick: () => {
202
+ onViewChange(option);
203
+ },
204
+ className: cn("rounded-md border px-2 py-0.5 text-xs font-medium capitalize transition-colors", view === option ? "border-transparent bg-secondary text-secondary-foreground" : "border-input bg-background text-muted-foreground hover:text-foreground", focusRing),
205
+ children: option
206
+ }, option))
207
+ });
208
+ }
209
+ //#endregion
210
+ export { DiffViewer, DiffViewerToolbar };
211
+
212
+ //# sourceMappingURL=diff-viewer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"diff-viewer.js","names":[],"sources":["../../../src/components/diff-viewer/diff-viewer.tsx"],"sourcesContent":["\"use client\";\n\nimport { useMemo, type ComponentPropsWithRef, type ReactNode } from \"react\";\n\nimport { disabledStyles, focusRing } from \"@/lib/styles\";\nimport { cn } from \"@/lib/utils\";\n\nimport { countChanges, toSplitRows, type DiffHunk, type DiffRow } from \"./diff-model\";\n\n/**\n * A diff, and a decision about it.\n *\n * The rendering half exists because every packaged viewer brings its own\n * styling system — emotion, or HTML strings plus a stylesheet — which design\n * tokens cannot reach and which is awkward under RSC. The deciding half exists\n * because Dowel is for AI products, and an agent proposing a change to a file is\n * the case that needs it: what every coding agent ships, and no component\n * library does.\n *\n * Decisions are controlled. The component never mutates the diff or applies\n * anything; it reports which hunks were accepted and rejected and leaves the\n * consequences to the application, the same way ai-action-ledger does.\n *\n * Not a grid. A code listing is a table of text, and role=\"grid\" would promise\n * cell-by-cell arrow navigation that neither exists here nor makes sense for\n * reading code. Line kind is carried in text, because a plus sign and a green\n * background are not information.\n */\n\nexport type HunkDecision = \"accepted\" | \"rejected\";\n\nconst KIND_LABEL: Record<DiffRow[\"kind\"], string> = {\n added: \"Added\",\n removed: \"Removed\",\n context: \"Unchanged\",\n};\n\nexport interface DiffViewerProps extends Omit<ComponentPropsWithRef<\"div\">, \"children\"> {\n hunks: DiffHunk[];\n /** Names the diff — usually the path of the file being changed. */\n label: string;\n view?: \"unified\" | \"split\";\n /** Per-hunk decisions, keyed by hunk id. Controlled. */\n decisions?: Record<string, HunkDecision>;\n onDecision?: (hunkId: string, decision: HunkDecision) => void;\n children?: ReactNode;\n}\n\nexport function DiffViewer({\n className,\n hunks,\n label,\n view = \"unified\",\n decisions,\n onDecision,\n children,\n ...props\n}: DiffViewerProps) {\n const counts = useMemo(() => countChanges(hunks), [hunks]);\n\n return (\n <div\n data-slot=\"diff-viewer\"\n data-view={view}\n className={cn(\"flex flex-col gap-2\", className)}\n {...props}\n >\n <div className=\"flex flex-wrap items-baseline justify-between gap-2\">\n <span className=\"font-mono text-sm font-medium\">{label}</span>\n {/* Said in words as well as coloured, and before the diff rather than\n after it: a reader deserves to know the size of the change before\n they start reading it. */}\n <span data-slot=\"diff-viewer-summary\" className=\"text-xs text-muted-foreground\">\n <span className=\"text-success\">+{counts.added}</span>{\" \"}\n <span className=\"text-destructive\">−{counts.removed}</span>\n <span className=\"sr-only\">\n {` — ${String(counts.added)} lines added, ${String(counts.removed)} removed`}\n </span>\n </span>\n </div>\n\n {children}\n\n {hunks.length === 0 ? (\n <p className=\"rounded-lg border border-border bg-muted/30 p-3 text-sm text-muted-foreground\">\n No changes.\n </p>\n ) : (\n <div className=\"overflow-hidden rounded-lg border border-border\">\n {hunks.map((hunk) => (\n <DiffHunkView\n key={hunk.id}\n hunk={hunk}\n label={label}\n view={view}\n decision={decisions?.[hunk.id]}\n onDecision={onDecision}\n />\n ))}\n </div>\n )}\n </div>\n );\n}\n\nfunction DiffHunkView({\n hunk,\n label,\n view,\n decision,\n onDecision,\n}: {\n hunk: DiffHunk;\n label: string;\n view: \"unified\" | \"split\";\n decision?: HunkDecision;\n onDecision?: (hunkId: string, decision: HunkDecision) => void;\n}) {\n const splitRows = useMemo(\n () => (view === \"split\" ? toSplitRows(hunk.rows) : []),\n [view, hunk.rows],\n );\n\n return (\n <section\n data-slot=\"diff-hunk\"\n data-decision={decision}\n aria-label={`${label}, hunk ${hunk.id}`}\n className={cn(\n \"border-b border-border last:border-b-0\",\n decision === \"rejected\" && \"opacity-55\",\n )}\n >\n {hunk.skippedBefore > 0 ? (\n <p\n data-slot=\"diff-hunk-skipped\"\n className=\"border-b border-border bg-muted/40 px-3 py-1 font-mono text-2xs text-muted-foreground\"\n >\n {hunk.skippedBefore} unchanged {hunk.skippedBefore === 1 ? \"line\" : \"lines\"} hidden\n </p>\n ) : null}\n\n {onDecision ? (\n <div className=\"flex flex-wrap items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5\">\n <span className=\"flex-1 text-xs text-muted-foreground\">\n {decision === \"accepted\"\n ? \"Accepted\"\n : decision === \"rejected\"\n ? \"Rejected\"\n : \"Not decided\"}\n </span>\n <HunkButton\n pressed={decision === \"accepted\"}\n onClick={() => {\n onDecision(hunk.id, \"accepted\");\n }}\n >\n Accept\n </HunkButton>\n <HunkButton\n pressed={decision === \"rejected\"}\n onClick={() => {\n onDecision(hunk.id, \"rejected\");\n }}\n >\n Reject\n </HunkButton>\n </div>\n ) : null}\n\n <div className=\"overflow-x-auto\">\n <table data-slot=\"diff-table\" className=\"w-full border-collapse font-mono text-xs\">\n <tbody>\n {view === \"split\"\n ? splitRows.map((pair, index) => (\n <tr key={index} data-slot=\"diff-row\">\n <SplitCell row={pair.left} side=\"before\" />\n <SplitCell row={pair.right} side=\"after\" />\n </tr>\n ))\n : hunk.rows.map((row, index) => <UnifiedRow key={index} row={row} />)}\n </tbody>\n </table>\n </div>\n </section>\n );\n}\n\nconst ROW_STYLES: Record<DiffRow[\"kind\"], string> = {\n added: \"bg-success/10\",\n removed: \"bg-destructive/10\",\n context: \"\",\n};\n\nfunction UnifiedRow({ row }: { row: DiffRow }) {\n return (\n <tr data-slot=\"diff-row\" data-kind={row.kind} className={ROW_STYLES[row.kind]}>\n <LineNumber value={row.before} />\n <LineNumber value={row.after} />\n <td className=\"w-4 pr-1 text-center text-muted-foreground select-none\" aria-hidden=\"true\">\n {row.kind === \"added\" ? \"+\" : row.kind === \"removed\" ? \"−\" : \"\"}\n </td>\n <td className=\"w-full py-0.5 pr-3 break-all whitespace-pre-wrap\">\n {/* The kind, for anyone who cannot see the sign or the tint. Reading a\n diff aloud without it is reading the same file twice. */}\n <span className=\"sr-only\">{KIND_LABEL[row.kind]}: </span>\n <RowContent row={row} />\n </td>\n </tr>\n );\n}\n\nfunction SplitCell({ row, side }: { row: DiffRow | null; side: \"before\" | \"after\" }) {\n if (!row) {\n // An empty half of a pair, not a blank line of code. Hidden from assistive\n // technology so a reader is not read padding.\n return (\n <>\n <td aria-hidden=\"true\" className=\"w-10 bg-muted/40\" />\n <td aria-hidden=\"true\" className=\"w-1/2 bg-muted/20\" />\n </>\n );\n }\n\n return (\n <>\n <LineNumber value={side === \"before\" ? row.before : row.after} />\n <td\n className={cn(\"w-1/2 py-0.5 pr-3 break-all whitespace-pre-wrap\", ROW_STYLES[row.kind])}\n >\n <span className=\"sr-only\">{KIND_LABEL[row.kind]}: </span>\n <RowContent row={row} />\n </td>\n </>\n );\n}\n\nfunction RowContent({ row }: { row: DiffRow }) {\n if (!row.segments) return <>{row.content}</>;\n\n return (\n <>\n {row.segments.map((segment, index) =>\n segment.changed ? (\n // A mark element, so the changed words survive as structure rather\n // than existing only as a slightly stronger background.\n <mark\n key={index}\n className={cn(\n \"rounded-[2px] text-inherit\",\n row.kind === \"added\" ? \"bg-success/30\" : \"bg-destructive/30\",\n )}\n >\n {segment.text}\n </mark>\n ) : (\n <span key={index}>{segment.text}</span>\n ),\n )}\n </>\n );\n}\n\nfunction LineNumber({ value }: { value?: number }) {\n return (\n <td\n // Decorative: the line number is orientation for a sighted reader, and\n // announcing two numbers before every line makes the diff unlistenable.\n aria-hidden=\"true\"\n className=\"w-10 border-r border-border bg-muted/40 px-2 py-0.5 text-right text-muted-foreground tabular-nums select-none\"\n >\n {value ?? \"\"}\n </td>\n );\n}\n\nfunction HunkButton({\n className,\n pressed,\n ...props\n}: ComponentPropsWithRef<\"button\"> & { pressed: boolean }) {\n return (\n <button\n type=\"button\"\n aria-pressed={pressed}\n className={cn(\n \"rounded-md border px-2 py-0.5 text-xs font-medium transition-colors\",\n pressed\n ? \"border-primary bg-primary text-primary-foreground\"\n : \"border-input bg-background hover:bg-accent hover:text-accent-foreground\",\n focusRing,\n disabledStyles,\n className,\n )}\n {...props}\n />\n );\n}\n\n/** Switches between unified and side-by-side. */\nexport function DiffViewerToolbar({\n className,\n view,\n onViewChange,\n ...props\n}: Omit<ComponentPropsWithRef<\"div\">, \"onChange\"> & {\n view: \"unified\" | \"split\";\n onViewChange: (view: \"unified\" | \"split\") => void;\n}) {\n return (\n <div\n data-slot=\"diff-viewer-toolbar\"\n role=\"group\"\n aria-label=\"Diff layout\"\n className={cn(\"flex items-center gap-1\", className)}\n {...props}\n >\n {([\"unified\", \"split\"] as const).map((option) => (\n <button\n key={option}\n type=\"button\"\n aria-pressed={view === option}\n onClick={() => {\n onViewChange(option);\n }}\n className={cn(\n \"rounded-md border px-2 py-0.5 text-xs font-medium capitalize transition-colors\",\n view === option\n ? \"border-transparent bg-secondary text-secondary-foreground\"\n : \"border-input bg-background text-muted-foreground hover:text-foreground\",\n focusRing,\n )}\n >\n {option}\n </button>\n ))}\n </div>\n );\n}\n"],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ import { BuildDiffOptions, DiffHunk, DiffRow, RowKind, WordSegment, buildDiff, countChanges, groupIntoHunks, toSplitRows } from "./diff-model.js";
2
+ import { DiffViewer, DiffViewerProps, DiffViewerToolbar, HunkDecision } from "./diff-viewer.js";
3
+ export { type BuildDiffOptions, type DiffHunk, type DiffRow, DiffViewer, type DiffViewerProps, DiffViewerToolbar, type HunkDecision, type RowKind, type WordSegment, buildDiff, countChanges, groupIntoHunks, toSplitRows };
@@ -0,0 +1,3 @@
1
+ import { buildDiff, countChanges, groupIntoHunks, toSplitRows } from "./diff-model.js";
2
+ import { DiffViewer, DiffViewerToolbar } from "./diff-viewer.js";
3
+ export { DiffViewer, DiffViewerToolbar, buildDiff, countChanges, groupIntoHunks, toSplitRows };
@@ -0,0 +1,17 @@
1
+ import { defineMeta } from "../../registry/schema.js";
2
+ //#region src/components/diff-viewer/meta.ts
3
+ 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: "A semantic table, not a grid. role=\"grid\" would promise cell-by-cell arrow navigation that does not exist here and makes no sense for reading code. Every row states added, removed or unchanged in text, because a plus sign and a green tint are not information — a diff read aloud without it is just the same file twice. Line numbers are aria-hidden: they orient a sighted reader, and announcing two numbers before every line makes the diff unlistenable. Changed words are marked with mark elements so they survive as structure, and the empty half of a split pair is hidden rather than read as a blank line of code."
13
+ });
14
+ //#endregion
15
+ export { meta };
16
+
17
+ //# sourceMappingURL=meta.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"meta.js","names":[],"sources":["../../../src/components/diff-viewer/meta.ts"],"sourcesContent":["import { defineMeta } from \"@/registry/schema\";\n\nexport const meta = defineMeta({\n name: \"diff-viewer\",\n title: \"Diff Viewer\",\n description: \"A diff you can style, and accept or reject hunk by hunk.\",\n category: \"data\",\n status: \"stable\",\n dependencies: [\"diff\"],\n registryDependencies: [],\n files: [\"diff-model.ts\", \"diff-viewer.tsx\"],\n a11y:\n 'A semantic table, not a grid. role=\"grid\" would promise cell-by-cell arrow navigation that ' +\n \"does not exist here and makes no sense for reading code. Every row states added, removed or \" +\n \"unchanged in text, because a plus sign and a green tint are not information — a diff read \" +\n \"aloud without it is just the same file twice. Line numbers are aria-hidden: they orient a \" +\n \"sighted reader, and announcing two numbers before every line makes the diff unlistenable. \" +\n \"Changed words are marked with mark elements so they survive as structure, and the empty half \" +\n \"of a split pair is hidden rather than read as a blank line of code.\",\n});\n"],"mappings":";;AAEA,MAAa,OAAO,WAAW;CAC7B,MAAM;CACN,OAAO;CACP,aAAa;CACb,UAAU;CACV,QAAQ;CACR,cAAc,CAAC,MAAM;CACrB,sBAAsB,CAAC;CACvB,OAAO,CAAC,iBAAiB,iBAAiB;CAC1C,MACE;AAOJ,CAAC"}
@@ -0,0 +1,3 @@
1
+ import { FilterState, LOG_LEVELS, LogLevel, LogLine, MatchRange, UseLogStreamOptions, VisibleLine, compileQuery, findMatches, segment, useLogStream } from "./log-stream.js";
2
+ import { LogViewer, LogViewerProps, LogViewerRow, LogViewerRowProps, LogViewerToolbar, LogViewerToolbarProps } from "./log-viewer.js";
3
+ export { type FilterState, LOG_LEVELS, type LogLevel, type LogLine, LogViewer, type LogViewerProps, LogViewerRow, type LogViewerRowProps, LogViewerToolbar, type LogViewerToolbarProps, type MatchRange, type UseLogStreamOptions, type VisibleLine, compileQuery, findMatches, segment, useLogStream };
@@ -0,0 +1,3 @@
1
+ import { LOG_LEVELS, compileQuery, findMatches, segment, useLogStream } from "./log-stream.js";
2
+ import { LogViewer, LogViewerRow, LogViewerToolbar } from "./log-viewer.js";
3
+ export { LOG_LEVELS, LogViewer, LogViewerRow, LogViewerToolbar, compileQuery, findMatches, segment, useLogStream };
@@ -0,0 +1,68 @@
1
+ //#region src/components/log-viewer/log-stream.d.ts
2
+ /**
3
+ * Filtering and match-finding for a log stream.
4
+ *
5
+ * Separate from the view because it is pure and worth testing without a DOM,
6
+ * and because the awkward parts are here rather than in the rendering: a bad
7
+ * regex must not throw while someone is halfway through typing it, and a match
8
+ * has to be located precisely enough to highlight rather than merely detected.
9
+ */
10
+ type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
11
+ declare const LOG_LEVELS: readonly LogLevel[];
12
+ interface LogLine {
13
+ id: string;
14
+ message: string;
15
+ level?: LogLevel;
16
+ /** ISO timestamp, or anything the consumer wants shown in the gutter. */
17
+ timestamp?: string;
18
+ /** Structured fields, revealed when the row is expanded. */
19
+ fields?: Record<string, unknown>;
20
+ }
21
+ /** A [start, end) slice of a message that matched the filter. */
22
+ type MatchRange = readonly [number, number];
23
+ interface FilterState {
24
+ /** Substring, or a pattern when `regex` is on. */
25
+ query: string;
26
+ regex: boolean;
27
+ /** Levels to show. Empty means all of them. */
28
+ levels: Set<LogLevel>;
29
+ }
30
+ /**
31
+ * Compiles the query.
32
+ *
33
+ * Returns null for an invalid pattern rather than throwing: the query is being
34
+ * typed, so it spends most of its life syntactically incomplete, and a viewer
35
+ * that crashes on "(" is unusable.
36
+ */
37
+ declare function compileQuery(query: string, regex: boolean): RegExp | null;
38
+ /** Every match in a line, so each can be highlighted rather than just the first. */
39
+ declare function findMatches(message: string, pattern: RegExp | null): MatchRange[];
40
+ /** Splits a message into alternating plain and matched segments. */
41
+ declare function segment(message: string, ranges: MatchRange[]): {
42
+ text: string;
43
+ match: boolean;
44
+ }[];
45
+ interface VisibleLine extends LogLine {
46
+ matches: MatchRange[];
47
+ }
48
+ interface UseLogStreamOptions {
49
+ lines: LogLine[];
50
+ /** Levels present but unchecked are hidden. Empty shows everything. */
51
+ initialLevels?: Set<LogLevel>;
52
+ }
53
+ declare function useLogStream({ lines, initialLevels }: UseLogStreamOptions): {
54
+ query: string;
55
+ setQuery: import("react").Dispatch<import("react").SetStateAction<string>>;
56
+ regex: boolean;
57
+ setRegex: import("react").Dispatch<import("react").SetStateAction<boolean>>;
58
+ levels: Set<LogLevel>;
59
+ toggleLevel: (level: LogLevel) => void;
60
+ setLevels: import("react").Dispatch<import("react").SetStateAction<Set<LogLevel>>>;
61
+ visible: VisibleLine[];
62
+ counts: Map<LogLevel, number>;
63
+ invalidPattern: boolean;
64
+ total: number;
65
+ };
66
+ //#endregion
67
+ export { FilterState, LOG_LEVELS, LogLevel, LogLine, MatchRange, UseLogStreamOptions, VisibleLine, compileQuery, findMatches, segment, useLogStream };
68
+ //# sourceMappingURL=log-stream.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"log-stream.d.ts","names":[],"sources":["../../../src/components/log-viewer/log-stream.ts"],"mappings":";;;;;;;;;KAaY;cAEC,qBAAqB;UASjB;EACf;EACA;EACA,QAAQ;;EAER;;EAEA,SAAS;;;KAIC;UAEK;;EAEf;EACA;;EAEA,QAAQ,IAAI;;;;;;;;;iBAUE,aAAa,eAAe,iBAAiB;;iBAY7C,YAAY,iBAAiB,SAAS,gBAAgB;;iBAuBtD,QACd,iBACA,QAAQ;EACL;EAAc;;UAgBF,oBAAoB;EACnC,SAAS;;UAGM;EACf,OAAO;;EAEP,gBAAgB,IAAI;;iBAGN,eAAe,OAAO,iBAAiB;;;;;;EAqCb,cAAA,OAAA"}
@@ -0,0 +1,120 @@
1
+ "use client";
2
+ import { useCallback, useMemo, useState } from "react";
3
+ //#region src/components/log-viewer/log-stream.ts
4
+ const LOG_LEVELS = [
5
+ "trace",
6
+ "debug",
7
+ "info",
8
+ "warn",
9
+ "error",
10
+ "fatal"
11
+ ];
12
+ /**
13
+ * Compiles the query.
14
+ *
15
+ * Returns null for an invalid pattern rather than throwing: the query is being
16
+ * typed, so it spends most of its life syntactically incomplete, and a viewer
17
+ * that crashes on "(" is unusable.
18
+ */
19
+ function compileQuery(query, regex) {
20
+ if (query.length === 0) return null;
21
+ try {
22
+ return regex ? new RegExp(query, "gi") : new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+ /** Every match in a line, so each can be highlighted rather than just the first. */
28
+ function findMatches(message, pattern) {
29
+ if (!pattern) return [];
30
+ const ranges = [];
31
+ pattern.lastIndex = 0;
32
+ let match = pattern.exec(message);
33
+ while (match !== null) {
34
+ if (match[0].length === 0) pattern.lastIndex += 1;
35
+ else ranges.push([match.index, match.index + match[0].length]);
36
+ match = pattern.exec(message);
37
+ }
38
+ return ranges;
39
+ }
40
+ /** Splits a message into alternating plain and matched segments. */
41
+ function segment(message, ranges) {
42
+ if (ranges.length === 0) return [{
43
+ text: message,
44
+ match: false
45
+ }];
46
+ const parts = [];
47
+ let cursor = 0;
48
+ for (const [start, end] of ranges) {
49
+ if (start > cursor) parts.push({
50
+ text: message.slice(cursor, start),
51
+ match: false
52
+ });
53
+ parts.push({
54
+ text: message.slice(start, end),
55
+ match: true
56
+ });
57
+ cursor = end;
58
+ }
59
+ if (cursor < message.length) parts.push({
60
+ text: message.slice(cursor),
61
+ match: false
62
+ });
63
+ return parts;
64
+ }
65
+ function useLogStream({ lines, initialLevels }) {
66
+ const [query, setQuery] = useState("");
67
+ const [regex, setRegex] = useState(false);
68
+ const [levels, setLevels] = useState(initialLevels ?? /* @__PURE__ */ new Set());
69
+ const pattern = useMemo(() => compileQuery(query, regex), [query, regex]);
70
+ const invalidPattern = regex && query.length > 0 && pattern === null;
71
+ const visible = useMemo(() => {
72
+ const result = [];
73
+ for (const line of lines) {
74
+ if (levels.size > 0 && line.level && !levels.has(line.level)) continue;
75
+ const matches = findMatches(line.message, pattern);
76
+ if (pattern && matches.length === 0) continue;
77
+ result.push({
78
+ ...line,
79
+ matches
80
+ });
81
+ }
82
+ return result;
83
+ }, [
84
+ lines,
85
+ levels,
86
+ pattern
87
+ ]);
88
+ const counts = useMemo(() => {
89
+ const byLevel = /* @__PURE__ */ new Map();
90
+ for (const line of lines) {
91
+ if (!line.level) continue;
92
+ byLevel.set(line.level, (byLevel.get(line.level) ?? 0) + 1);
93
+ }
94
+ return byLevel;
95
+ }, [lines]);
96
+ return {
97
+ query,
98
+ setQuery,
99
+ regex,
100
+ setRegex,
101
+ levels,
102
+ toggleLevel: useCallback((level) => {
103
+ setLevels((current) => {
104
+ const next = new Set(current);
105
+ if (next.has(level)) next.delete(level);
106
+ else next.add(level);
107
+ return next;
108
+ });
109
+ }, []),
110
+ setLevels,
111
+ visible,
112
+ counts,
113
+ invalidPattern,
114
+ total: lines.length
115
+ };
116
+ }
117
+ //#endregion
118
+ export { LOG_LEVELS, compileQuery, findMatches, segment, useLogStream };
119
+
120
+ //# sourceMappingURL=log-stream.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"log-stream.js","names":[],"sources":["../../../src/components/log-viewer/log-stream.ts"],"sourcesContent":["\"use client\";\n\nimport { useCallback, useMemo, useState } from \"react\";\n\n/**\n * Filtering and match-finding for a log stream.\n *\n * Separate from the view because it is pure and worth testing without a DOM,\n * and because the awkward parts are here rather than in the rendering: a bad\n * regex must not throw while someone is halfway through typing it, and a match\n * has to be located precisely enough to highlight rather than merely detected.\n */\n\nexport type LogLevel = \"trace\" | \"debug\" | \"info\" | \"warn\" | \"error\" | \"fatal\";\n\nexport const LOG_LEVELS: readonly LogLevel[] = [\n \"trace\",\n \"debug\",\n \"info\",\n \"warn\",\n \"error\",\n \"fatal\",\n];\n\nexport interface LogLine {\n id: string;\n message: string;\n level?: LogLevel;\n /** ISO timestamp, or anything the consumer wants shown in the gutter. */\n timestamp?: string;\n /** Structured fields, revealed when the row is expanded. */\n fields?: Record<string, unknown>;\n}\n\n/** A [start, end) slice of a message that matched the filter. */\nexport type MatchRange = readonly [number, number];\n\nexport interface FilterState {\n /** Substring, or a pattern when `regex` is on. */\n query: string;\n regex: boolean;\n /** Levels to show. Empty means all of them. */\n levels: Set<LogLevel>;\n}\n\n/**\n * Compiles the query.\n *\n * Returns null for an invalid pattern rather than throwing: the query is being\n * typed, so it spends most of its life syntactically incomplete, and a viewer\n * that crashes on \"(\" is unusable.\n */\nexport function compileQuery(query: string, regex: boolean): RegExp | null {\n if (query.length === 0) return null;\n try {\n return regex\n ? new RegExp(query, \"gi\")\n : new RegExp(query.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\"), \"gi\");\n } catch {\n return null;\n }\n}\n\n/** Every match in a line, so each can be highlighted rather than just the first. */\nexport function findMatches(message: string, pattern: RegExp | null): MatchRange[] {\n if (!pattern) return [];\n\n const ranges: MatchRange[] = [];\n // Fresh lastIndex: the pattern is reused across lines and a global regex\n // carries position between calls, which would skip matches in later lines.\n pattern.lastIndex = 0;\n\n let match = pattern.exec(message);\n while (match !== null) {\n // A pattern that can match empty — \"a*\" — would loop forever otherwise.\n if (match[0].length === 0) {\n pattern.lastIndex += 1;\n } else {\n ranges.push([match.index, match.index + match[0].length]);\n }\n match = pattern.exec(message);\n }\n\n return ranges;\n}\n\n/** Splits a message into alternating plain and matched segments. */\nexport function segment(\n message: string,\n ranges: MatchRange[],\n): { text: string; match: boolean }[] {\n if (ranges.length === 0) return [{ text: message, match: false }];\n\n const parts: { text: string; match: boolean }[] = [];\n let cursor = 0;\n\n for (const [start, end] of ranges) {\n if (start > cursor) parts.push({ text: message.slice(cursor, start), match: false });\n parts.push({ text: message.slice(start, end), match: true });\n cursor = end;\n }\n if (cursor < message.length) parts.push({ text: message.slice(cursor), match: false });\n\n return parts;\n}\n\nexport interface VisibleLine extends LogLine {\n matches: MatchRange[];\n}\n\nexport interface UseLogStreamOptions {\n lines: LogLine[];\n /** Levels present but unchecked are hidden. Empty shows everything. */\n initialLevels?: Set<LogLevel>;\n}\n\nexport function useLogStream({ lines, initialLevels }: UseLogStreamOptions) {\n const [query, setQuery] = useState(\"\");\n const [regex, setRegex] = useState(false);\n const [levels, setLevels] = useState<Set<LogLevel>>(initialLevels ?? new Set());\n\n const pattern = useMemo(() => compileQuery(query, regex), [query, regex]);\n\n // An invalid pattern is reported, not swallowed: with no feedback the reader\n // sees an empty log and concludes nothing matched.\n const invalidPattern = regex && query.length > 0 && pattern === null;\n\n const visible = useMemo<VisibleLine[]>(() => {\n const result: VisibleLine[] = [];\n\n for (const line of lines) {\n if (levels.size > 0 && line.level && !levels.has(line.level)) continue;\n\n const matches = findMatches(line.message, pattern);\n // A query with no match hides the line; an invalid pattern hides nothing,\n // because the reader has not finished saying what they want yet.\n if (pattern && matches.length === 0) continue;\n\n result.push({ ...line, matches });\n }\n\n return result;\n }, [lines, levels, pattern]);\n\n const counts = useMemo(() => {\n const byLevel = new Map<LogLevel, number>();\n for (const line of lines) {\n if (!line.level) continue;\n byLevel.set(line.level, (byLevel.get(line.level) ?? 0) + 1);\n }\n return byLevel;\n }, [lines]);\n\n const toggleLevel = useCallback((level: LogLevel) => {\n setLevels((current) => {\n const next = new Set(current);\n if (next.has(level)) next.delete(level);\n else next.add(level);\n return next;\n });\n }, []);\n\n return {\n query,\n setQuery,\n regex,\n setRegex,\n levels,\n toggleLevel,\n setLevels,\n visible,\n counts,\n invalidPattern,\n total: lines.length,\n };\n}\n"],"mappings":";;;AAeA,MAAa,aAAkC;CAC7C;CACA;CACA;CACA;CACA;CACA;AACF;;;;;;;;AA8BA,SAAgB,aAAa,OAAe,OAA+B;CACzE,IAAI,MAAM,WAAW,GAAG,OAAO;CAC/B,IAAI;EACF,OAAO,QACH,IAAI,OAAO,OAAO,IAAI,IACtB,IAAI,OAAO,MAAM,QAAQ,uBAAuB,MAAM,GAAG,IAAI;CACnE,QAAQ;EACN,OAAO;CACT;AACF;;AAGA,SAAgB,YAAY,SAAiB,SAAsC;CACjF,IAAI,CAAC,SAAS,OAAO,CAAC;CAEtB,MAAM,SAAuB,CAAC;CAG9B,QAAQ,YAAY;CAEpB,IAAI,QAAQ,QAAQ,KAAK,OAAO;CAChC,OAAO,UAAU,MAAM;EAErB,IAAI,MAAM,EAAE,CAAC,WAAW,GACtB,QAAQ,aAAa;OAErB,OAAO,KAAK,CAAC,MAAM,OAAO,MAAM,QAAQ,MAAM,EAAE,CAAC,MAAM,CAAC;EAE1D,QAAQ,QAAQ,KAAK,OAAO;CAC9B;CAEA,OAAO;AACT;;AAGA,SAAgB,QACd,SACA,QACoC;CACpC,IAAI,OAAO,WAAW,GAAG,OAAO,CAAC;EAAE,MAAM;EAAS,OAAO;CAAM,CAAC;CAEhE,MAAM,QAA4C,CAAC;CACnD,IAAI,SAAS;CAEb,KAAK,MAAM,CAAC,OAAO,QAAQ,QAAQ;EACjC,IAAI,QAAQ,QAAQ,MAAM,KAAK;GAAE,MAAM,QAAQ,MAAM,QAAQ,KAAK;GAAG,OAAO;EAAM,CAAC;EACnF,MAAM,KAAK;GAAE,MAAM,QAAQ,MAAM,OAAO,GAAG;GAAG,OAAO;EAAK,CAAC;EAC3D,SAAS;CACX;CACA,IAAI,SAAS,QAAQ,QAAQ,MAAM,KAAK;EAAE,MAAM,QAAQ,MAAM,MAAM;EAAG,OAAO;CAAM,CAAC;CAErF,OAAO;AACT;AAYA,SAAgB,aAAa,EAAE,OAAO,iBAAsC;CAC1E,MAAM,CAAC,OAAO,YAAY,SAAS,EAAE;CACrC,MAAM,CAAC,OAAO,YAAY,SAAS,KAAK;CACxC,MAAM,CAAC,QAAQ,aAAa,SAAwB,iCAAiB,IAAI,IAAI,CAAC;CAE9E,MAAM,UAAU,cAAc,aAAa,OAAO,KAAK,GAAG,CAAC,OAAO,KAAK,CAAC;CAIxE,MAAM,iBAAiB,SAAS,MAAM,SAAS,KAAK,YAAY;CAEhE,MAAM,UAAU,cAA6B;EAC3C,MAAM,SAAwB,CAAC;EAE/B,KAAK,MAAM,QAAQ,OAAO;GACxB,IAAI,OAAO,OAAO,KAAK,KAAK,SAAS,CAAC,OAAO,IAAI,KAAK,KAAK,GAAG;GAE9D,MAAM,UAAU,YAAY,KAAK,SAAS,OAAO;GAGjD,IAAI,WAAW,QAAQ,WAAW,GAAG;GAErC,OAAO,KAAK;IAAE,GAAG;IAAM;GAAQ,CAAC;EAClC;EAEA,OAAO;CACT,GAAG;EAAC;EAAO;EAAQ;CAAO,CAAC;CAE3B,MAAM,SAAS,cAAc;EAC3B,MAAM,0BAAU,IAAI,IAAsB;EAC1C,KAAK,MAAM,QAAQ,OAAO;GACxB,IAAI,CAAC,KAAK,OAAO;GACjB,QAAQ,IAAI,KAAK,QAAQ,QAAQ,IAAI,KAAK,KAAK,KAAK,KAAK,CAAC;EAC5D;EACA,OAAO;CACT,GAAG,CAAC,KAAK,CAAC;CAWV,OAAO;EACL;EACA;EACA;EACA;EACA;EACA,aAfkB,aAAa,UAAoB;GACnD,WAAW,YAAY;IACrB,MAAM,OAAO,IAAI,IAAI,OAAO;IAC5B,IAAI,KAAK,IAAI,KAAK,GAAG,KAAK,OAAO,KAAK;SACjC,KAAK,IAAI,KAAK;IACnB,OAAO;GACT,CAAC;EACH,GAAG,CAAC,CAQQ;EACV;EACA;EACA;EACA;EACA,OAAO,MAAM;CACf;AACF"}
@@ -0,0 +1,51 @@
1
+ import { LogLevel, VisibleLine } from "./log-stream.js";
2
+ import { ComponentPropsWithRef, ReactNode } from "react";
3
+ //#region src/components/log-viewer/log-viewer.d.ts
4
+ interface LogViewerProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
5
+ lines: VisibleLine[];
6
+ /** Names the region. */
7
+ label: string;
8
+ /** Row height in pixels. Rows are one line; expansion is measured. */
9
+ rowHeight?: number;
10
+ height?: number | string;
11
+ /**
12
+ * Read new lines aloud. Off by default — a console that announces every line
13
+ * makes a screen reader useless for anything else.
14
+ */
15
+ announce?: boolean;
16
+ /** The way out of the virtual window, for anyone who needs the whole log. */
17
+ onDownload?: () => void;
18
+ children?: ReactNode;
19
+ }
20
+ declare function LogViewer({ className, lines, label, rowHeight, height, announce, onDownload, children, ...props }: LogViewerProps): import("react").JSX.Element;
21
+ interface LogViewerRowProps {
22
+ line: VisibleLine;
23
+ expanded?: boolean;
24
+ onToggle?: () => void;
25
+ }
26
+ /**
27
+ * One line.
28
+ *
29
+ * Exported because the virtualized container cannot be measured outside a real
30
+ * browser — jsdom reports every box as zero — so the row's behaviour would
31
+ * otherwise be untestable. It is also genuinely useful on its own for anyone
32
+ * rendering a short log without virtualization.
33
+ */
34
+ declare function LogViewerRow({ line, expanded, onToggle }: LogViewerRowProps): import("react").JSX.Element;
35
+ interface LogViewerToolbarProps extends Omit<ComponentPropsWithRef<"div">, "onChange"> {
36
+ query: string;
37
+ onQueryChange: (query: string) => void;
38
+ regex: boolean;
39
+ onRegexChange: (regex: boolean) => void;
40
+ levels: Set<LogLevel>;
41
+ onToggleLevel: (level: LogLevel) => void;
42
+ counts: Map<LogLevel, number>;
43
+ invalidPattern?: boolean;
44
+ /** Shown as "N of M lines". */
45
+ showing: number;
46
+ total: number;
47
+ }
48
+ declare function LogViewerToolbar({ className, query, onQueryChange, regex, onRegexChange, levels, onToggleLevel, counts, invalidPattern, showing, total, ...props }: LogViewerToolbarProps): import("react").JSX.Element;
49
+ //#endregion
50
+ export { LogViewer, LogViewerProps, LogViewerRow, LogViewerRowProps, LogViewerToolbar, LogViewerToolbarProps };
51
+ //# sourceMappingURL=log-viewer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"log-viewer.d.ts","names":[],"sources":["../../../src/components/log-viewer/log-viewer.tsx"],"mappings":";;;UAmDiB,uBAAuB,KAAK;EAC3C,OAAO;;EAEP;;EAEA;EACA;;;;;EAKA;;EAEA;EACA,WAAW;;iBAGG,YACd,WACA,OACA,OACA,WACA,QACA,UACA,YACA,aACG,SACF,iCAAc,IAAA;UAmJA;EACf,MAAM;EACN;EACA;;;;;;;;;;iBAWc,eAAe,MAAM,UAAkB,YAAY,oCAAiB,IAAA;UA2EnE,8BAA8B,KAAK;EAClD;EACA,gBAAgB;EAChB;EACA,gBAAgB;EAChB,QAAQ,IAAI;EACZ,gBAAgB,OAAO;EACvB,QAAQ,IAAI;EACZ;;EAEA;EACA;;iBAGc,mBACd,WACA,OACA,eACA,OACA,eACA,QACA,eACA,QACA,gBACA,SACA,UACG,SACF,wCAAqB,IAAA"}