@tangle-network/ui 1.0.1 → 4.1.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 (48) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/dist/chat.js +8 -7
  3. package/dist/{chunk-TMFOPHHN.js → chunk-52Y3FMFI.js} +2 -2
  4. package/dist/{chunk-7UO2ZMRQ.js → chunk-5VPTNXX7.js} +2 -2
  5. package/dist/{chunk-XIHMJ7ZQ.js → chunk-AAUNOHVL.js} +5 -30
  6. package/dist/{chunk-YJ2G3XO5.js → chunk-CMX2I43A.js} +1 -1
  7. package/dist/{chunk-2VH6PUXD.js → chunk-DGW77LD7.js} +1 -1
  8. package/dist/{chunk-CD53GZOM.js → chunk-FJBTCTZM.js} +1 -1
  9. package/dist/{chunk-YNN4O57I.js → chunk-JBPWIYTQ.js} +4 -4
  10. package/dist/{chunk-2NFQRQOD.js → chunk-KT5RNO7N.js} +4 -4
  11. package/dist/{chunk-HJKCSXCH.js → chunk-LELGOLFV.js} +44 -78
  12. package/dist/{chunk-EEE55AVS.js → chunk-SZ44QDA6.js} +1 -1
  13. package/dist/{chunk-66BNMOVT.js → chunk-WUQDUBJG.js} +5 -4
  14. package/dist/chunk-ZRVH3WCA.js +107 -0
  15. package/dist/{code-block-DjXf8eOG.d.ts → code-block-0kSpWMnf.d.ts} +7 -1
  16. package/dist/{document-editor-pane-A5LT5H4N.js → document-editor-pane-WCTA3ZOE.js} +3 -3
  17. package/dist/editor.js +3 -4
  18. package/dist/files.d.ts +39 -2
  19. package/dist/files.js +16 -4
  20. package/dist/hooks.js +5 -5
  21. package/dist/index.d.ts +46 -7
  22. package/dist/index.js +120 -37
  23. package/dist/markdown.d.ts +1 -1
  24. package/dist/markdown.js +2 -2
  25. package/dist/nav.d.ts +25 -0
  26. package/dist/nav.js +16 -0
  27. package/dist/openui.js +3 -3
  28. package/dist/primitives.d.ts +1 -1
  29. package/dist/primitives.js +1 -1
  30. package/dist/run.js +8 -7
  31. package/dist/sdk-hooks.js +5 -5
  32. package/dist/tool-previews.js +3 -2
  33. package/package.json +13 -3
  34. package/src/files/file-artifact-pane.tsx +3 -3
  35. package/src/files/file-format.test.ts +176 -0
  36. package/src/files/file-format.ts +167 -0
  37. package/src/files/file-preview.stories.tsx +87 -0
  38. package/src/files/file-preview.test.tsx +52 -0
  39. package/src/files/file-preview.tsx +48 -94
  40. package/src/files/index.ts +8 -0
  41. package/src/index.ts +1 -2
  42. package/src/markdown/code-block.test.tsx +62 -0
  43. package/src/markdown/code-block.tsx +11 -4
  44. package/src/nav/index.tsx +34 -0
  45. package/src/redaction/index.ts +7 -0
  46. package/src/redaction/redacted-document.tsx +150 -0
  47. package/src/tool-previews/write-file-preview.tsx +2 -30
  48. package/dist/chunk-Q7EIIWTC.js +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/ui",
3
- "version": "1.0.1",
3
+ "version": "4.1.0",
4
4
  "description": "Generic React UI components for Tangle products — primitives, chat, run, files, editor, markdown.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -96,6 +96,11 @@
96
96
  "types": "./dist/tool-previews.d.ts",
97
97
  "import": "./dist/tool-previews.js",
98
98
  "default": "./dist/tool-previews.js"
99
+ },
100
+ "./nav": {
101
+ "types": "./dist/nav.d.ts",
102
+ "import": "./dist/nav.js",
103
+ "default": "./dist/nav.js"
99
104
  }
