feat(skills): add syntax highlighting, CSV tables, responsive layout

- Add highlight.js with tree-shaken language imports for code viewer
- Add CSV file rendering as styled table with sticky headers
- Add resizable file tree panel with drag handle
- Add mobile stacked layout with back navigation
- Strip frontmatter from .md files in file viewer
- Add responsive dialog breakpoints (sm/md/lg/xl/2xl)
- Split skill-detail-dialog into 4 focused modules (<200 lines each)
- Add highlight.js CSS with dark mode overrides
This commit is contained in:
viettranx committed 2026-03-08 10:47:42 +07:00
1 parent 2a4db0da94
commit 85e7198f6b
8 files changed
+547 -277

No files matched your search

+1
View File
@@ -16,6 +16,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.34.3",
"highlight.js": "^11.11.1",
"lucide-react": "^0.468.0",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
+3
View File
@@ -26,6 +26,9 @@ importers:
framer-motion:
specifier: ^12.34.3
version: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
highlight.js:
specifier: ^11.11.1
version: 11.11.1
lucide-react:
specifier: ^0.468.0
version: 0.468.0(react@19.2.4)
+27 -1
View File
@@ -1,4 +1,5 @@
@import "tailwindcss";
@import "highlight.js/styles/github.min.css";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *));
@@ -135,5 +136,30 @@
@apply bg-background text-foreground;
font-family: system-ui, -apple-system, sans-serif;
}
}
/* highlight.js: transparent bg, container handles it */
.hljs { background: transparent !important; }
/* Dark mode syntax colors (github-dark palette) */
.dark .hljs { color: #e6edf3; }
.dark .hljs-comment,
.dark .hljs-quote { color: #8b949e; }
.dark .hljs-keyword,
.dark .hljs-selector-tag,
.dark .hljs-type { color: #ff7b72; }
.dark .hljs-string,
.dark .hljs-addition { color: #a5d6ff; }
.dark .hljs-number,
.dark .hljs-literal { color: #79c0ff; }
.dark .hljs-title,
.dark .hljs-section,
.dark .hljs-attr { color: #d2a8ff; }
.dark .hljs-name,
.dark .hljs-tag { color: #7ee787; }
.dark .hljs-built_in,
.dark .hljs-symbol { color: #ffa657; }
.dark .hljs-variable,
.dark .hljs-template-variable { color: #ffa657; }
.dark .hljs-deletion { color: #ffa198; background: rgba(255,129,130,0.15); }
.dark .hljs-addition { background: rgba(63,185,80,0.15); }
+11 -276
View File
@@ -15,21 +15,9 @@ import {
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
import { useClipboard } from "@/hooks/use-clipboard";
import {
Folder,
FolderOpen,
FileText,
FileCode2,
File,
ChevronRight,
Check,
Copy,
Loader2,
} from "lucide-react";
import type { SkillInfo, SkillFile, SkillVersions } from "@/types/skill";
// --- Types ---
import { buildTree } from "./skill-file-helpers";
import { FileBrowser } from "./skill-file-browser";
interface SkillDetailDialogProps {
skill: SkillInfo & { content: string };
@@ -39,220 +27,6 @@ interface SkillDetailDialogProps {
getSkillFileContent: (id: string, path: string, version?: number) => Promise<{ content: string; path: string; size: number }>;
}
interface TreeNode {
name: string;
path: string;
isDir: boolean;
size: number;
children: TreeNode[];
}
// --- Helpers ---
const CODE_EXTENSIONS = new Set([
"ts", "tsx", "js", "jsx", "py", "go", "json", "yaml", "yml", "toml",
"sh", "bash", "css", "html", "sql", "rs", "rb", "java", "kt", "swift",
"c", "cpp", "h", "xml", "graphql", "proto", "lua", "zig", "env",
]);
function extOf(name: string): string {
const dot = name.lastIndexOf(".");
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : "";
}
function langFor(ext: string): string {
const map: Record<string, string> = {
ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx",
py: "python", go: "go", rs: "rust", rb: "ruby", sh: "bash", bash: "bash",
yml: "yaml", yaml: "yaml", json: "json", toml: "toml",
css: "css", html: "html", sql: "sql", xml: "xml",
java: "java", kt: "kotlin", swift: "swift",
c: "c", cpp: "cpp", h: "c", graphql: "graphql",
proto: "protobuf", lua: "lua", zig: "zig",
};
return map[ext] ?? ext;
}
function buildTree(files: SkillFile[]): TreeNode[] {
const root: TreeNode[] = [];
const dirMap = new Map<string, TreeNode>();
// Sort: dirs first, then alphabetically
const sorted = [...files].sort((a, b) => {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
return a.path.localeCompare(b.path);
});
for (const f of sorted) {
const node: TreeNode = {
name: f.name,
path: f.path,
isDir: f.isDir,
size: f.size,
children: [],
};
if (f.isDir) {
dirMap.set(f.path, node);
}
// Find parent dir
const parentPath = f.path.includes("/")
? f.path.slice(0, f.path.lastIndexOf("/"))
: "";
if (parentPath && dirMap.has(parentPath)) {
dirMap.get(parentPath)!.children.push(node);
} else {
root.push(node);
}
}
// Sort children: dirs first, then alphabetically
const sortChildren = (nodes: TreeNode[]) => {
nodes.sort((a, b) => {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
return a.name.localeCompare(b.name);
});
for (const n of nodes) {
if (n.children.length > 0) sortChildren(n.children);
}
};
sortChildren(root);
return root;
}
function stripFrontmatter(content: string): string {
if (!content.startsWith("---")) return content;
const end = content.indexOf("---", 3);
if (end < 0) return content;
return content.slice(end + 3).replace(/^\n+/, "");
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function FileIcon({ name }: { name: string }) {
const ext = extOf(name);
if (ext === "md") return <FileText className="h-4 w-4 shrink-0 text-blue-500" />;
if (CODE_EXTENSIONS.has(ext)) return <FileCode2 className="h-4 w-4 shrink-0 text-orange-500" />;
return <File className="h-4 w-4 shrink-0 text-muted-foreground" />;
}
// --- Components ---
function TreeItem({
node,
depth,
activePath,
onSelect,
}: {
node: TreeNode;
depth: number;
activePath: string | null;
onSelect: (path: string) => void;
}) {
const [expanded, setExpanded] = useState(depth === 0);
const isActive = activePath === node.path;
if (node.isDir) {
return (
<div>
<button
type="button"
className="flex w-full items-center gap-1 rounded px-2 py-1 text-left text-sm hover:bg-accent cursor-pointer"
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => setExpanded(!expanded)}
>
<ChevronRight
className={`h-3 w-3 shrink-0 transition-transform ${expanded ? "rotate-90" : ""}`}
/>
{expanded ? (
<FolderOpen className="h-4 w-4 shrink-0 text-yellow-600" />
) : (
<Folder className="h-4 w-4 shrink-0 text-yellow-600" />
)}
<span className="truncate">{node.name}</span>
</button>
{expanded && node.children.map((child) => (
<TreeItem
key={child.path}
node={child}
depth={depth + 1}
activePath={activePath}
onSelect={onSelect}
/>
))}
</div>
);
}
return (
<button
type="button"
className={`flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-sm cursor-pointer ${
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
}`}
style={{ paddingLeft: `${depth * 16 + 20}px` }}
onClick={() => onSelect(node.path)}
>
<FileIcon name={node.name} />
<span className="truncate">{node.name}</span>
</button>
);
}
function CodeViewer({ content, language }: { content: string; language: string }) {
const { copied, copy } = useClipboard();
return (
<div className="group relative overflow-hidden rounded-md border">
<div className="flex items-center justify-between bg-muted px-3 py-1 text-xs text-muted-foreground">
<span>{language || "text"}</span>
<button
type="button"
onClick={() => copy(content)}
className="cursor-pointer opacity-0 transition-opacity group-hover:opacity-100"
title="Copy"
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
<pre className="overflow-auto bg-muted/50 p-4 text-sm text-foreground max-h-[60vh]">
<code className={language ? `language-${language}` : ""}>{content}</code>
</pre>
</div>
);
}
function FileContentViewer({ path, content, size }: { path: string; content: string; size: number }) {
const ext = extOf(path);
const displayContent = ext === "md" ? stripFrontmatter(content) : content;
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between text-xs text-muted-foreground border-b pb-2">
<span className="font-mono">{path}</span>
<span>{formatSize(size)}</span>
</div>
{ext === "md" ? (
<MarkdownRenderer content={displayContent} />
) : CODE_EXTENSIONS.has(ext) ? (
<CodeViewer content={displayContent} language={langFor(ext)} />
) : (
<pre className="whitespace-pre-wrap rounded-md border bg-muted/30 p-4 text-sm">
{displayContent}
</pre>
)}
</div>
);
}
// --- Main Component ---
export function SkillDetailDialog({
skill,
onClose,
@@ -277,7 +51,6 @@ export function SkillDetailDialog({
const tree = useMemo(() => buildTree(files), [files]);
// Load versions when Files tab is first opened
const loadVersions = useCallback(async () => {
if (!skill.id || versions) return;
const v = await getSkillVersions(skill.id);
@@ -285,7 +58,6 @@ export function SkillDetailDialog({
setSelectedVersion(v.current);
}, [skill.id, versions, getSkillVersions]);
// Load files for selected version
const loadFiles = useCallback(async (version?: number) => {
if (!skill.id) return;
setFilesLoading(true);
@@ -299,7 +71,6 @@ export function SkillDetailDialog({
}
}, [skill.id, getSkillFiles]);
// Load file content
const loadFileContent = useCallback(async (path: string) => {
if (!skill.id) return;
setActivePath(path);
@@ -312,7 +83,6 @@ export function SkillDetailDialog({
}
}, [skill.id, selectedVersion, getSkillFileContent]);
// On version change
useEffect(() => {
if (selectedVersion != null) {
loadFiles(selectedVersion);
@@ -330,7 +100,7 @@ export function SkillDetailDialog({
return (
<Dialog open onOpenChange={() => onClose()}>
<DialogContent className="max-h-[85vh] overflow-hidden flex flex-col sm:max-w-6xl">
<DialogContent className="max-h-[85vh] overflow-hidden flex flex-col sm:max-w-2xl md:max-w-4xl lg:max-w-5xl xl:max-w-6xl 2xl:max-w-7xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 flex-wrap">
{skill.name}
@@ -372,7 +142,6 @@ export function SkillDetailDialog({
{hasFiles && (
<TabsContent value="files" className="flex-1 overflow-hidden flex flex-col mt-2 gap-2">
{/* Version selector */}
{versions && versions.versions.length > 1 && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Version:</span>
@@ -394,48 +163,14 @@ export function SkillDetailDialog({
</div>
)}
{/* File browser */}
<div className="flex-1 flex border rounded-md overflow-hidden min-h-0">
{/* Tree panel */}
<div className="w-[35%] border-r overflow-y-auto bg-muted/20 py-1">
{filesLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : tree.length === 0 ? (
<p className="px-3 py-4 text-sm text-muted-foreground">No files found.</p>
) : (
tree.map((node) => (
<TreeItem
key={node.path}
node={node}
depth={0}
activePath={activePath}
onSelect={loadFileContent}
/>
))
)}
</div>
{/* Content panel */}
<div className="w-[65%] overflow-y-auto p-3">
{contentLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : fileContent ? (
<FileContentViewer
path={fileContent.path}
content={fileContent.content}
size={fileContent.size}
/>
) : (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Select a file to view its content
</div>
)}
</div>
</div>
<FileBrowser
tree={tree}
filesLoading={filesLoading}
activePath={activePath}
onSelect={loadFileContent}
contentLoading={contentLoading}
fileContent={fileContent}
/>
</TabsContent>
)}
</Tabs>
@@ -0,0 +1,124 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { formatSize, type TreeNode } from "./skill-file-helpers";
import { FileTreePanel } from "./skill-file-tree";
import { FileContentPanel } from "./skill-file-viewers";
function useIsMobile(breakpoint = 640) {
const [mobile, setMobile] = useState(window.innerWidth < breakpoint);
useEffect(() => {
const onResize = () => setMobile(window.innerWidth < breakpoint);
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [breakpoint]);
return mobile;
}
export function FileBrowser({
tree,
filesLoading,
activePath,
onSelect,
contentLoading,
fileContent,
}: {
tree: TreeNode[];
filesLoading: boolean;
activePath: string | null;
onSelect: (path: string) => void;
contentLoading: boolean;
fileContent: { content: string; path: string; size: number } | null;
}) {
const isMobile = useIsMobile();
const containerRef = useRef<HTMLDivElement>(null);
const [treeWidth, setTreeWidth] = useState(220);
const [mobileShowTree, setMobileShowTree] = useState(true);
const dragging = useRef(false);
const handleSelect = useCallback((path: string) => {
onSelect(path);
if (isMobile) setMobileShowTree(false);
}, [onSelect, isMobile]);
const onMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
dragging.current = true;
const startX = e.clientX;
const startW = treeWidth;
const onMove = (ev: MouseEvent) => {
if (!dragging.current) return;
const container = containerRef.current;
if (!container) return;
const maxW = container.offsetWidth * 0.5;
const newW = Math.max(140, Math.min(maxW, startW + ev.clientX - startX));
setTreeWidth(newW);
};
const onUp = () => {
dragging.current = false;
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
}, [treeWidth]);
// Mobile: stacked layout — tree first, tap file → content view
if (isMobile) {
return (
<div className="flex-1 flex flex-col border rounded-md overflow-hidden min-h-0">
{mobileShowTree ? (
<div className="flex-1 overflow-y-auto bg-muted/20 py-1">
<FileTreePanel tree={tree} filesLoading={filesLoading} activePath={activePath} onSelect={handleSelect} />
</div>
) : (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<div className="flex items-center gap-2 text-xs text-muted-foreground border-b px-3 py-2 shrink-0">
<button
type="button"
onClick={() => setMobileShowTree(true)}
className="text-primary hover:underline cursor-pointer shrink-0"
>
&larr; Files
</button>
{fileContent && (
<>
<span className="font-mono truncate">{fileContent.path}</span>
<span className="shrink-0 ml-auto">{formatSize(fileContent.size)}</span>
</>
)}
</div>
<div className="flex-1 overflow-auto p-3 min-h-0">
<FileContentPanel fileContent={fileContent} contentLoading={contentLoading} />
</div>
</div>
)}
</div>
);
}
// Desktop: side-by-side with resizable divider
return (
<div ref={containerRef} className="flex-1 flex border rounded-md overflow-hidden min-h-0">
<div className="overflow-y-auto bg-muted/20 py-1 shrink-0" style={{ width: treeWidth }}>
<FileTreePanel tree={tree} filesLoading={filesLoading} activePath={activePath} onSelect={handleSelect} />
</div>
<div
className="w-1 cursor-col-resize bg-border hover:bg-primary/30 active:bg-primary/50 shrink-0"
onMouseDown={onMouseDown}
/>
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
{fileContent && (
<div className="flex items-center justify-between text-xs text-muted-foreground border-b px-3 py-2 shrink-0">
<span className="font-mono truncate">{fileContent.path}</span>
<span className="shrink-0 ml-2">{formatSize(fileContent.size)}</span>
</div>
)}
<div className="flex-1 overflow-auto p-3 min-h-0">
<FileContentPanel fileContent={fileContent} contentLoading={contentLoading} />
</div>
</div>
</div>
);
}
@@ -0,0 +1,92 @@
import type { SkillFile } from "@/types/skill";
export interface TreeNode {
name: string;
path: string;
isDir: boolean;
size: number;
children: TreeNode[];
}
export const CODE_EXTENSIONS = new Set([
"ts", "tsx", "js", "jsx", "py", "go", "json", "yaml", "yml", "toml",
"sh", "bash", "css", "html", "sql", "rs", "rb", "java", "kt", "swift",
"c", "cpp", "h", "xml", "graphql", "proto", "lua", "zig", "env",
]);
export function extOf(name: string): string {
const dot = name.lastIndexOf(".");
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : "";
}
export function langFor(ext: string): string {
const map: Record<string, string> = {
ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx",
py: "python", go: "go", rs: "rust", rb: "ruby", sh: "bash", bash: "bash",
yml: "yaml", yaml: "yaml", json: "json", toml: "toml",
css: "css", html: "html", sql: "sql", xml: "xml",
java: "java", kt: "kotlin", swift: "swift",
c: "c", cpp: "cpp", h: "c", graphql: "graphql",
proto: "protobuf", lua: "lua", zig: "zig",
};
return map[ext] ?? ext;
}
export function buildTree(files: SkillFile[]): TreeNode[] {
const root: TreeNode[] = [];
const dirMap = new Map<string, TreeNode>();
const sorted = [...files].sort((a, b) => {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
return a.path.localeCompare(b.path);
});
for (const f of sorted) {
const node: TreeNode = {
name: f.name,
path: f.path,
isDir: f.isDir,
size: f.size,
children: [],
};
if (f.isDir) {
dirMap.set(f.path, node);
}
const parentPath = f.path.includes("/")
? f.path.slice(0, f.path.lastIndexOf("/"))
: "";
if (parentPath && dirMap.has(parentPath)) {
dirMap.get(parentPath)!.children.push(node);
} else {
root.push(node);
}
}
const sortChildren = (nodes: TreeNode[]) => {
nodes.sort((a, b) => {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
return a.name.localeCompare(b.name);
});
for (const n of nodes) {
if (n.children.length > 0) sortChildren(n.children);
}
};
sortChildren(root);
return root;
}
export function stripFrontmatter(content: string): string {
if (!content.startsWith("---")) return content;
const end = content.indexOf("---", 3);
if (end < 0) return content;
return content.slice(end + 3).replace(/^\n+/, "");
}
export function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
+109
View File
@@ -0,0 +1,109 @@
import { useState } from "react";
import {
Folder,
FolderOpen,
FileText,
FileCode2,
File,
ChevronRight,
Loader2,
} from "lucide-react";
import { extOf, CODE_EXTENSIONS, type TreeNode } from "./skill-file-helpers";
function FileIcon({ name }: { name: string }) {
const ext = extOf(name);
if (ext === "md") return <FileText className="h-4 w-4 shrink-0 text-blue-500" />;
if (CODE_EXTENSIONS.has(ext)) return <FileCode2 className="h-4 w-4 shrink-0 text-orange-500" />;
return <File className="h-4 w-4 shrink-0 text-muted-foreground" />;
}
export function TreeItem({
node,
depth,
activePath,
onSelect,
}: {
node: TreeNode;
depth: number;
activePath: string | null;
onSelect: (path: string) => void;
}) {
const [expanded, setExpanded] = useState(depth === 0);
const isActive = activePath === node.path;
if (node.isDir) {
return (
<div>
<button
type="button"
className="flex w-full items-center gap-1 rounded px-2 py-1 text-left text-sm hover:bg-accent cursor-pointer"
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => setExpanded(!expanded)}
>
<ChevronRight
className={`h-3 w-3 shrink-0 transition-transform ${expanded ? "rotate-90" : ""}`}
/>
{expanded ? (
<FolderOpen className="h-4 w-4 shrink-0 text-yellow-600" />
) : (
<Folder className="h-4 w-4 shrink-0 text-yellow-600" />
)}
<span className="truncate">{node.name}</span>
</button>
{expanded && node.children.map((child) => (
<TreeItem
key={child.path}
node={child}
depth={depth + 1}
activePath={activePath}
onSelect={onSelect}
/>
))}
</div>
);
}
return (
<button
type="button"
className={`flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-sm cursor-pointer ${
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
}`}
style={{ paddingLeft: `${depth * 16 + 20}px` }}
onClick={() => onSelect(node.path)}
>
<FileIcon name={node.name} />
<span className="truncate">{node.name}</span>
</button>
);
}
export function FileTreePanel({
tree,
filesLoading,
activePath,
onSelect,
}: {
tree: TreeNode[];
filesLoading: boolean;
activePath: string | null;
onSelect: (path: string) => void;
}) {
if (filesLoading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
if (tree.length === 0) {
return <p className="px-3 py-4 text-sm text-muted-foreground">No files found.</p>;
}
return (
<>
{tree.map((node) => (
<TreeItem key={node.path} node={node} depth={0} activePath={activePath} onSelect={onSelect} />
))}
</>
);
}
@@ -0,0 +1,180 @@
import { useMemo, useRef } from "react";
import { Check, Copy, Loader2 } from "lucide-react";
import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
import { useClipboard } from "@/hooks/use-clipboard";
import hljs from "highlight.js/lib/core";
import typescript from "highlight.js/lib/languages/typescript";
import javascript from "highlight.js/lib/languages/javascript";
import python from "highlight.js/lib/languages/python";
import go from "highlight.js/lib/languages/go";
import bash from "highlight.js/lib/languages/bash";
import json from "highlight.js/lib/languages/json";
import yaml from "highlight.js/lib/languages/yaml";
import css from "highlight.js/lib/languages/css";
import xml from "highlight.js/lib/languages/xml";
import sql from "highlight.js/lib/languages/sql";
import rust from "highlight.js/lib/languages/rust";
import ruby from "highlight.js/lib/languages/ruby";
import java from "highlight.js/lib/languages/java";
import c from "highlight.js/lib/languages/c";
import cpp from "highlight.js/lib/languages/cpp";
import lua from "highlight.js/lib/languages/lua";
import { extOf, langFor, stripFrontmatter, CODE_EXTENSIONS } from "./skill-file-helpers";
hljs.registerLanguage("typescript", typescript);
hljs.registerLanguage("tsx", typescript);
hljs.registerLanguage("javascript", javascript);
hljs.registerLanguage("jsx", javascript);
hljs.registerLanguage("python", python);
hljs.registerLanguage("go", go);
hljs.registerLanguage("bash", bash);
hljs.registerLanguage("json", json);
hljs.registerLanguage("yaml", yaml);
hljs.registerLanguage("css", css);
hljs.registerLanguage("html", xml);
hljs.registerLanguage("xml", xml);
hljs.registerLanguage("sql", sql);
hljs.registerLanguage("rust", rust);
hljs.registerLanguage("ruby", ruby);
hljs.registerLanguage("java", java);
hljs.registerLanguage("c", c);
hljs.registerLanguage("cpp", cpp);
hljs.registerLanguage("lua", lua);
function CodeViewer({ content, language }: { content: string; language: string }) {
const { copied, copy } = useClipboard();
const codeRef = useRef<HTMLElement>(null);
const highlighted = useMemo(() => {
if (language && hljs.getLanguage(language)) {
try {
return hljs.highlight(content, { language }).value;
} catch { /* fallback */ }
}
return null;
}, [content, language]);
return (
<div className="group relative overflow-hidden rounded-md border">
<div className="flex items-center justify-between bg-muted px-3 py-1 text-xs text-muted-foreground">
<span>{language || "text"}</span>
<button
type="button"
onClick={() => copy(content)}
className="cursor-pointer opacity-0 transition-opacity group-hover:opacity-100"
title="Copy"
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
<pre className="overflow-auto p-4 text-sm hljs">
{highlighted ? (
<code ref={codeRef} dangerouslySetInnerHTML={{ __html: highlighted }} />
) : (
<code>{content}</code>
)}
</pre>
</div>
);
}
function CsvViewer({ content }: { content: string }) {
const { copied, copy } = useClipboard();
const rows = useMemo(() => {
return content.split("\n").filter(Boolean).map((line) => {
const cols: string[] = [];
let cur = "";
let inQuote = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') { inQuote = !inQuote; continue; }
if (ch === "," && !inQuote) { cols.push(cur.trim()); cur = ""; continue; }
cur += ch;
}
cols.push(cur.trim());
return cols;
});
}, [content]);
const header = rows[0];
if (!header || rows.length === 0) return <pre className="text-sm p-4">{content}</pre>;
const body = rows.slice(1);
return (
<div className="group relative rounded-md border flex flex-col overflow-hidden">
<div className="flex items-center justify-between bg-muted px-3 py-1 text-xs text-muted-foreground shrink-0">
<span>csv ({body.length} rows)</span>
<button
type="button"
onClick={() => copy(content)}
className="cursor-pointer opacity-0 transition-opacity group-hover:opacity-100"
title="Copy"
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
<div className="overflow-auto flex-1 min-h-0">
<table className="w-full text-xs border-collapse">
<thead className="sticky top-0 z-10">
<tr className="bg-muted">
{header.map((col, i) => (
<th key={i} className="px-3 py-2 text-left font-medium border-b whitespace-nowrap">
{col}
</th>
))}
</tr>
</thead>
<tbody>
{body.map((row, i) => (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
{header.map((_, j) => (
<td key={j} className="px-3 py-1.5 border-r last:border-r-0">
{row[j] ?? ""}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
export function FileContentBody({ path, content }: { path: string; content: string }) {
const ext = extOf(path);
const displayContent = ext === "md" ? stripFrontmatter(content) : content;
if (ext === "md") return <MarkdownRenderer content={displayContent} />;
if (ext === "csv") return <CsvViewer content={displayContent} />;
if (CODE_EXTENSIONS.has(ext)) return <CodeViewer content={displayContent} language={langFor(ext)} />;
return (
<pre className="whitespace-pre-wrap rounded-md border bg-muted/30 p-4 text-sm">
{displayContent}
</pre>
);
}
export function FileContentPanel({
fileContent,
contentLoading,
}: {
fileContent: { content: string; path: string; size: number } | null;
contentLoading: boolean;
}) {
if (contentLoading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
if (fileContent) {
return <FileContentBody path={fileContent.path} content={fileContent.content} />;
}
return (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Select a file to view its content
</div>
);
}