@tangle-network/ui 3.0.0 → 5.0.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 (41) hide show
  1. package/CHANGELOG.md +20 -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 -3
  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 +2 -2
  22. package/dist/index.js +22 -10
  23. package/dist/markdown.d.ts +1 -1
  24. package/dist/markdown.js +2 -2
  25. package/dist/openui.js +3 -3
  26. package/dist/primitives.d.ts +1 -1
  27. package/dist/primitives.js +1 -1
  28. package/dist/run.js +8 -7
  29. package/dist/sdk-hooks.js +5 -5
  30. package/dist/tool-previews.js +3 -2
  31. package/package.json +2 -2
  32. package/src/files/file-artifact-pane.tsx +3 -3
  33. package/src/files/file-format.test.ts +176 -0
  34. package/src/files/file-format.ts +167 -0
  35. package/src/files/file-preview.stories.tsx +87 -0
  36. package/src/files/file-preview.test.tsx +52 -0
  37. package/src/files/file-preview.tsx +48 -94
  38. package/src/files/index.ts +8 -0
  39. package/src/markdown/code-block.test.tsx +62 -0
  40. package/src/markdown/code-block.tsx +11 -4
  41. package/src/tool-previews/write-file-preview.tsx +2 -30
@@ -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
+ });
@@ -4,7 +4,7 @@
4
4
  * Renders any file type beautifully:
5
5
  * - PDF: embedded viewer
6
6
  * - CSV/XLSX: tabular preview
7
- * - Code (py/json/yaml/ts/js): line-numbered viewer
7
+ * - Code (py/json/yaml/ts/js): syntax-highlighted, line-numbered viewer
8
8
  * - Markdown: rendered prose
9
9
  * - Images: inline display
10
10
  * - Text: monospace preview
@@ -17,6 +17,14 @@ import {
17
17
  } from "lucide-react";
18
18
  import { cn } from "../lib/utils";
19
19
  import { Markdown } from "../markdown/markdown";
20
+ import { CodeBlock, CopyButton } from "../markdown/code-block";
21
+ import {
22
+ detectFileFormat,
23
+ fileExtension,
24
+ getCodeLanguage,
25
+ getFormatLabel,
26
+ type FileFormat,
27
+ } from "./file-format";
20
28
 
