@dowel-ui/react 0.2.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.
- package/README.md +14 -7
- package/dist/components/ai-agent-plan/ai-agent-plan.d.ts +50 -0
- package/dist/components/ai-agent-plan/ai-agent-plan.d.ts.map +1 -0
- package/dist/components/ai-agent-plan/ai-agent-plan.js +156 -0
- package/dist/components/ai-agent-plan/ai-agent-plan.js.map +1 -0
- package/dist/components/ai-agent-plan/index.d.ts +2 -0
- package/dist/components/ai-agent-plan/index.js +2 -0
- package/dist/components/ai-agent-plan/meta.js +17 -0
- package/dist/components/ai-agent-plan/meta.js.map +1 -0
- package/dist/components/ai-agent-status/ai-agent-status.d.ts +1 -1
- package/dist/components/ai-approval-request/ai-approval-request.d.ts +67 -0
- package/dist/components/ai-approval-request/ai-approval-request.d.ts.map +1 -0
- package/dist/components/ai-approval-request/ai-approval-request.js +218 -0
- package/dist/components/ai-approval-request/ai-approval-request.js.map +1 -0
- package/dist/components/ai-approval-request/index.d.ts +2 -0
- package/dist/components/ai-approval-request/index.js +2 -0
- package/dist/components/ai-approval-request/meta.js +17 -0
- package/dist/components/ai-approval-request/meta.js.map +1 -0
- package/dist/components/ai-disclosure/ai-disclosure.d.ts +92 -0
- package/dist/components/ai-disclosure/ai-disclosure.d.ts.map +1 -0
- package/dist/components/ai-disclosure/ai-disclosure.js +122 -0
- package/dist/components/ai-disclosure/ai-disclosure.js.map +1 -0
- package/dist/components/ai-disclosure/index.d.ts +2 -0
- package/dist/components/ai-disclosure/index.js +2 -0
- package/dist/components/ai-disclosure/meta.js +17 -0
- package/dist/components/ai-disclosure/meta.js.map +1 -0
- package/dist/components/ai-response/ai-response.d.ts.map +1 -1
- package/dist/components/ai-response/ai-response.js +1 -0
- package/dist/components/ai-response/ai-response.js.map +1 -1
- package/dist/components/ai-structured-output/ai-structured-output.d.ts.map +1 -1
- package/dist/components/ai-structured-output/ai-structured-output.js +13 -2
- package/dist/components/ai-structured-output/ai-structured-output.js.map +1 -1
- package/dist/components/ai-tool/ai-tool.d.ts +1 -1
- package/dist/components/alert/alert.d.ts +1 -1
- package/dist/components/avatar/avatar.d.ts +1 -1
- package/dist/components/badge/badge.d.ts +1 -1
- package/dist/components/button/button.d.ts +1 -1
- package/dist/components/date-picker/date-picker.js +1 -1
- package/dist/components/diff-viewer/diff-model.d.ts +69 -0
- package/dist/components/diff-viewer/diff-model.d.ts.map +1 -0
- package/dist/components/diff-viewer/diff-model.js +180 -0
- package/dist/components/diff-viewer/diff-model.js.map +1 -0
- package/dist/components/diff-viewer/diff-viewer.d.ts +42 -0
- package/dist/components/diff-viewer/diff-viewer.d.ts.map +1 -0
- package/dist/components/diff-viewer/diff-viewer.js +212 -0
- package/dist/components/diff-viewer/diff-viewer.js.map +1 -0
- package/dist/components/diff-viewer/index.d.ts +3 -0
- package/dist/components/diff-viewer/index.js +3 -0
- package/dist/components/diff-viewer/meta.js +17 -0
- package/dist/components/diff-viewer/meta.js.map +1 -0
- package/dist/components/file-upload/file-upload.d.ts +46 -0
- package/dist/components/file-upload/file-upload.d.ts.map +1 -0
- package/dist/components/file-upload/file-upload.js +204 -0
- package/dist/components/file-upload/file-upload.js.map +1 -0
- package/dist/components/file-upload/index.d.ts +3 -0
- package/dist/components/file-upload/index.js +3 -0
- package/dist/components/file-upload/meta.js +17 -0
- package/dist/components/file-upload/meta.js.map +1 -0
- package/dist/components/file-upload/upload-queue.d.ts +87 -0
- package/dist/components/file-upload/upload-queue.d.ts.map +1 -0
- package/dist/components/file-upload/upload-queue.js +249 -0
- package/dist/components/file-upload/upload-queue.js.map +1 -0
- package/dist/components/log-viewer/index.d.ts +3 -0
- package/dist/components/log-viewer/index.js +3 -0
- package/dist/components/log-viewer/log-stream.d.ts +68 -0
- package/dist/components/log-viewer/log-stream.d.ts.map +1 -0
- package/dist/components/log-viewer/log-stream.js +120 -0
- package/dist/components/log-viewer/log-stream.js.map +1 -0
- package/dist/components/log-viewer/log-viewer.d.ts +51 -0
- package/dist/components/log-viewer/log-viewer.d.ts.map +1 -0
- package/dist/components/log-viewer/log-viewer.js +284 -0
- package/dist/components/log-viewer/log-viewer.js.map +1 -0
- package/dist/components/log-viewer/meta.js +17 -0
- package/dist/components/log-viewer/meta.js.map +1 -0
- package/dist/components/progress/progress.d.ts +1 -1
- package/dist/components/progress/progress.js +1 -0
- package/dist/components/progress/progress.js.map +1 -1
- package/dist/components/spinner/spinner.d.ts +1 -1
- package/dist/components/spinner/spinner.js +1 -0
- package/dist/components/spinner/spinner.js.map +1 -1
- package/dist/components/tags-input/index.d.ts +2 -0
- package/dist/components/tags-input/index.js +2 -0
- package/dist/components/tags-input/meta.js +17 -0
- package/dist/components/tags-input/meta.js.map +1 -0
- package/dist/components/tags-input/tags-input.d.ts +47 -0
- package/dist/components/tags-input/tags-input.d.ts.map +1 -0
- package/dist/components/tags-input/tags-input.js +182 -0
- package/dist/components/tags-input/tags-input.js.map +1 -0
- package/dist/components/time-range-picker/index.d.ts +3 -0
- package/dist/components/time-range-picker/index.js +3 -0
- package/dist/components/time-range-picker/meta.js +22 -0
- package/dist/components/time-range-picker/meta.js.map +1 -0
- package/dist/components/time-range-picker/time-expression.d.ts +88 -0
- package/dist/components/time-range-picker/time-expression.d.ts.map +1 -0
- package/dist/components/time-range-picker/time-expression.js +335 -0
- package/dist/components/time-range-picker/time-expression.js.map +1 -0
- package/dist/components/time-range-picker/time-range-picker.d.ts +65 -0
- package/dist/components/time-range-picker/time-range-picker.d.ts.map +1 -0
- package/dist/components/time-range-picker/time-range-picker.js +377 -0
- package/dist/components/time-range-picker/time-range-picker.js.map +1 -0
- package/dist/components/toast/toast.d.ts +1 -1
- package/dist/index.d.ts +21 -1
- package/dist/index.js +14 -2
- package/dist/registry/components.d.ts.map +1 -1
- package/dist/registry/components.js +66 -50
- package/dist/registry/components.js.map +1 -1
- package/package.json +6 -4
- package/src/components/ai-agent-plan/ai-agent-plan.tsx +0 -0
- package/src/components/ai-agent-plan/index.ts +10 -0
- package/src/components/ai-agent-plan/meta.ts +19 -0
- package/src/components/ai-approval-request/ai-approval-request.tsx +368 -0
- package/src/components/ai-approval-request/index.ts +7 -0
- package/src/components/ai-approval-request/meta.ts +21 -0
- package/src/components/ai-disclosure/ai-disclosure.tsx +244 -0
- package/src/components/ai-disclosure/index.ts +9 -0
- package/src/components/ai-disclosure/meta.ts +23 -0
- package/src/components/ai-response/ai-response.tsx +1 -0
- package/src/components/ai-structured-output/ai-structured-output.tsx +25 -1
- package/src/components/diff-viewer/diff-model.ts +256 -0
- package/src/components/diff-viewer/diff-viewer.tsx +339 -0
- package/src/components/diff-viewer/index.ts +17 -0
- package/src/components/diff-viewer/meta.ts +20 -0
- package/src/components/file-upload/file-upload.tsx +322 -0
- package/src/components/file-upload/index.ts +20 -0
- package/src/components/file-upload/meta.ts +21 -0
- package/src/components/file-upload/upload-queue.ts +337 -0
- package/src/components/log-viewer/index.ts +21 -0
- package/src/components/log-viewer/log-stream.ts +176 -0
- package/src/components/log-viewer/log-viewer.tsx +424 -0
- package/src/components/log-viewer/meta.ts +21 -0
- package/src/components/progress/progress.tsx +3 -0
- package/src/components/spinner/spinner.tsx +5 -0
- package/src/components/tags-input/index.ts +1 -0
- package/src/components/tags-input/meta.ts +23 -0
- package/src/components/tags-input/tags-input.tsx +300 -0
- package/src/components/time-range-picker/index.ts +25 -0
- package/src/components/time-range-picker/meta.ts +22 -0
- package/src/components/time-range-picker/time-expression.ts +491 -0
- package/src/components/time-range-picker/time-range-picker.tsx +522 -0
- package/src/index.ts +8 -0
- package/src/registry/components.ts +16 -0
|
@@ -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,322 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useId,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
type ComponentPropsWithRef,
|
|
8
|
+
type DragEvent,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from "react";
|
|
11
|
+
|
|
12
|
+
import { disabledStyles, focusRing } from "@/lib/styles";
|
|
13
|
+
import { cn } from "@/lib/utils";
|
|
14
|
+
|
|
15
|
+
import { formatBytes, type QueuedFile, type UploadStatus } from "./upload-queue";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The visible half of uploading. The queue is in `upload-queue.ts` and is the
|
|
19
|
+
* part worth owning.
|
|
20
|
+
*
|
|
21
|
+
* There is no dropzone pattern in the WAI-ARIA APG, and inventing one is the
|
|
22
|
+
* usual failure: a `div` with `role="button"`, a keydown handler, and a file
|
|
23
|
+
* picker that keyboard users can never reach. So the control here is a real
|
|
24
|
+
* `<input type="file">` with a real `<label>`. That is already operable by
|
|
25
|
+
* keyboard, already announces itself, already opens the picker on Enter and
|
|
26
|
+
* Space, and needs nothing added. Drag and drop is layered on top as a pointer
|
|
27
|
+
* convenience, and every drop can also be done from the input.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const STATUS_LABEL: Record<UploadStatus, string> = {
|
|
31
|
+
queued: "Waiting",
|
|
32
|
+
uploading: "Uploading",
|
|
33
|
+
done: "Uploaded",
|
|
34
|
+
failed: "Failed",
|
|
35
|
+
cancelled: "Cancelled",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export interface FileUploadProps extends Omit<ComponentPropsWithRef<"div">, "onDrop"> {
|
|
39
|
+
/** Names the control. */
|
|
40
|
+
label: string;
|
|
41
|
+
onFiles: (files: File[]) => void;
|
|
42
|
+
accept?: string;
|
|
43
|
+
multiple?: boolean;
|
|
44
|
+
disabled?: boolean;
|
|
45
|
+
/** Shown under the prompt: accepted types, size limit. */
|
|
46
|
+
hint?: ReactNode;
|
|
47
|
+
children?: ReactNode;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function FileUpload({
|
|
51
|
+
className,
|
|
52
|
+
label,
|
|
53
|
+
onFiles,
|
|
54
|
+
accept,
|
|
55
|
+
multiple = true,
|
|
56
|
+
disabled = false,
|
|
57
|
+
hint,
|
|
58
|
+
children,
|
|
59
|
+
...props
|
|
60
|
+
}: FileUploadProps) {
|
|
61
|
+
const inputId = useId();
|
|
62
|
+
const hintId = useId();
|
|
63
|
+
const [dragging, setDragging] = useState(false);
|
|
64
|
+
const depth = useRef(0);
|
|
65
|
+
|
|
66
|
+
function handleDrop(event: DragEvent<HTMLDivElement>) {
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
depth.current = 0;
|
|
69
|
+
setDragging(false);
|
|
70
|
+
if (disabled) return;
|
|
71
|
+
|
|
72
|
+
const dropped = [...event.dataTransfer.files];
|
|
73
|
+
if (dropped.length > 0) onFiles(multiple ? dropped : dropped.slice(0, 1));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div data-slot="file-upload" className={cn("flex flex-col gap-3", className)} {...props}>
|
|
78
|
+
{/* dragenter/dragleave fire for every child element, so a plain boolean
|
|
79
|
+
flickers as the pointer crosses the prompt text. Counting depth is
|
|
80
|
+
what makes the highlight steady. */}
|
|
81
|
+
<div
|
|
82
|
+
data-slot="file-upload-dropzone"
|
|
83
|
+
data-dragging={dragging || undefined}
|
|
84
|
+
onDragEnter={(event) => {
|
|
85
|
+
event.preventDefault();
|
|
86
|
+
depth.current += 1;
|
|
87
|
+
if (!disabled) setDragging(true);
|
|
88
|
+
}}
|
|
89
|
+
onDragLeave={() => {
|
|
90
|
+
depth.current -= 1;
|
|
91
|
+
if (depth.current <= 0) setDragging(false);
|
|
92
|
+
}}
|
|
93
|
+
onDragOver={(event) => {
|
|
94
|
+
event.preventDefault();
|
|
95
|
+
}}
|
|
96
|
+
onDrop={handleDrop}
|
|
97
|
+
className={cn(
|
|
98
|
+
"rounded-lg border border-dashed border-border-strong bg-muted/30 px-4 py-6 text-center",
|
|
99
|
+
"transition-colors duration-[var(--duration-fast)]",
|
|
100
|
+
dragging && "border-primary bg-primary/5",
|
|
101
|
+
disabled && "pointer-events-none opacity-55",
|
|
102
|
+
)}
|
|
103
|
+
>
|
|
104
|
+
{/* The label is the control. Clicking it opens the picker, Enter and
|
|
105
|
+
Space activate it, and assistive technology already describes it. */}
|
|
106
|
+
<label
|
|
107
|
+
htmlFor={inputId}
|
|
108
|
+
className={cn(
|
|
109
|
+
"inline-flex cursor-pointer flex-col items-center gap-1 rounded-md px-2 py-1 text-sm",
|
|
110
|
+
"focus-within:ring-2 focus-within:ring-ring/55",
|
|
111
|
+
)}
|
|
112
|
+
>
|
|
113
|
+
<span className="font-medium">{label}</span>
|
|
114
|
+
<span className="text-xs text-muted-foreground">
|
|
115
|
+
Drop {multiple ? "files" : "a file"} here, or choose from your device
|
|
116
|
+
</span>
|
|
117
|
+
<input
|
|
118
|
+
id={inputId}
|
|
119
|
+
type="file"
|
|
120
|
+
accept={accept}
|
|
121
|
+
multiple={multiple}
|
|
122
|
+
disabled={disabled}
|
|
123
|
+
aria-describedby={hint ? hintId : undefined}
|
|
124
|
+
onChange={(event) => {
|
|
125
|
+
const chosen = [...(event.target.files ?? [])];
|
|
126
|
+
if (chosen.length > 0) onFiles(chosen);
|
|
127
|
+
// Reset, so choosing the same file twice fires change twice.
|
|
128
|
+
event.target.value = "";
|
|
129
|
+
}}
|
|
130
|
+
className="sr-only"
|
|
131
|
+
/>
|
|
132
|
+
</label>
|
|
133
|
+
|
|
134
|
+
{hint ? (
|
|
135
|
+
<p id={hintId} className="mt-2 text-xs text-muted-foreground">
|
|
136
|
+
{hint}
|
|
137
|
+
</p>
|
|
138
|
+
) : null}
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
{children}
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface FileUploadListProps extends ComponentPropsWithRef<"ul"> {
|
|
147
|
+
files: QueuedFile[];
|
|
148
|
+
onCancel?: (id: string) => void;
|
|
149
|
+
onRetry?: (id: string) => void;
|
|
150
|
+
onRemove?: (id: string) => void;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function FileUploadList({
|
|
154
|
+
className,
|
|
155
|
+
files,
|
|
156
|
+
onCancel,
|
|
157
|
+
onRetry,
|
|
158
|
+
onRemove,
|
|
159
|
+
...props
|
|
160
|
+
}: FileUploadListProps) {
|
|
161
|
+
if (files.length === 0) return null;
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<ul
|
|
165
|
+
data-slot="file-upload-list"
|
|
166
|
+
className={cn("flex list-none flex-col gap-2", className)}
|
|
167
|
+
{...props}
|
|
168
|
+
>
|
|
169
|
+
{files.map((entry) => (
|
|
170
|
+
<FileUploadItem
|
|
171
|
+
key={entry.id}
|
|
172
|
+
entry={entry}
|
|
173
|
+
onCancel={onCancel}
|
|
174
|
+
onRetry={onRetry}
|
|
175
|
+
onRemove={onRemove}
|
|
176
|
+
/>
|
|
177
|
+
))}
|
|
178
|
+
</ul>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface FileUploadItemProps extends Omit<ComponentPropsWithRef<"li">, "children"> {
|
|
183
|
+
entry: QueuedFile;
|
|
184
|
+
onCancel?: (id: string) => void;
|
|
185
|
+
onRetry?: (id: string) => void;
|
|
186
|
+
onRemove?: (id: string) => void;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function FileUploadItem({
|
|
190
|
+
className,
|
|
191
|
+
entry,
|
|
192
|
+
onCancel,
|
|
193
|
+
onRetry,
|
|
194
|
+
onRemove,
|
|
195
|
+
...props
|
|
196
|
+
}: FileUploadItemProps) {
|
|
197
|
+
const { id, file, status, progress, error } = entry;
|
|
198
|
+
const percent = progress === null ? null : Math.round(progress * 100);
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<li
|
|
202
|
+
data-slot="file-upload-item"
|
|
203
|
+
data-status={status}
|
|
204
|
+
className={cn(
|
|
205
|
+
"flex items-center gap-3 rounded-lg border px-3 py-2 text-sm",
|
|
206
|
+
status === "failed"
|
|
207
|
+
? "border-destructive/40 bg-destructive/5"
|
|
208
|
+
: "border-border bg-card",
|
|
209
|
+
className,
|
|
210
|
+
)}
|
|
211
|
+
{...props}
|
|
212
|
+
>
|
|
213
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
214
|
+
<div className="flex items-baseline justify-between gap-2">
|
|
215
|
+
<span className="truncate font-medium">{file.name}</span>
|
|
216
|
+
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
|
|
217
|
+
{formatBytes(file.size)}
|
|
218
|
+
</span>
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
{/* Status in words, always. A bar at 60% with a red tint does not say
|
|
222
|
+
whether it is uploading, stalled or failed. */}
|
|
223
|
+
<p
|
|
224
|
+
className={cn(
|
|
225
|
+
"text-xs",
|
|
226
|
+
status === "failed" ? "text-destructive" : "text-muted-foreground",
|
|
227
|
+
)}
|
|
228
|
+
>
|
|
229
|
+
{STATUS_LABEL[status]}
|
|
230
|
+
{status === "uploading" && percent !== null ? ` · ${String(percent)}%` : null}
|
|
231
|
+
{error ? ` · ${error}` : null}
|
|
232
|
+
</p>
|
|
233
|
+
|
|
234
|
+
{status === "uploading" ? (
|
|
235
|
+
<div
|
|
236
|
+
role="progressbar"
|
|
237
|
+
aria-label={`Uploading ${file.name}`}
|
|
238
|
+
aria-valuenow={percent ?? undefined}
|
|
239
|
+
aria-valuemin={0}
|
|
240
|
+
aria-valuemax={100}
|
|
241
|
+
className="h-1 w-full overflow-hidden rounded-full bg-muted"
|
|
242
|
+
>
|
|
243
|
+
<div
|
|
244
|
+
data-slot="file-upload-progress"
|
|
245
|
+
className="h-full rounded-full bg-primary transition-[width] duration-[var(--duration-normal)] ease-[var(--ease-out-quint)]"
|
|
246
|
+
style={{ width: `${String(percent ?? 0)}%` }}
|
|
247
|
+
/>
|
|
248
|
+
</div>
|
|
249
|
+
) : null}
|
|
250
|
+
</div>
|
|
251
|
+
|
|
252
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
253
|
+
{status === "uploading" && onCancel ? (
|
|
254
|
+
<ItemButton onClick={() => onCancel(id)}>Cancel</ItemButton>
|
|
255
|
+
) : null}
|
|
256
|
+
{(status === "failed" || status === "cancelled") && onRetry ? (
|
|
257
|
+
<ItemButton onClick={() => onRetry(id)}>Retry</ItemButton>
|
|
258
|
+
) : null}
|
|
259
|
+
{onRemove ? (
|
|
260
|
+
<ItemButton onClick={() => onRemove(id)} aria-label={`Remove ${file.name}`}>
|
|
261
|
+
Remove
|
|
262
|
+
</ItemButton>
|
|
263
|
+
) : null}
|
|
264
|
+
</div>
|
|
265
|
+
</li>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function ItemButton({ className, ...props }: ComponentPropsWithRef<"button">) {
|
|
270
|
+
return (
|
|
271
|
+
<button
|
|
272
|
+
type="button"
|
|
273
|
+
className={cn(
|
|
274
|
+
"rounded-md border border-input bg-background px-2 py-0.5 text-xs font-medium",
|
|
275
|
+
"transition-colors hover:bg-accent hover:text-accent-foreground",
|
|
276
|
+
focusRing,
|
|
277
|
+
disabledStyles,
|
|
278
|
+
className,
|
|
279
|
+
)}
|
|
280
|
+
{...props}
|
|
281
|
+
/>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* One sentence covering the whole queue, announced politely.
|
|
287
|
+
*
|
|
288
|
+
* Per-file live regions would talk over each other the moment two uploads run
|
|
289
|
+
* at once; one summary of the set is readable where six competing ones are not.
|
|
290
|
+
*/
|
|
291
|
+
export function FileUploadStatus({
|
|
292
|
+
className,
|
|
293
|
+
stats,
|
|
294
|
+
...props
|
|
295
|
+
}: ComponentPropsWithRef<"p"> & {
|
|
296
|
+
stats: { total: number; active: number; done: number; failed: number };
|
|
297
|
+
}) {
|
|
298
|
+
const { total, active, done, failed } = stats;
|
|
299
|
+
|
|
300
|
+
// Progress through the set, not a count of what happens to be in flight.
|
|
301
|
+
// "3 of 5 uploading" is wrong the moment a concurrency limit holds two back,
|
|
302
|
+
// and it never tells the reader how much of the job is left.
|
|
303
|
+
const message =
|
|
304
|
+
total === 0
|
|
305
|
+
? ""
|
|
306
|
+
: active > 0
|
|
307
|
+
? `${String(done)} of ${String(total)} uploaded`
|
|
308
|
+
: failed > 0
|
|
309
|
+
? `${String(done)} uploaded, ${String(failed)} failed`
|
|
310
|
+
: `${String(done)} uploaded`;
|
|
311
|
+
|
|
312
|
+
return (
|
|
313
|
+
<p
|
|
314
|
+
data-slot="file-upload-status"
|
|
315
|
+
aria-live="polite"
|
|
316
|
+
className={cn("text-xs text-muted-foreground", className)}
|
|
317
|
+
{...props}
|
|
318
|
+
>
|
|
319
|
+
{message}
|
|
320
|
+
</p>
|
|
321
|
+
);
|
|
322
|
+
}
|