100
105
  },
101
106
  "dependencies": {
@@ -126,7 +131,8 @@
126
131
  "peerDependencies": {
127
132
  "react": "^18 || ^19",
128
133
  "react-dom": "^18 || ^19",
129
- "@tangle-network/brand": "^0.3.0"
134
+ "react-router": "^7",
135
+ "@tangle-network/brand": "^0.5.0"
130
136
  },
131
137
  "peerDependenciesMeta": {
132
138
  "@nanostores/react": {
@@ -158,6 +164,9 @@
158
164
  },
159
165
  "yjs": {
160
166
  "optional": true
167
+ },
168
+ "react-router": {
169
+ "optional": true
161
170
  }
162
171
  },
163
172
  "devDependencies": {
@@ -177,7 +186,8 @@
177
186
  "@storybook/react": "^8.6.18",
178
187
  "tsup": "^8.3.5",
179
188
  "typescript": "^5.6.0",
180
- "yjs": "^13.6.0"
189
+ "yjs": "^13.6.0",
190
+ "react-router": "^7"
181
191
  },
182
192
  "keywords": [
183
193
  "tangle",
@@ -6,6 +6,7 @@ import {
6
6
  type DocumentEditorPaneCollaborationConfig,
7
7
  } from "../editor/document-editor-pane";
8
8
  import { ArtifactPane, type ArtifactPaneProps } from "../primitives/artifact-pane";
9
+ import { detectFileFormat } from "./file-format";
9
10
  import { FilePreview, type FilePreviewProps } from "./file-preview";
10
11
  import { FileTabs, type FileTabData } from "./file-tabs";
11
12
 
@@ -79,9 +80,8 @@ export function FileArtifactPane({
79
80
  />
80
81
  ) : undefined;
81
82
  const isMarkdown =
82
- mimeType === "text/markdown" ||
83
- filename.toLowerCase().endsWith(".md") ||
84
- path?.toLowerCase().endsWith(".md");
83
+ detectFileFormat(filename, mimeType) === "markdown" ||
84
+ (path ? detectFileFormat(path, mimeType) === "markdown" : false);
85
85
  const isEditableMarkdown = isMarkdown && editor?.enabled;
86
86
  const headerActions = (
87
87
  <>
@@ -0,0 +1,176 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ detectFileFormat,
4
+ fileExtension,
5
+ getCodeLanguage,
6
+ getFormatLabel,
7
+ getSyntaxLanguage,
8
+ } from "./file-format";
9
+
10
+ describe("detectFileFormat", () => {
11
+ it("detects by extension", () => {
12
+ expect(detectFileFormat("report.pdf")).toBe("pdf");
13
+ expect(detectFileFormat("photo.PNG")).toBe("image");
14
+ expect(detectFileFormat("data.csv")).toBe("csv");
15
+ expect(detectFileFormat("book.xlsx")).toBe("spreadsheet");
16
+ expect(detectFileFormat("legacy.xls")).toBe("spreadsheet");
17
+ expect(detectFileFormat("main.py")).toBe("code");
18
+ expect(detectFileFormat("app.tsx")).toBe("code");
19
+ expect(detectFileFormat("config.json")).toBe("json");
20
+ expect(detectFileFormat("compose.yaml")).toBe("yaml");
21
+ expect(detectFileFormat("compose.yml")).toBe("yaml");
22
+ expect(detectFileFormat("README.md")).toBe("markdown");
23
+ expect(detectFileFormat("notes.markdown")).toBe("markdown");
24
+ expect(detectFileFormat("output.log")).toBe("text");
25
+ });
26
+
27
+ it("treats shell dotfiles as code", () => {
28
+ expect(detectFileFormat(".bashrc")).toBe("code");
29
+ expect(detectFileFormat(".profile")).toBe("code");
30
+ expect(detectFileFormat(".gitignore")).toBe("code");
31
+ });
32
+
33
+ it("routes every highlightable extension to the code viewer", () => {
34
+ // detectFileFormat must stay in sync with getSyntaxLanguage — anything we
35
+ // can highlight (and that has no dedicated format) renders as code, not text.
36
+ for (const file of [
37
+ "main.rs",
38
+ "server.go",
39
+ "app.rb",
40
+ "styles.css",
41
+ "theme.scss",
42
+ "index.html",
43
+ "Cargo.toml",
44
+ "query.sql",
45
+ "Token.sol",
46
+ "schema.proto",
47
+ "module.mjs",
48
+ "legacy.cjs",
49
+ "deploy.zsh",
50
+ ]) {
51
+ expect(detectFileFormat(file)).toBe("code");
52
+ expect(getSyntaxLanguage(file)).toBeDefined();
53
+ }
54
+ });
55
+
56
+ it("keeps formats with a dedicated renderer out of the code viewer", () => {
57
+ expect(detectFileFormat("config.json")).toBe("json");
58
+ expect(detectFileFormat("compose.yml")).toBe("yaml");
59
+ expect(detectFileFormat("README.md")).toBe("markdown");
60
+ });
61
+
62
+ it("prefers MIME type when it is more specific", () => {
63
+ expect(detectFileFormat("file", "application/pdf")).toBe("pdf");
64
+ expect(detectFileFormat("blob", "image/png")).toBe("image");
65
+ expect(detectFileFormat("CHANGELOG", "text/markdown")).toBe("markdown");
66
+ expect(detectFileFormat("noext", "text/plain")).toBe("text");
67
+ });
68
+
69
+ it("falls back to unknown when nothing matches", () => {
70
+ expect(detectFileFormat("mystery.bin")).toBe("unknown");
71
+ expect(detectFileFormat("noextension")).toBe("unknown");
72
+ });
73
+
74
+ it("does not classify a dotless basename that happens to spell an extension", () => {
75
+ // A file literally named "pdf"/"json" has no extension — it must not be
76
+ // treated as that format (e.g. a pdf with no blobUrl renders empty).
77
+ expect(detectFileFormat("pdf")).toBe("unknown");
78
+ expect(detectFileFormat("json")).toBe("unknown");
79
+ expect(detectFileFormat("csv")).toBe("unknown");
80
+ });
81
+
82
+ it("lets a concrete extension win over a generic text/plain MIME", () => {
83
+ expect(detectFileFormat("config.json", "text/plain")).toBe("json");
84
+ expect(detectFileFormat("main.py", "text/plain")).toBe("code");
85
+ });
86
+
87
+ it("detects structured-data MIME types on generically-named files", () => {
88
+ expect(detectFileFormat("data", "application/json")).toBe("json");
89
+ expect(detectFileFormat("records", "text/csv")).toBe("csv");
90
+ expect(detectFileFormat("export", "application/csv")).toBe("csv");
91
+ expect(detectFileFormat("config", "application/yaml")).toBe("yaml");
92
+ expect(detectFileFormat("feed", "application/x-yaml")).toBe("yaml");
93
+ expect(detectFileFormat("feed", "text/yaml")).toBe("yaml");
94
+ });
95
+
96
+ it("ignores MIME charset parameters", () => {
97
+ expect(detectFileFormat("data", "application/json; charset=utf-8")).toBe("json");
98
+ expect(detectFileFormat("blob", "IMAGE/PNG")).toBe("image");
99
+ });
100
+
101
+ it("treats an authoritative MIME type as outranking a conflicting extension", () => {
102
+ expect(detectFileFormat("notes.json", "text/markdown")).toBe("markdown");
103
+ expect(detectFileFormat("page.txt", "application/json")).toBe("json");
104
+ });
105
+ });
106
+
107
+ describe("getFormatLabel", () => {
108
+ it("maps every format to a human label", () => {
109
+ expect(getFormatLabel("pdf")).toBe("PDF");
110
+ expect(getFormatLabel("json")).toBe("JSON");
111
+ expect(getFormatLabel("yaml")).toBe("YAML");
112
+ expect(getFormatLabel("markdown")).toBe("Markdown");
113
+ expect(getFormatLabel("spreadsheet")).toBe("Spreadsheet");
114
+ expect(getFormatLabel("unknown")).toBe("File");
115
+ });
116
+ });
117
+
118
+ describe("getSyntaxLanguage", () => {
119
+ it("maps known extensions to highlight.js language ids", () => {
120
+ expect(getSyntaxLanguage("main.py")).toBe("python");
121
+ expect(getSyntaxLanguage("server.ts")).toBe("typescript");
122
+ expect(getSyntaxLanguage("index.mjs")).toBe("javascript");
123
+ expect(getSyntaxLanguage("config.json")).toBe("json");
124
+ expect(getSyntaxLanguage("compose.yml")).toBe("yaml");
125
+ expect(getSyntaxLanguage(".bashrc")).toBe("bash");
126
+ expect(getSyntaxLanguage("lib.rs")).toBe("rust");
127
+ });
128
+
129
+ it("resolves from a full path, not just a basename", () => {
130
+ expect(getSyntaxLanguage("src/server/index.ts")).toBe("typescript");
131
+ });
132
+
133
+ it("returns undefined for unmapped extensions", () => {
134
+ expect(getSyntaxLanguage("mystery.bin")).toBeUndefined();
135
+ expect(getSyntaxLanguage("noextension")).toBeUndefined();
136
+ });
137
+ });
138
+
139
+ describe("getCodeLanguage", () => {
140
+ it("uses the detected format for json/yaml, covering extensionless MIME-only files", () => {
141
+ expect(getCodeLanguage("config", "json")).toBe("json");
142
+ expect(getCodeLanguage("config", "yaml")).toBe("yaml");
143
+ // A real extension resolves to the same answer.
144
+ expect(getCodeLanguage("config.json", "json")).toBe("json");
145
+ expect(getCodeLanguage("compose.yml", "yaml")).toBe("yaml");
146
+ });
147
+
148
+ it("keys off the extension for other code formats", () => {
149
+ expect(getCodeLanguage("main.rs", "code")).toBe("rust");
150
+ expect(getCodeLanguage("server.ts", "code")).toBe("typescript");
151
+ expect(getCodeLanguage("notes", "code")).toBeUndefined();
152
+ });
153
+ });
154
+
155
+ describe("fileExtension", () => {
156
+ it("lowercases the trailing extension", () => {
157
+ expect(fileExtension("Photo.JPEG")).toBe("jpeg");
158
+ });
159
+
160
+ it("returns the dotfile name for files that are all extension", () => {
161
+ expect(fileExtension(".gitignore")).toBe("gitignore");
162
+ });
163
+
164
+ it("ignores dots in directory components", () => {
165
+ expect(fileExtension("my.config.dir/file")).toBe("");
166
+ expect(fileExtension("v1.2.0/Makefile")).toBe("");
167
+ expect(fileExtension("a.b.c/server.ts")).toBe("ts");
168
+ });
169
+
170
+ it("returns no extension for a dotless basename, even one that looks like an extension", () => {
171
+ expect(fileExtension("json")).toBe("");
172
+ expect(fileExtension("pdf")).toBe("");
173
+ expect(fileExtension("README")).toBe("");
174
+ expect(fileExtension("Makefile")).toBe("");
175
+ });
176
+ });
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Shared file-format detection for every file surface — the preview pane, the
3
+ * artifact pane, and code rendering. Centralising extension/MIME → format logic
4
+ * keeps chat and artifact views consistent and avoids the same mapping drifting
5
+ * across components.
6
+ */
7
+
8
+ export type FileFormat =
9
+ | "pdf"
10
+ | "image"
11
+ | "csv"
12
+ | "spreadsheet"
13
+ | "code"
14
+ | "json"
15
+ | "yaml"
16
+ | "markdown"
17
+ | "text"
18
+ | "unknown";
19
+
20
+ const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "svg", "webp"];
21
+
22
+ const EXTENSION_TO_SYNTAX_LANGUAGE: Record<string, string> = {
23
+ ts: "typescript",
24
+ tsx: "tsx",
25
+ js: "javascript",
26
+ jsx: "jsx",
27
+ mjs: "javascript",
28
+ cjs: "javascript",
29
+ rs: "rust",
30
+ py: "python",
31
+ go: "go",
32
+ rb: "ruby",
33
+ json: "json",
34
+ yaml: "yaml",
35
+ yml: "yaml",
36
+ toml: "toml",
37
+ md: "markdown",
38
+ markdown: "markdown",
39
+ css: "css",
40
+ scss: "scss",
41
+ html: "html",
42
+ sh: "bash",
43
+ bash: "bash",
44
+ zsh: "bash",
45
+ bashrc: "bash",
46
+ bash_logout: "bash",
47
+ profile: "bash",
48
+ sql: "sql",
49
+ sol: "solidity",
50
+ proto: "protobuf",
51
+ };
52
+
53
+ // Syntax-mapped extensions that render through their own dedicated format
54
+ // instead of the generic code viewer.
55
+ const NON_CODE_SYNTAX_EXTENSIONS = new Set(["json", "yaml", "yml", "md", "markdown"]);
56
+
57
+ // Code files with no highlight language — still shown as themed monospace.
58
+ const PLAIN_CODE_EXTENSIONS = ["env", "gitignore"];
59
+
60
+ // Derived from the syntax map so code detection and highlighting never drift:
61
+ // every extension we can highlight (minus those with a dedicated format) routes
62
+ // through the code viewer.
63
+ const CODE_EXTENSIONS = new Set<string>([
64
+ ...Object.keys(EXTENSION_TO_SYNTAX_LANGUAGE).filter((ext) => !NON_CODE_SYNTAX_EXTENSIONS.has(ext)),
65
+ ...PLAIN_CODE_EXTENSIONS,
66
+ ]);
67
+
68
+ /**
69
+ * Lowercased trailing extension. Returns "" for a name with no extension
70
+ * ("README", "json" → ""), and the post-dot name for a dotfile (".bashrc" →
71
+ * "bashrc"). Directory components are ignored so dots in a directory name don't
72
+ * leak in ("my.config/file" → "").
73
+ */
74
+ export function fileExtension(filename: string): string {
75
+ const base = filename.slice(filename.lastIndexOf("/") + 1);
76
+ const dot = base.lastIndexOf(".");
77
+ // No dot → no extension. A leading dot (dotfile) is the one case where the
78
+ // whole post-dot name is the extension key.
79
+ if (dot < 0) return "";
80
+ return base.slice(dot + 1).toLowerCase();
81
+ }
82
+
83
+ /** Bare MIME essence, lowercased with any `; charset=…` parameters stripped. */
84
+ function mimeEssence(mimeType?: string): string {
85
+ return mimeType?.split(";")[0]?.trim().toLowerCase() ?? "";
86
+ }
87
+
88
+ /**
89
+ * Resolve a filename + optional MIME type to the renderer format. A specific,
90
+ * authoritative MIME type wins over the extension; otherwise the extension
91
+ * decides; a generic text/plain payload is the final fallback.
92
+ */
93
+ export function detectFileFormat(filename: string, mimeType?: string): FileFormat {
94
+ const ext = fileExtension(filename);
95
+ const mime = mimeEssence(mimeType);
96
+
97
+ // 1. Specific MIME types are authoritative — they outrank the extension.
98
+ if (mime === "application/pdf") return "pdf";
99
+ if (mime.startsWith("image/")) return "image";
100
+ if (mime === "text/markdown") return "markdown";
101
+ if (mime === "application/json") return "json";
102
+ if (mime === "text/csv" || mime === "application/csv") return "csv";
103
+ if (mime === "application/yaml" || mime === "application/x-yaml" || mime === "text/yaml") return "yaml";
104
+
105
+ // 2. Fall back to the file extension.
106
+ if (ext === "pdf") return "pdf";
107
+ if (IMAGE_EXTENSIONS.includes(ext)) return "image";
108
+ if (ext === "csv") return "csv";
109
+ if (ext === "xlsx" || ext === "xls") return "spreadsheet";
110
+ if (CODE_EXTENSIONS.has(ext)) return "code";
111
+ if (ext === "json") return "json";
112
+ if (ext === "yaml" || ext === "yml") return "yaml";
113
+ if (ext === "md" || ext === "markdown") return "markdown";
114
+ if (["txt", "log", "text"].includes(ext)) return "text";
115
+
116
+ // 3. Unknown extension but a text/plain payload — show as text, not "unknown".
117
+ if (mime === "text/plain") return "text";
118
+
119
+ return "unknown";
120
+ }
121
+
122
+ /** Human-facing label for a detected format. */
123
+ export function getFormatLabel(format: FileFormat): string {
124
+ switch (format) {
125
+ case "pdf":
126
+ return "PDF";
127
+ case "image":
128
+ return "Image";
129
+ case "csv":
130
+ return "CSV";
131
+ case "spreadsheet":
132
+ return "Spreadsheet";
133
+ case "code":
134
+ return "Code";
135
+ case "json":
136
+ return "JSON";
137
+ case "yaml":
138
+ return "YAML";
139
+ case "markdown":
140
+ return "Markdown";
141
+ case "text":
142
+ return "Text";
143
+ default:
144
+ return "File";
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Map a filename (or path) to a highlight.js language id for CodeBlock. Returns
150
+ * undefined when there is no confident mapping; CodeBlock then renders themed
151
+ * monospace and lets the highlighter auto-detect, so callers never need a
152
+ * bespoke language table.
153
+ */
154
+ export function getSyntaxLanguage(filename: string): string | undefined {
155
+ return EXTENSION_TO_SYNTAX_LANGUAGE[fileExtension(filename)];
156
+ }
157
+
158
+ /**
159
+ * Highlight language for a file already classified as a code-like format.
160
+ * `json`/`yaml` are their own highlight language even when detected purely from
161
+ * a MIME type on an extensionless file (where the extension can't reveal it);
162
+ * any other code format keys off the extension.
163
+ */
164
+ export function getCodeLanguage(filename: string, format: FileFormat): string | undefined {
165
+ if (format === "json" || format === "yaml") return format;
166
+ return getSyntaxLanguage(filename);
167
+ }
@@ -0,0 +1,87 @@
1
+ import type { Meta, StoryObj } from '@storybook/react'
2
+ import { FilePreview } from './file-preview'
3
+
4
+ const TS_SOURCE = `interface SandboxConfig {
5
+ model: string
6
+ timeout: number
7
+ env: Record<string, string>
8
+ }
9
+
10
+ // Create a new sandbox and stream its output.
11
+ export async function run(config: SandboxConfig) {
12
+ const sandbox = await Sandbox.create(config)
13
+ for await (const chunk of sandbox.stream()) {
14
+ process.stdout.write(chunk)
15
+ }
16
+ }`
17
+
18
+ const JSON_SOURCE = `{
19
+ "name": "@tangle-network/sandbox-ui",
20
+ "version": "0.37.1",
21
+ "type": "module",
22
+ "scripts": {
23
+ "build": "tsup",
24
+ "test": "vitest run"
25
+ }
26
+ }`
27
+
28
+ const SHELL_SOURCE = `# ~/.bashrc — loaded for interactive shells
29
+ export NIX_BIN_PATH=/nix/profile/bin
30
+ export PATH="$NIX_BIN_PATH:$PATH"
31
+
32
+ alias gs="git status"
33
+ alias ll="ls -alh"`
34
+
35
+ const CSV_SOURCE = `name,role,active
36
+ Ada,engineer,true
37
+ Linus,maintainer,true
38
+ Grace,architect,false`
39
+
40
+ const MARKDOWN_SOURCE = `# Artifact
41
+
42
+ Markdown still renders as **prose**, not as a code block.
43
+
44
+ - Themed headings
45
+ - \`inline code\`
46
+ - [links](https://tangle.tools)`
47
+
48
+ const meta: Meta<typeof FilePreview> = {
49
+ title: 'Files/FilePreview',
50
+ component: FilePreview,
51
+ parameters: { layout: 'fullscreen', backgrounds: { default: 'dark' } },
52
+ decorators: [
53
+ (Story) => (
54
+ <div className="h-[560px] w-[760px] bg-card text-foreground">
55
+ <Story />
56
+ </div>
57
+ ),
58
+ ],
59
+ }
60
+
61
+ export default meta
62
+ type Story = StoryObj<typeof FilePreview>
63
+
64
+ export const Code: Story = {
65
+ name: 'Code (TypeScript) — syntax highlighted',
66
+ args: { filename: 'run.ts', content: TS_SOURCE },
67
+ }
68
+
69
+ export const Json: Story = {
70
+ name: 'JSON',
71
+ args: { filename: 'package.json', content: JSON_SOURCE },
72
+ }
73
+
74
+ export const ShellDotfile: Story = {
75
+ name: 'Shell dotfile (.bashrc)',
76
+ args: { filename: '.bashrc', content: SHELL_SOURCE },
77
+ }
78
+
79
+ export const Csv: Story = {
80
+ name: 'CSV table',
81
+ args: { filename: 'people.csv', content: CSV_SOURCE },
82
+ }
83
+
84
+ export const MarkdownProse: Story = {
85
+ name: 'Markdown (rendered prose)',
86
+ args: { filename: 'README.md', content: MARKDOWN_SOURCE },
87
+ }
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render } from "@testing-library/react";
3
+ import { FilePreview } from "./file-preview";
4
+
5
+ describe("FilePreview", () => {
6
+ it("renders code through the syntax highlighter, not a plain table", () => {
7
+ const { container, getByText } = render(
8
+ <FilePreview filename="server.ts" content={'const x: number = 1;\nexport { x };'} />,
9
+ );
10
+
11
+ // react-syntax-highlighter emits a <code> element; the old plain viewer
12
+ // used a <table>. Asserting both pins the artifact viewer to the shared,
13
+ // theme-aware CodeBlock used by chat.
14
+ expect(container.querySelector("code")).not.toBeNull();
15
+ expect(container.querySelector("table")).toBeNull();
16
+
17
+ // Header label carries the extension + line count, and a copy button is wired.
18
+ expect(getByText(/ts · 2 lines/i)).toBeInTheDocument();
19
+ expect(container.querySelector('button[title="Copy to clipboard"]')).not.toBeNull();
20
+ });
21
+
22
+ it("renders JSON through the highlighter too", () => {
23
+ const { container } = render(
24
+ <FilePreview filename="config.json" content={'{\n "a": 1\n}'} />,
25
+ );
26
+ expect(container.querySelector("code")).not.toBeNull();
27
+ expect(container.querySelector("table")).toBeNull();
28
+ });
29
+
30
+ it("routes MIME-detected json on an extensionless file to the code viewer", () => {
31
+ const { container } = render(
32
+ <FilePreview filename="config" mimeType="application/json" content={'{"a":1}'} />,
33
+ );
34
+ expect(container.querySelector("code")).not.toBeNull();
35
+ expect(container.querySelector("pre.bg-background")).toBeNull(); // not the plain TextPreview
36
+ });
37
+
38
+ it("renders csv as a table", () => {
39
+ const { container } = render(
40
+ <FilePreview filename="data.csv" content={"a,b\n1,2"} />,
41
+ );
42
+ expect(container.querySelector("table")).not.toBeNull();
43
+ });
44
+
45
+ it("renders plain text in a pre, without the highlighter", () => {
46
+ const { container } = render(
47
+ <FilePreview filename="notes.txt" content={"just words"} />,
48
+ );
49
+ expect(container.querySelector("pre")).not.toBeNull();
50
+ expect(container.querySelector("code")).toBeNull();
51
+ });
52
+ });