21
29
  export interface FilePreviewProps {
22
30
  filename: string;
@@ -29,87 +37,33 @@ export interface FilePreviewProps {
29
37
  className?: string;
30
38
  }
31
39
 
32
- function getPreviewType(filename: string, mimeType?: string): string {
33
- const ext = filename.split(".").pop()?.toLowerCase() || "";
34
- if (mimeType?.startsWith("application/pdf") || ext === "pdf") return "pdf";
35
- if (mimeType?.startsWith("image/") || ["png", "jpg", "jpeg", "gif", "svg", "webp"].includes(ext)) return "image";
36
- if (["csv"].includes(ext)) return "csv";
37
- if (["xlsx", "xls"].includes(ext)) return "spreadsheet";
38
- if (["py", "ts", "js", "tsx", "jsx", "sh", "bash"].includes(ext) || ["profile", "bashrc", "bash_logout", "env", "gitignore"].includes(ext)) return "code";
39
- if (["json"].includes(ext)) return "json";
40
- if (["yaml", "yml"].includes(ext)) return "yaml";
41
- if (["md", "markdown"].includes(ext)) return "markdown";
42
- if (["txt", "log", "text"].includes(ext)) return "text";
43
-
44
- // If we have no known extension but we do have a text plain content payload, fallback to text rather than "unknown"
45
- if (mimeType?.startsWith("text/plain")) return "text";
46
-
47
- return "unknown";
48
- }
49
-
50
- function getPreviewLabel(previewType: string) {
51
- switch (previewType) {
52
- case "pdf":
53
- return "PDF";
54
- case "image":
55
- return "Image";
56
- case "csv":
57
- return "CSV";
58
- case "spreadsheet":
59
- return "Spreadsheet";
60
- case "code":
61
- return "Code";
62
- case "json":
63
- return "JSON";
64
- case "yaml":
65
- return "YAML";
66
- case "markdown":
67
- return "Markdown";
68
- case "text":
69
- return "Text";
70
- default:
71
- return "File";
72
- }
73
- }
74
-
75
- function CodePreview({ content, filename }: { content: string; filename: string }) {
76
- const lines = content.split("\n");
77
- const language = filename.split(".").pop()?.toUpperCase() || "TXT";
40
+ function CodePreview({
41
+ content,
42
+ filename,
43
+ format,
44
+ }: {
45
+ content: string;
46
+ filename: string;
47
+ format: FileFormat;
48
+ }) {
49
+ const lineCount = content.split("\n").length;
50
+ const language = getCodeLanguage(filename, format);
51
+ // Prefer the extension; for an extensionless file (e.g. one detected from its
52
+ // MIME type) fall back to the highlight language so the label stays meaningful.
53
+ const labelToken = fileExtension(filename) || language || "txt";
78
54
 
55
+ // Same theme-aware highlighter the chat markdown renderer uses, so code looks
56
+ // identical in an artifact pane and inline in a message.
79
57
  return (
80
- <div className="relative bg-background rounded-[var(--radius-md)] border border-border overflow-hidden">
81
- <div className="flex items-center justify-between gap-3 border-b border-border px-4 py-2.5">
82
- <div className="flex gap-1.5">
83
- <div className="w-3 h-3 rounded-full bg-[#FF5F57]" />
84
- <div className="w-3 h-3 rounded-full bg-[#FEBC2E]" />
85
- <div className="w-3 h-3 rounded-full bg-[#8E59FF]" />
86
- </div>
87
- <div className="ml-2 min-w-0 flex-1 truncate text-xs font-mono text-muted-foreground">
88
- {filename}
89
- </div>
90
- <div className="inline-flex items-center gap-2 rounded-[var(--radius-full)] border border-border bg-card px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
91
- <span>{language}</span>
92
- <span className="h-1 w-1 rounded-full bg-[var(--border-hover)]" />
93
- <span>{lines.length} lines</span>
94
- </div>
95
- </div>
96
- <div className="overflow-auto max-h-[70vh]">
97
- <table className="w-full">
98
- <tbody>
99
- {lines.map((line, i) => (
100
- <tr key={i} className="hover:bg-accent">
101
- <td className="text-right pr-4 pl-4 py-0 select-none text-muted-foreground text-xs font-mono w-10 align-top leading-[1.55]">
102
- {i + 1}
103
- </td>
104
- <td className="pr-4 py-0 font-mono text-[13px] text-foreground leading-[1.55] whitespace-pre">
105
- {line || " "}
106
- </td>
107
- </tr>
108
- ))}
109
- </tbody>
110
- </table>
111
- </div>
112
- </div>
58
+ <CodeBlock
59
+ code={content}
60
+ language={language}
61
+ label={`${labelToken} · ${lineCount} lines`}
62
+ showLineNumbers
63
+ className="max-h-[70vh] overflow-auto"
64
+ >
65
+ <CopyButton text={content} />
66
+ </CodeBlock>
113
67
  );
114
68
  }
115
69
 
@@ -270,13 +224,13 @@ export function FilePreview({
270
224
  hideHeader = false,
271
225
  className,
272
226
  }: FilePreviewProps) {
273
- const previewType = getPreviewType(filename, mimeType);
274
- const previewLabel = getPreviewLabel(previewType);
227
+ const format: FileFormat = detectFileFormat(filename, mimeType);
228
+ const previewLabel = getFormatLabel(format);
275
229
  const hasRenderableSource =
276
230
  Boolean(content) ||
277
231
  Boolean(blobUrl) ||
278
- previewType === "unknown" ||
279
- previewType === "spreadsheet";
232
+ format === "unknown" ||
233
+ format === "spreadsheet";
280
234
 
281
235
  return (
282
236
  <div className={cn("flex flex-col h-full", className)}>
@@ -312,23 +266,23 @@ export function FilePreview({
312
266
  )}
313
267
 
314
268
  <div className="flex-1 overflow-auto p-3">
315
- {previewType === "pdf" && blobUrl && <PdfPreview blobUrl={blobUrl} filename={filename} />}
316
- {previewType === "image" && blobUrl && <ImagePreview src={blobUrl} filename={filename} />}
317
- {previewType === "csv" && typeof content === "string" && <CsvPreview content={content} />}
318
- {(previewType === "code" || previewType === "json" || previewType === "yaml") && typeof content === "string" && (
319
- <CodePreview content={content} filename={filename} />
269
+ {format === "pdf" && blobUrl && <PdfPreview blobUrl={blobUrl} filename={filename} />}
270
+ {format === "image" && blobUrl && <ImagePreview src={blobUrl} filename={filename} />}
271
+ {format === "csv" && typeof content === "string" && <CsvPreview content={content} />}
272
+ {(format === "code" || format === "json" || format === "yaml") && typeof content === "string" && (
273
+ <CodePreview content={content} filename={filename} format={format} />
320
274
  )}
321
- {previewType === "text" && typeof content === "string" && <TextPreview content={content} />}
322
- {previewType === "markdown" && typeof content === "string" && <MarkdownPreview content={content} />}
323
- {previewType === "spreadsheet" && (
275
+ {format === "text" && typeof content === "string" && <TextPreview content={content} />}
276
+ {format === "markdown" && typeof content === "string" && <MarkdownPreview content={content} />}
277
+ {format === "spreadsheet" && (
324
278
  <UnsupportedPreview
325
279
  filename={filename}
326
280
  title="Spreadsheet preview is not available in this surface"
327
281
  description="Download the workbook or convert it to CSV when you need an inline preview."
328
282
  />
329
283
  )}
330
- {previewType === "unknown" && typeof content !== "string" && <EmptyPreview filename={filename} />}
331
- {previewType === "unknown" && typeof content === "string" && <TextPreview content={content} />}
284
+ {format === "unknown" && typeof content !== "string" && <EmptyPreview filename={filename} />}
285
+ {format === "unknown" && typeof content === "string" && <TextPreview content={content} />}
332
286
  {!hasRenderableSource && typeof content !== "string" && (
333
287
  <UnsupportedPreview
334
288
  filename={filename}
@@ -15,3 +15,11 @@ export {
15
15
  export { FilePreview, type FilePreviewProps } from "./file-preview";
16
16
  export { FileTabs, type FileTabsProps, type FileTabData } from "./file-tabs";
17
17
  export { FileArtifactPane, type FileArtifactPaneProps } from "./file-artifact-pane";
18
+ export {
19
+ detectFileFormat,
20
+ fileExtension,
21
+ getCodeLanguage,
22
+ getFormatLabel,
23
+ getSyntaxLanguage,
24
+ type FileFormat,
25
+ } from "./file-format";
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render } from "@testing-library/react";
3
+ import { CodeBlock, CopyButton } from "./code-block";
4
+
5
+ describe("CodeBlock", () => {
6
+ it("renders the code through the syntax highlighter", () => {
7
+ const { container } = render(<CodeBlock code={"const x = 1;"} language="typescript" />);
8
+ expect(container.querySelector("code")).not.toBeNull();
9
+ });
10
+
11
+ it("uses `label` as the header text, overriding `language`", () => {
12
+ const { getByText, queryByText } = render(
13
+ <CodeBlock code={"x"} language="typescript" label="config.ts" />,
14
+ );
15
+ expect(getByText("config.ts")).toBeInTheDocument();
16
+ expect(queryByText("typescript")).toBeNull();
17
+ });
18
+
19
+ it("falls back to `language` for the header when no `label` is given", () => {
20
+ const { getByText } = render(<CodeBlock code={"x"} language="python" />);
21
+ expect(getByText("python")).toBeInTheDocument();
22
+ });
23
+
24
+ it("renders no header when neither label nor language is set", () => {
25
+ const { container } = render(<CodeBlock code={"x"} />);
26
+ // The header is the only element carrying a bottom border.
27
+ expect(container.querySelector(".border-b")).toBeNull();
28
+ });
29
+
30
+ it("treats an explicit empty label as 'no header', even with a language", () => {
31
+ const { container, queryByText } = render(
32
+ <CodeBlock code={"x"} language="python" label="" />,
33
+ );
34
+ expect(container.querySelector(".border-b")).toBeNull();
35
+ expect(queryByText("python")).toBeNull();
36
+ });
37
+
38
+ it("places children in the header when a header is shown", () => {
39
+ const { container } = render(
40
+ <CodeBlock code={"x"} label="run.ts">
41
+ <CopyButton text="x" />
42
+ </CodeBlock>,
43
+ );
44
+ const copyButton = container.querySelector('button[title="Copy to clipboard"]');
45
+ expect(copyButton).not.toBeNull();
46
+ // Inside the bordered header, not the absolute hover overlay.
47
+ expect(copyButton?.closest(".border-b")).not.toBeNull();
48
+ expect(copyButton?.closest(".absolute")).toBeNull();
49
+ });
50
+
51
+ it("moves children to a hover overlay when no header is shown", () => {
52
+ const { container } = render(
53
+ <CodeBlock code={"x"}>
54
+ <CopyButton text="x" />
55
+ </CodeBlock>,
56
+ );
57
+ const copyButton = container.querySelector('button[title="Copy to clipboard"]');
58
+ expect(copyButton).not.toBeNull();
59
+ expect(copyButton?.closest(".absolute")).not.toBeNull();
60
+ expect(copyButton?.closest(".border-b")).toBeNull();
61
+ });
62
+ });
@@ -74,6 +74,12 @@ function getSyntaxTheme(): { [key: string]: React.CSSProperties } {
74
74
  export interface CodeBlockProps extends HTMLAttributes<HTMLDivElement> {
75
75
  code: string;
76
76
  language?: string;
77
+ /**
78
+ * Header text. Defaults to `language`. Set this when the display name differs
79
+ * from the highlight.js language id — e.g. a file extension ("BASHRC") whose
80
+ * content highlights as a known language, or none.
81
+ */
82
+ label?: string;
77
83
  showLineNumbers?: boolean;
78
84
  /** Force light theme; defaults to dark */
79
85
  light?: boolean;
@@ -107,28 +113,29 @@ function useIsLightTheme(): boolean {
107
113
  }
108
114
 
109
115
  export const CodeBlock = memo(
110
- ({ code, language, showLineNumbers = false, light: lightProp, className, children, ...props }: CodeBlockProps) => {
116
+ ({ code, language, label, showLineNumbers = false, light: lightProp, className, children, ...props }: CodeBlockProps) => {
111
117
  const isLight = useIsLightTheme();
112
118
  const light = lightProp ?? isLight;
113
119
  const theme = getSyntaxTheme();
114
120
  const bg = "bg-card border-border";
115
121
  const headerBg = light ? "bg-muted/50 border-border" : "bg-background border-border";
116
122
  const langColor = "text-muted-foreground";
123
+ const headerLabel = label ?? language;
117
124
 
118
125
  return (
119
126
  <div
120
127
  className={cn("group relative overflow-hidden rounded-lg border font-mono", bg, className)}
121
128
  {...props}
122
129
  >
123
- {language && (
130
+ {headerLabel && (
124
131
  <div className={cn("flex items-center justify-between border-b px-3 py-1", headerBg)}>
125
132
  <span className={cn("text-[calc(var(--font-size-xs)-1px)] font-mono font-medium uppercase tracking-widest", langColor)}>
126
- {language}
133
+ {headerLabel}
127
134
  </span>
128
135
  {children}
129
136
  </div>
130
137
  )}
131
- {!language && children && (
138
+ {!headerLabel && children && (
132
139
  <div className="absolute right-2 top-2 z-10 flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100">
133
140
  {children}
134
141
  </div>
@@ -1,6 +1,7 @@
1
1
  import { memo } from "react";
2
2
  import { FileEdit } from "lucide-react";
3
3
  import type { ToolPart } from "../types/parts";
4
+ import { getSyntaxLanguage } from "../files/file-format";
4
5
  import { CodeBlock, CopyButton } from "../markdown/code-block";
5
6
  import { PreviewCard, PreviewError, PreviewLoading } from "./preview-primitives";
6
7
 
@@ -18,35 +19,6 @@ function extractWriteContent(
18
19
  return { path, content };
19
20
  }
20
21
 
21
- function getLanguageFromPath(path: string): string | undefined {
22
- const ext = path.split(".").pop()?.toLowerCase();
23
- const map: Record<string, string> = {
24
- ts: "typescript",
25
- tsx: "tsx",
26
- js: "javascript",
27
- jsx: "jsx",
28
- rs: "rust",
29
- py: "python",
30
- go: "go",
31
- rb: "ruby",
32
- json: "json",
33
- yaml: "yaml",
34
- yml: "yaml",
35
- toml: "toml",
36
- md: "markdown",
37
- css: "css",
38
- scss: "scss",
39
- html: "html",
40
- sh: "bash",
41
- bash: "bash",
42
- zsh: "bash",
43
- sql: "sql",
44
- sol: "solidity",
45
- proto: "protobuf",
46
- };
47
- return ext ? map[ext] : undefined;
48
- }
49
-
50
22
  /**
51
23
  * Preview for file write/create operations.
52
24
  * Shows file path, line count, and the written content.
@@ -56,7 +28,7 @@ export const WriteFilePreview = memo(({ part }: WriteFilePreviewProps) => {
56
28
  if (!write) return null;
57
29
 
58
30
  const lineCount = write.content.split("\n").length;
59
- const language = getLanguageFromPath(write.path);
31
+ const language = getSyntaxLanguage(write.path);
60
32
 
61
33
  return (
62
34
  <PreviewCard