@tangle-network/ui 11.7.0 → 11.9.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/CHANGELOG.md +12 -0
- package/dist/chat.js +10 -9
- package/dist/{chunk-3M7BPSSW.js → chunk-3TI5DEHV.js} +1 -1
- package/dist/chunk-4CLN43XT.js +45 -0
- package/dist/{chunk-UYTRNR7Z.js → chunk-565EATZZ.js} +1 -1
- package/dist/{chunk-VDCZHOOO.js → chunk-7YOCS6IF.js} +6 -6
- package/dist/{chunk-P5F43A65.js → chunk-EOAX2ENE.js} +2 -1
- package/dist/{chunk-XT4JMYVB.js → chunk-KNYSKNZX.js} +5 -3
- package/dist/{chunk-EIUE5GV2.js → chunk-ND3ZFXQK.js} +1 -44
- package/dist/{chunk-AAUNOHVL.js → chunk-NWECTSOH.js} +1 -1
- package/dist/{chunk-CVX53NCC.js → chunk-TN4FUEWY.js} +7 -7
- package/dist/{chunk-4ENFLPWQ.js → chunk-VPWY4I5O.js} +1 -1
- package/dist/{chunk-ZRVH3WCA.js → chunk-XU3HB5QL.js} +21 -1
- package/dist/{chunk-6RIX7M27.js → chunk-XYAIX4SU.js} +2 -2
- package/dist/{chunk-PLWRAS5Z.js → chunk-YJ6RE5WV.js} +272 -108
- package/dist/{chunk-LFVF2HFB.js → chunk-YTO5V6QS.js} +1 -1
- package/dist/{chunk-QGTIBEPX.js → chunk-ZKCEAIIZ.js} +1 -1
- package/dist/{document-editor-pane-A3JURPW5.js → document-editor-pane-EX25CEYW.js} +2 -2
- package/dist/editor.js +2 -2
- package/dist/files.d.ts +41 -12
- package/dist/files.js +14 -6
- package/dist/hooks.js +9 -8
- package/dist/index.d.ts +2 -1
- package/dist/index.js +32 -24
- package/dist/markdown.d.ts +4 -1
- package/dist/markdown.js +1 -1
- package/dist/openui.js +2 -2
- package/dist/run.js +10 -9
- package/dist/sdk-hooks.js +9 -8
- package/dist/tool-previews.js +2 -2
- package/dist/utils.js +6 -4
- package/package.json +3 -3
- package/src/files/file-artifact-pane.stories.tsx +149 -0
- package/src/files/file-artifact-pane.test.tsx +192 -0
- package/src/files/file-artifact-pane.tsx +6 -1
- package/src/files/file-format.ts +42 -4
- package/src/files/file-preview-kind.test.ts +109 -0
- package/src/files/file-preview.tsx +315 -132
- package/src/files/index.ts +8 -1
- package/src/markdown/markdown.test.tsx +26 -2
- package/src/markdown/markdown.tsx +5 -2
|
@@ -1,53 +1,72 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* FilePreview — universal file renderer.
|
|
3
3
|
*
|
|
4
|
-
* Renders any file type
|
|
5
|
-
* - PDF: embedded viewer
|
|
6
|
-
* -
|
|
4
|
+
* Renders any file type:
|
|
5
|
+
* - PDF: embedded viewer at full pane height
|
|
6
|
+
* - Images: fit to the pane on a checker ground; click toggles natural size
|
|
7
|
+
* - Video / audio: native players
|
|
8
|
+
* - CSV: sticky-header table, capped at CSV_PREVIEW_ROW_LIMIT rows
|
|
9
|
+
* - XLSX / XLS: download card
|
|
7
10
|
* - Code (py/json/yaml/ts/js): syntax-highlighted, line-numbered viewer
|
|
8
11
|
* - Markdown: rendered prose
|
|
9
|
-
* - Images: inline display
|
|
10
12
|
* - Text: monospace preview
|
|
13
|
+
* - Anything else: text when content is a string, otherwise a download card
|
|
11
14
|
*/
|
|
12
15
|
|
|
16
|
+
import { useState } from "react";
|
|
13
17
|
import {
|
|
14
18
|
Download,
|
|
15
|
-
|
|
19
|
+
FileSpreadsheet,
|
|
16
20
|
FileText,
|
|
21
|
+
Music,
|
|
22
|
+
X,
|
|
23
|
+
type LucideIcon,
|
|
17
24
|
} from "lucide-react";
|
|
18
25
|
import { cn } from "../lib/utils";
|
|
26
|
+
import { Button } from "../primitives/button";
|
|
19
27
|
import { Markdown } from "../markdown/markdown";
|
|
20
28
|
import { CodeBlock, CopyButton } from "../markdown/code-block";
|
|
29
|
+
import { formatBytes } from "../utils/format";
|
|
21
30
|
import {
|
|
22
|
-
detectFileFormat,
|
|
23
31
|
fileExtension,
|
|
24
32
|
getCodeLanguage,
|
|
25
33
|
getFormatLabel,
|
|
26
|
-
|
|
34
|
+
resolveFilePreviewKind,
|
|
35
|
+
type FilePreviewKind,
|
|
27
36
|
} from "./file-format";
|
|
28
37
|
|
|
29
38
|
export interface FilePreviewProps {
|
|
30
39
|
filename: string;
|
|
31
40
|
content?: string;
|
|
41
|
+
/** Object URL or remote URL for binary content: images, PDF, video, audio. */
|
|
32
42
|
blobUrl?: string;
|
|
33
43
|
mimeType?: string;
|
|
44
|
+
/** Size in bytes; download cards show it when known. */
|
|
45
|
+
size?: number;
|
|
34
46
|
onClose?: () => void;
|
|
35
47
|
onDownload?: () => void;
|
|
36
48
|
hideHeader?: boolean;
|
|
37
49
|
className?: string;
|
|
38
50
|
}
|
|
39
51
|
|
|
52
|
+
/** Rows rendered by the CSV preview; the count line reports the total. */
|
|
53
|
+
export const CSV_PREVIEW_ROW_LIMIT = 500;
|
|
54
|
+
|
|
55
|
+
const NEEDS_DOWNLOAD_LINK = "This file needs a download link to preview.";
|
|
56
|
+
const NO_INLINE_PREVIEW = "This file type has no inline preview.";
|
|
57
|
+
const NO_INLINE_CONTENT = "This file has no inline content to preview yet.";
|
|
58
|
+
|
|
40
59
|
function CodePreview({
|
|
41
60
|
content,
|
|
42
61
|
filename,
|
|
43
|
-
|
|
62
|
+
kind,
|
|
44
63
|
}: {
|
|
45
64
|
content: string;
|
|
46
65
|
filename: string;
|
|
47
|
-
|
|
66
|
+
kind: FilePreviewKind;
|
|
48
67
|
}) {
|
|
49
68
|
const lineCount = content.split("\n").length;
|
|
50
|
-
const language = getCodeLanguage(filename,
|
|
69
|
+
const language = getCodeLanguage(filename, kind);
|
|
51
70
|
// Prefer the extension; for an extensionless file (e.g. one detected from its
|
|
52
71
|
// MIME type) fall back to the highlight language so the label stays meaningful.
|
|
53
72
|
const labelToken = fileExtension(filename) || language || "txt";
|
|
@@ -67,135 +86,219 @@ function CodePreview({
|
|
|
67
86
|
);
|
|
68
87
|
}
|
|
69
88
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Split CSV text into rows of cells (RFC 4180): a quoted cell may hold commas,
|
|
91
|
+
* line breaks, and doubled quotes. Unquoted cells are trimmed; blank lines and
|
|
92
|
+
* a trailing line break add no row.
|
|
93
|
+
*/
|
|
94
|
+
export function parseCsv(content: string): string[][] {
|
|
95
|
+
const rows: string[][] = [];
|
|
96
|
+
let row: string[] = [];
|
|
97
|
+
let cell = "";
|
|
98
|
+
let quoted = false;
|
|
73
99
|
let inQuotes = false;
|
|
74
100
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
101
|
+
const endCell = () => {
|
|
102
|
+
row.push(quoted ? cell : cell.trim());
|
|
103
|
+
cell = "";
|
|
104
|
+
quoted = false;
|
|
105
|
+
};
|
|
106
|
+
const endRow = () => {
|
|
107
|
+
endCell();
|
|
108
|
+
if (row.length > 1 || row[0] !== "") rows.push(row);
|
|
109
|
+
row = [];
|
|
110
|
+
};
|
|
78
111
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
current += "\"";
|
|
82
|
-
index += 1;
|
|
83
|
-
continue;
|
|
84
|
-
}
|
|
112
|
+
for (let index = 0; index < content.length; index += 1) {
|
|
113
|
+
const char = content[index];
|
|
85
114
|
|
|
86
|
-
|
|
115
|
+
if (inQuotes) {
|
|
116
|
+
if (char === '"') {
|
|
117
|
+
if (content[index + 1] === '"') {
|
|
118
|
+
cell += '"';
|
|
119
|
+
index += 1;
|
|
120
|
+
} else {
|
|
121
|
+
inQuotes = false;
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
cell += char;
|
|
125
|
+
}
|
|
87
126
|
continue;
|
|
88
127
|
}
|
|
89
128
|
|
|
90
|
-
if (char === "
|
|
91
|
-
|
|
92
|
-
|
|
129
|
+
if (char === '"') {
|
|
130
|
+
inQuotes = true;
|
|
131
|
+
quoted = true;
|
|
93
132
|
continue;
|
|
94
133
|
}
|
|
95
|
-
|
|
96
|
-
|
|
134
|
+
if (char === ",") {
|
|
135
|
+
endCell();
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (char === "\r" || char === "\n") {
|
|
139
|
+
if (char === "\r" && content[index + 1] === "\n") index += 1;
|
|
140
|
+
endRow();
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
cell += char;
|
|
97
144
|
}
|
|
98
145
|
|
|
99
|
-
|
|
100
|
-
return
|
|
146
|
+
if (cell.length > 0 || quoted || row.length > 0) endRow();
|
|
147
|
+
return rows;
|
|
101
148
|
}
|
|
102
149
|
|
|
103
150
|
function CsvPreview({ content }: { content: string }) {
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
.split(/\r?\n/)
|
|
107
|
-
.filter(Boolean);
|
|
151
|
+
const [headers = [], ...body] = parseCsv(content);
|
|
152
|
+
if (headers.length === 0) return null;
|
|
108
153
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
const rows = lines.slice(1).map((line) =>
|
|
113
|
-
parseCsvRow(line).map((cell) => cell.replace(/^"|"$/g, "")),
|
|
114
|
-
);
|
|
154
|
+
const visible = body.slice(0, CSV_PREVIEW_ROW_LIMIT);
|
|
155
|
+
const columnCount = Math.max(headers.length, ...visible.map((cells) => cells.length));
|
|
156
|
+
const capped = body.length > visible.length;
|
|
115
157
|
|
|
116
158
|
return (
|
|
117
|
-
<div className="
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
</th>
|
|
128
|
-
))}
|
|
129
|
-
</tr>
|
|
130
|
-
</thead>
|
|
131
|
-
<tbody>
|
|
132
|
-
{rows.map((row, i) => (
|
|
133
|
-
<tr key={i} className="border-b border-border hover:bg-accent">
|
|
134
|
-
{row.map((cell, j) => (
|
|
135
|
-
<td
|
|
136
|
-
key={j}
|
|
137
|
-
className="px-3 py-1.5 text-foreground font-mono text-xs whitespace-nowrap"
|
|
159
|
+
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
|
160
|
+
{/* Content height when short; shrinks and scrolls (sticky header) when tall. */}
|
|
161
|
+
<div className="min-h-0 overflow-auto rounded-[var(--radius-md)] border border-border">
|
|
162
|
+
<table className="w-full text-sm">
|
|
163
|
+
<thead>
|
|
164
|
+
<tr>
|
|
165
|
+
{Array.from({ length: columnCount }, (_, column) => (
|
|
166
|
+
<th
|
|
167
|
+
key={column}
|
|
168
|
+
className="sticky top-0 whitespace-nowrap border-b border-border bg-card px-3 py-2 text-left text-xs font-semibold text-foreground"
|
|
138
169
|
>
|
|
139
|
-
{
|
|
140
|
-
</
|
|
170
|
+
{headers[column] ?? ""}
|
|
171
|
+
</th>
|
|
141
172
|
))}
|
|
142
173
|
</tr>
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
174
|
+
</thead>
|
|
175
|
+
<tbody>
|
|
176
|
+
{visible.map((cells, rowIndex) => (
|
|
177
|
+
<tr key={rowIndex} className="border-b border-border hover:bg-accent">
|
|
178
|
+
{Array.from({ length: columnCount }, (_, column) => (
|
|
179
|
+
<td
|
|
180
|
+
key={column}
|
|
181
|
+
className="whitespace-nowrap px-3 py-1.5 font-mono text-xs text-foreground"
|
|
182
|
+
>
|
|
183
|
+
{cells[column] ?? ""}
|
|
184
|
+
</td>
|
|
185
|
+
))}
|
|
186
|
+
</tr>
|
|
187
|
+
))}
|
|
188
|
+
</tbody>
|
|
189
|
+
</table>
|
|
190
|
+
</div>
|
|
191
|
+
<p className="shrink-0 text-xs text-muted-foreground">
|
|
192
|
+
{capped
|
|
193
|
+
? `Showing ${visible.length} of ${body.length} rows`
|
|
194
|
+
: `${body.length} ${body.length === 1 ? "row" : "rows"}`}
|
|
195
|
+
{` · ${columnCount} ${columnCount === 1 ? "column" : "columns"}`}
|
|
196
|
+
</p>
|
|
146
197
|
</div>
|
|
147
198
|
);
|
|
148
199
|
}
|
|
149
200
|
|
|
201
|
+
// Checker ground behind images so transparency and edges stay visible in
|
|
202
|
+
// every theme; both colors come from the active theme's tokens.
|
|
203
|
+
const CHECKER_STYLE = {
|
|
204
|
+
backgroundColor: "var(--color-background)",
|
|
205
|
+
backgroundImage: "repeating-conic-gradient(var(--color-muted) 0% 25%, transparent 0% 50%)",
|
|
206
|
+
backgroundSize: "16px 16px",
|
|
207
|
+
} as const;
|
|
208
|
+
|
|
150
209
|
function ImagePreview({ src, filename }: { src: string; filename: string }) {
|
|
210
|
+
const [naturalSize, setNaturalSize] = useState(false);
|
|
151
211
|
return (
|
|
152
|
-
<div
|
|
153
|
-
|
|
212
|
+
<div
|
|
213
|
+
className="flex min-h-[12rem] flex-1 overflow-auto rounded-[var(--radius-md)] border border-border"
|
|
214
|
+
style={CHECKER_STYLE}
|
|
215
|
+
>
|
|
216
|
+
<button
|
|
217
|
+
type="button"
|
|
218
|
+
onClick={() => setNaturalSize((value) => !value)}
|
|
219
|
+
aria-pressed={naturalSize}
|
|
220
|
+
aria-label={naturalSize ? "Fit image to pane" : "Show image at natural size"}
|
|
221
|
+
className={cn(
|
|
222
|
+
"flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/60",
|
|
223
|
+
naturalSize ? "m-auto cursor-zoom-out" : "h-full w-full cursor-zoom-in p-4",
|
|
224
|
+
)}
|
|
225
|
+
>
|
|
226
|
+
<img
|
|
227
|
+
src={src}
|
|
228
|
+
alt={filename}
|
|
229
|
+
draggable={false}
|
|
230
|
+
className={naturalSize ? "max-w-none" : "max-h-full max-w-full object-contain"}
|
|
231
|
+
/>
|
|
232
|
+
</button>
|
|
154
233
|
</div>
|
|
155
234
|
);
|
|
156
235
|
}
|
|
157
236
|
|
|
158
|
-
function PdfPreview({
|
|
159
|
-
|
|
160
|
-
|
|
237
|
+
function PdfPreview({
|
|
238
|
+
blobUrl,
|
|
239
|
+
filename,
|
|
240
|
+
size,
|
|
241
|
+
onDownload,
|
|
242
|
+
}: {
|
|
243
|
+
blobUrl: string;
|
|
244
|
+
filename: string;
|
|
245
|
+
size?: number;
|
|
246
|
+
onDownload?: () => void;
|
|
247
|
+
}) {
|
|
248
|
+
// <object> lets a browser without an inline PDF viewer render the fallback
|
|
249
|
+
// card instead of a blank frame. The viewer draws its own chrome, so the
|
|
250
|
+
// object has no border; the card brings its own and fills the same height.
|
|
161
251
|
return (
|
|
162
|
-
<
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
252
|
+
<object
|
|
253
|
+
data={blobUrl}
|
|
254
|
+
type="application/pdf"
|
|
255
|
+
title={filename}
|
|
256
|
+
className="min-h-[24rem] w-full flex-1 rounded-[var(--radius-md)]"
|
|
257
|
+
>
|
|
258
|
+
<DownloadCard
|
|
259
|
+
filename={filename}
|
|
260
|
+
size={size}
|
|
261
|
+
sentence="This browser does not show PDFs inline."
|
|
262
|
+
onDownload={onDownload}
|
|
263
|
+
className="h-full"
|
|
167
264
|
/>
|
|
168
|
-
</
|
|
265
|
+
</object>
|
|
169
266
|
);
|
|
170
267
|
}
|
|
171
268
|
|
|
172
|
-
function
|
|
269
|
+
function VideoPreview({ src, filename }: { src: string; filename: string }) {
|
|
173
270
|
return (
|
|
174
|
-
<
|
|
175
|
-
{
|
|
176
|
-
|
|
271
|
+
<div className="flex min-h-[12rem] flex-1 items-center justify-center overflow-hidden rounded-[var(--radius-md)] border border-border bg-background">
|
|
272
|
+
{/* Caption tracks are unknown for an arbitrary artifact, so there is no <track>. */}
|
|
273
|
+
<video
|
|
274
|
+
controls
|
|
275
|
+
preload="metadata"
|
|
276
|
+
src={src}
|
|
277
|
+
aria-label={filename}
|
|
278
|
+
className="max-h-full max-w-full"
|
|
279
|
+
/>
|
|
280
|
+
</div>
|
|
177
281
|
);
|
|
178
282
|
}
|
|
179
283
|
|
|
180
|
-
function
|
|
181
|
-
filename,
|
|
182
|
-
title,
|
|
183
|
-
description,
|
|
184
|
-
}: {
|
|
185
|
-
filename: string;
|
|
186
|
-
title: string;
|
|
187
|
-
description: string;
|
|
188
|
-
}) {
|
|
284
|
+
function AudioPreview({ src, filename }: { src: string; filename: string }) {
|
|
189
285
|
return (
|
|
190
|
-
<div className="flex flex-col items-center
|
|
191
|
-
<
|
|
192
|
-
<p className="text-sm text-foreground">{
|
|
193
|
-
<
|
|
194
|
-
<p className="mt-4 text-[11px] uppercase tracking-[0.12em]">{filename}</p>
|
|
286
|
+
<div className="flex flex-col items-center gap-4 rounded-[var(--radius-md)] border border-border bg-background px-6 py-10">
|
|
287
|
+
<Music className="h-10 w-10 text-muted-foreground opacity-60" />
|
|
288
|
+
<p className="max-w-full truncate text-sm font-medium text-foreground">{filename}</p>
|
|
289
|
+
<audio controls preload="metadata" src={src} aria-label={filename} className="w-full max-w-md" />
|
|
195
290
|
</div>
|
|
196
291
|
);
|
|
197
292
|
}
|
|
198
293
|
|
|
294
|
+
function TextPreview({ content }: { content: string }) {
|
|
295
|
+
return (
|
|
296
|
+
<pre className="bg-card rounded-[var(--radius-md)] border border-border p-4 overflow-auto max-h-[70vh] text-sm text-foreground font-mono leading-[1.55]">
|
|
297
|
+
{content}
|
|
298
|
+
</pre>
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
199
302
|
function MarkdownPreview({ content }: { content: string }) {
|
|
200
303
|
return (
|
|
201
304
|
<div className="rounded-[var(--radius-md)] border border-border bg-background p-5">
|
|
@@ -204,33 +307,127 @@ function MarkdownPreview({ content }: { content: string }) {
|
|
|
204
307
|
);
|
|
205
308
|
}
|
|
206
309
|
|
|
207
|
-
|
|
310
|
+
/**
|
|
311
|
+
* One muted sentence plus the filename, its size when known, and a Download
|
|
312
|
+
* button when the host wires `onDownload`. Serves spreadsheets, binaries, and
|
|
313
|
+
* every kind whose source (blob or text) is missing.
|
|
314
|
+
*/
|
|
315
|
+
function DownloadCard({
|
|
316
|
+
filename,
|
|
317
|
+
size,
|
|
318
|
+
sentence,
|
|
319
|
+
icon: Icon = FileText,
|
|
320
|
+
onDownload,
|
|
321
|
+
className,
|
|
322
|
+
}: {
|
|
323
|
+
filename: string;
|
|
324
|
+
size?: number;
|
|
325
|
+
sentence: string;
|
|
326
|
+
icon?: LucideIcon;
|
|
327
|
+
onDownload?: () => void;
|
|
328
|
+
className?: string;
|
|
329
|
+
}) {
|
|
208
330
|
return (
|
|
209
|
-
<div
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
331
|
+
<div
|
|
332
|
+
className={cn(
|
|
333
|
+
"flex flex-col items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border bg-background px-6 py-16 text-center",
|
|
334
|
+
className,
|
|
335
|
+
)}
|
|
336
|
+
>
|
|
337
|
+
<Icon className="mb-3 h-12 w-12 text-muted-foreground opacity-40" />
|
|
338
|
+
<p className="max-w-full truncate text-sm font-medium text-foreground">{filename}</p>
|
|
339
|
+
{size !== undefined && (
|
|
340
|
+
<p className="mt-1 text-xs text-muted-foreground">{formatBytes(size)}</p>
|
|
341
|
+
)}
|
|
342
|
+
<p className="mt-3 max-w-md text-sm text-muted-foreground">{sentence}</p>
|
|
343
|
+
{onDownload && (
|
|
344
|
+
<Button type="button" variant="outline" size="sm" className="mt-5" onClick={onDownload}>
|
|
345
|
+
<Download />
|
|
346
|
+
Download
|
|
347
|
+
</Button>
|
|
348
|
+
)}
|
|
213
349
|
</div>
|
|
214
350
|
);
|
|
215
351
|
}
|
|
216
352
|
|
|
353
|
+
function PreviewBody({
|
|
354
|
+
kind,
|
|
355
|
+
filename,
|
|
356
|
+
content,
|
|
357
|
+
blobUrl,
|
|
358
|
+
size,
|
|
359
|
+
onDownload,
|
|
360
|
+
}: {
|
|
361
|
+
kind: FilePreviewKind;
|
|
362
|
+
filename: string;
|
|
363
|
+
content?: string;
|
|
364
|
+
blobUrl?: string;
|
|
365
|
+
size?: number;
|
|
366
|
+
onDownload?: () => void;
|
|
367
|
+
}) {
|
|
368
|
+
const hasText = typeof content === "string";
|
|
369
|
+
const card = (sentence: string, icon?: LucideIcon) => (
|
|
370
|
+
<DownloadCard
|
|
371
|
+
filename={filename}
|
|
372
|
+
size={size}
|
|
373
|
+
sentence={sentence}
|
|
374
|
+
icon={icon}
|
|
375
|
+
onDownload={onDownload}
|
|
376
|
+
/>
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
switch (kind) {
|
|
380
|
+
case "pdf":
|
|
381
|
+
if (blobUrl) {
|
|
382
|
+
return <PdfPreview blobUrl={blobUrl} filename={filename} size={size} onDownload={onDownload} />;
|
|
383
|
+
}
|
|
384
|
+
return card(NEEDS_DOWNLOAD_LINK);
|
|
385
|
+
case "image":
|
|
386
|
+
if (blobUrl) return <ImagePreview src={blobUrl} filename={filename} />;
|
|
387
|
+
// An SVG arrives as text when the host read it as source.
|
|
388
|
+
if (hasText) return <CodePreview content={content} filename={filename} kind="code" />;
|
|
389
|
+
return card(NEEDS_DOWNLOAD_LINK);
|
|
390
|
+
case "video":
|
|
391
|
+
if (blobUrl) return <VideoPreview src={blobUrl} filename={filename} />;
|
|
392
|
+
return card(NEEDS_DOWNLOAD_LINK);
|
|
393
|
+
case "audio":
|
|
394
|
+
if (blobUrl) return <AudioPreview src={blobUrl} filename={filename} />;
|
|
395
|
+
return card(NEEDS_DOWNLOAD_LINK, Music);
|
|
396
|
+
case "csv":
|
|
397
|
+
if (hasText) return <CsvPreview content={content} />;
|
|
398
|
+
return card(NO_INLINE_CONTENT, FileSpreadsheet);
|
|
399
|
+
case "code":
|
|
400
|
+
case "json":
|
|
401
|
+
case "yaml":
|
|
402
|
+
if (hasText) return <CodePreview content={content} filename={filename} kind={kind} />;
|
|
403
|
+
return card(NO_INLINE_CONTENT);
|
|
404
|
+
case "markdown":
|
|
405
|
+
if (hasText) return <MarkdownPreview content={content} />;
|
|
406
|
+
return card(NO_INLINE_CONTENT);
|
|
407
|
+
case "text":
|
|
408
|
+
if (hasText) return <TextPreview content={content} />;
|
|
409
|
+
return card(NO_INLINE_CONTENT);
|
|
410
|
+
case "spreadsheet":
|
|
411
|
+
return card("Download to open this workbook in a spreadsheet app.", FileSpreadsheet);
|
|
412
|
+
case "binary":
|
|
413
|
+
if (hasText) return <TextPreview content={content} />;
|
|
414
|
+
return card(blobUrl ? NO_INLINE_PREVIEW : NEEDS_DOWNLOAD_LINK);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
217
418
|
export function FilePreview({
|
|
218
419
|
filename,
|
|
219
420
|
content,
|
|
220
421
|
blobUrl,
|
|
221
422
|
mimeType,
|
|
423
|
+
size,
|
|
222
424
|
onClose,
|
|
223
425
|
onDownload,
|
|
224
426
|
hideHeader = false,
|
|
225
427
|
className,
|
|
226
428
|
}: FilePreviewProps) {
|
|
227
|
-
const
|
|
228
|
-
const previewLabel = getFormatLabel(
|
|
229
|
-
const hasRenderableSource =
|
|
230
|
-
Boolean(content) ||
|
|
231
|
-
Boolean(blobUrl) ||
|
|
232
|
-
format === "unknown" ||
|
|
233
|
-
format === "spreadsheet";
|
|
429
|
+
const kind = resolveFilePreviewKind(filename, mimeType);
|
|
430
|
+
const previewLabel = getFormatLabel(kind);
|
|
234
431
|
|
|
235
432
|
return (
|
|
236
433
|
<div className={cn("flex flex-col h-full", className)}>
|
|
@@ -265,31 +462,17 @@ export function FilePreview({
|
|
|
265
462
|
</div>
|
|
266
463
|
)}
|
|
267
464
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
filename={filename}
|
|
280
|
-
title="Spreadsheet preview is not available in this surface"
|
|
281
|
-
description="Download the workbook or convert it to CSV when you need an inline preview."
|
|
282
|
-
/>
|
|
283
|
-
)}
|
|
284
|
-
{format === "unknown" && typeof content !== "string" && <EmptyPreview filename={filename} />}
|
|
285
|
-
{format === "unknown" && typeof content === "string" && <TextPreview content={content} />}
|
|
286
|
-
{!hasRenderableSource && typeof content !== "string" && (
|
|
287
|
-
<UnsupportedPreview
|
|
288
|
-
filename={filename}
|
|
289
|
-
title="Preview data is not available yet"
|
|
290
|
-
description="This artifact can be shown once the app provides inline content or a downloadable blob."
|
|
291
|
-
/>
|
|
292
|
-
)}
|
|
465
|
+
{/* A flex column so full-height previews (PDF, image, video, CSV) fill
|
|
466
|
+
the pane with `flex-1`, while prose and code keep their own height. */}
|
|
467
|
+
<div className="flex min-h-0 flex-1 flex-col overflow-auto p-3">
|
|
468
|
+
<PreviewBody
|
|
469
|
+
kind={kind}
|
|
470
|
+
filename={filename}
|
|
471
|
+
content={content}
|
|
472
|
+
blobUrl={blobUrl}
|
|
473
|
+
size={size}
|
|
474
|
+
onDownload={onDownload}
|
|
475
|
+
/>
|
|
293
476
|
</div>
|
|
294
477
|
</div>
|
|
295
478
|
);
|
package/src/files/index.ts
CHANGED
|
@@ -12,7 +12,12 @@ export {
|
|
|
12
12
|
type RichFileTreeGitStatus,
|
|
13
13
|
type RichFileTreeThemeVars,
|
|
14
14
|
} from "./rich-file-tree";
|
|
15
|
-
export {
|
|
15
|
+
export {
|
|
16
|
+
CSV_PREVIEW_ROW_LIMIT,
|
|
17
|
+
FilePreview,
|
|
18
|
+
parseCsv,
|
|
19
|
+
type FilePreviewProps,
|
|
20
|
+
} from "./file-preview";
|
|
16
21
|
export { FileTabs, type FileTabsProps, type FileTabData } from "./file-tabs";
|
|
17
22
|
export { FileArtifactPane, type FileArtifactPaneProps } from "./file-artifact-pane";
|
|
18
23
|
export {
|
|
@@ -21,5 +26,7 @@ export {
|
|
|
21
26
|
getCodeLanguage,
|
|
22
27
|
getFormatLabel,
|
|
23
28
|
getSyntaxLanguage,
|
|
29
|
+
resolveFilePreviewKind,
|
|
24
30
|
type FileFormat,
|
|
31
|
+
type FilePreviewKind,
|
|
25
32
|
} from "./file-format";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { render } from "@testing-library/react";
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
3
|
import { Markdown } from "./markdown";
|
|
4
4
|
|
|
5
5
|
describe("Markdown", () => {
|
|
@@ -20,4 +20,28 @@ describe("Markdown", () => {
|
|
|
20
20
|
expect(inlineCode).toHaveClass("bg-card");
|
|
21
21
|
expect(inlineCode).toHaveClass("text-[var(--code-keyword)]");
|
|
22
22
|
});
|
|
23
|
+
|
|
24
|
+
it("passes parsed URLs through the typed transform hook", () => {
|
|
25
|
+
const urlTransform = vi.fn((url: string, key: string) => (
|
|
26
|
+
key === "href" && url === "system/brief.md"
|
|
27
|
+
? "/app/ws-1/vault?file=system%2Fbrief.md"
|
|
28
|
+
: url
|
|
29
|
+
));
|
|
30
|
+
|
|
31
|
+
render(
|
|
32
|
+
<Markdown urlTransform={urlTransform}>
|
|
33
|
+
{'[Brief](system/brief.md)'}
|
|
34
|
+
</Markdown>,
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
expect(screen.getByRole("link", { name: "Brief" })).toHaveAttribute(
|
|
38
|
+
"href",
|
|
39
|
+
"/app/ws-1/vault?file=system%2Fbrief.md",
|
|
40
|
+
);
|
|
41
|
+
expect(urlTransform).toHaveBeenCalledWith(
|
|
42
|
+
"system/brief.md",
|
|
43
|
+
"href",
|
|
44
|
+
expect.objectContaining({ tagName: "a" }),
|
|
45
|
+
);
|
|
46
|
+
});
|
|
23
47
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { memo } from "react";
|
|
2
|
-
import ReactMarkdown from "react-markdown";
|
|
2
|
+
import ReactMarkdown, { type UrlTransform } from "react-markdown";
|
|
3
3
|
import remarkGfm from "remark-gfm";
|
|
4
4
|
import rehypeSanitize from "rehype-sanitize";
|
|
5
5
|
import { CodeBlock, CopyButton } from "./code-block";
|
|
@@ -8,13 +8,15 @@ import { cn } from "../lib/utils";
|
|
|
8
8
|
export interface MarkdownProps {
|
|
9
9
|
children: string;
|
|
10
10
|
className?: string;
|
|
11
|
+
/** Transform parsed link and image URLs before sanitisation. */
|
|
12
|
+
urlTransform?: UrlTransform;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* Renders Markdown content with GFM support, XSS sanitisation, and
|
|
15
17
|
* custom code block rendering via our CodeBlock component.
|
|
16
18
|
*/
|
|
17
|
-
export const Markdown = memo(({ children, className }: MarkdownProps) => {
|
|
19
|
+
export const Markdown = memo(({ children, className, urlTransform }: MarkdownProps) => {
|
|
18
20
|
return (
|
|
19
21
|
<div
|
|
20
22
|
className={cn("tangle-prose max-w-none text-sm", className)}
|
|
@@ -22,6 +24,7 @@ export const Markdown = memo(({ children, className }: MarkdownProps) => {
|
|
|
22
24
|
<ReactMarkdown
|
|
23
25
|
remarkPlugins={[remarkGfm]}
|
|
24
26
|
rehypePlugins={[rehypeSanitize]}
|
|
27
|
+
urlTransform={urlTransform}
|
|
25
28
|
components={{
|
|
26
29
|
pre({ children: preChildren }) {
|
|
27
30
|
return <>{preChildren}</>;
|