@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.
- package/CHANGELOG.md +20 -0
- package/dist/chat.js +8 -7
- package/dist/{chunk-TMFOPHHN.js → chunk-52Y3FMFI.js} +2 -2
- package/dist/{chunk-7UO2ZMRQ.js → chunk-5VPTNXX7.js} +2 -2
- package/dist/{chunk-XIHMJ7ZQ.js → chunk-AAUNOHVL.js} +5 -30
- package/dist/{chunk-YJ2G3XO5.js → chunk-CMX2I43A.js} +1 -1
- package/dist/{chunk-2VH6PUXD.js → chunk-DGW77LD7.js} +1 -1
- package/dist/{chunk-CD53GZOM.js → chunk-FJBTCTZM.js} +1 -1
- package/dist/{chunk-YNN4O57I.js → chunk-JBPWIYTQ.js} +4 -4
- package/dist/{chunk-2NFQRQOD.js → chunk-KT5RNO7N.js} +4 -4
- package/dist/{chunk-HJKCSXCH.js → chunk-LELGOLFV.js} +44 -78
- package/dist/{chunk-EEE55AVS.js → chunk-SZ44QDA6.js} +1 -1
- package/dist/{chunk-66BNMOVT.js → chunk-WUQDUBJG.js} +5 -4
- package/dist/chunk-ZRVH3WCA.js +107 -0
- package/dist/{code-block-DjXf8eOG.d.ts → code-block-0kSpWMnf.d.ts} +7 -1
- package/dist/{document-editor-pane-A5LT5H4N.js → document-editor-pane-WCTA3ZOE.js} +3 -3
- package/dist/editor.js +3 -3
- package/dist/files.d.ts +39 -2
- package/dist/files.js +16 -4
- package/dist/hooks.js +5 -5
- package/dist/index.d.ts +2 -2
- package/dist/index.js +22 -10
- package/dist/markdown.d.ts +1 -1
- package/dist/markdown.js +2 -2
- package/dist/openui.js +3 -3
- package/dist/primitives.d.ts +1 -1
- package/dist/primitives.js +1 -1
- package/dist/run.js +8 -7
- package/dist/sdk-hooks.js +5 -5
- package/dist/tool-previews.js +3 -2
- package/package.json +2 -2
- package/src/files/file-artifact-pane.tsx +3 -3
- package/src/files/file-format.test.ts +176 -0
- package/src/files/file-format.ts +167 -0
- package/src/files/file-preview.stories.tsx +87 -0
- package/src/files/file-preview.test.tsx +52 -0
- package/src/files/file-preview.tsx +48 -94
- package/src/files/index.ts +8 -0
- package/src/markdown/code-block.test.tsx +62 -0
- package/src/markdown/code-block.tsx +11 -4
- 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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
//
|
|
45
|
-
|
|
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
|
-
<
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
274
|
-
const previewLabel =
|
|
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
|
-
|
|
279
|
-
|
|
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
|
-
{
|
|
316
|
-
{
|
|
317
|
-
{
|
|
318
|
-
{(
|
|
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
|
-
{
|
|
322
|
-
{
|
|
323
|
-
{
|
|
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
|
-
{
|
|
331
|
-
{
|
|
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}
|
package/src/files/index.ts
CHANGED
|
@@ -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
|
-
{
|
|
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
|
-
{
|
|
133
|
+
{headerLabel}
|
|
127
134
|
</span>
|
|
128
135
|
{children}
|
|
129
136
|
</div>
|
|
130
137
|
)}
|
|
131
|
-
{!
|
|
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 =
|
|
31
|
+
const language = getSyntaxLanguage(write.path);
|
|
60
32
|
|
|
61
33
|
return (
|
|
62
34
|
<PreviewCard
|