diff --git a/ui/web/package.json b/ui/web/package.json index c35501ee..7a587909 100644 --- a/ui/web/package.json +++ b/ui/web/package.json @@ -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", diff --git a/ui/web/pnpm-lock.yaml b/ui/web/pnpm-lock.yaml index e846a787..b311bfee 100644 --- a/ui/web/pnpm-lock.yaml +++ b/ui/web/pnpm-lock.yaml @@ -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) diff --git a/ui/web/src/index.css b/ui/web/src/index.css index 2ec90831..f3b9d750 100644 --- a/ui/web/src/index.css +++ b/ui/web/src/index.css @@ -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); } diff --git a/ui/web/src/pages/skills/skill-detail-dialog.tsx b/ui/web/src/pages/skills/skill-detail-dialog.tsx index c1d9227c..5246a881 100644 --- a/ui/web/src/pages/skills/skill-detail-dialog.tsx +++ b/ui/web/src/pages/skills/skill-detail-dialog.tsx @@ -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 = { - 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(); - - // 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 ; - if (CODE_EXTENSIONS.has(ext)) return ; - return ; -} - -// --- 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 ( -
- - {expanded && node.children.map((child) => ( - - ))} -
- ); - } - - return ( - - ); -} - -function CodeViewer({ content, language }: { content: string; language: string }) { - const { copied, copy } = useClipboard(); - - return ( -
-
- {language || "text"} - -
-
-        {content}
-      
-
- ); -} - -function FileContentViewer({ path, content, size }: { path: string; content: string; size: number }) { - const ext = extOf(path); - const displayContent = ext === "md" ? stripFrontmatter(content) : content; - - return ( -
-
- {path} - {formatSize(size)} -
- {ext === "md" ? ( - - ) : CODE_EXTENSIONS.has(ext) ? ( - - ) : ( -
-          {displayContent}
-        
- )} -
- ); -} - -// --- 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 ( onClose()}> - + {skill.name} @@ -372,7 +142,6 @@ export function SkillDetailDialog({ {hasFiles && ( - {/* Version selector */} {versions && versions.versions.length > 1 && (
Version: @@ -394,48 +163,14 @@ export function SkillDetailDialog({
)} - {/* File browser */} -
- {/* Tree panel */} -
- {filesLoading ? ( -
- -
- ) : tree.length === 0 ? ( -

No files found.

- ) : ( - tree.map((node) => ( - - )) - )} -
- - {/* Content panel */} -
- {contentLoading ? ( -
- -
- ) : fileContent ? ( - - ) : ( -
- Select a file to view its content -
- )} -
-
+
)} diff --git a/ui/web/src/pages/skills/skill-file-browser.tsx b/ui/web/src/pages/skills/skill-file-browser.tsx new file mode 100644 index 00000000..ff6cc212 --- /dev/null +++ b/ui/web/src/pages/skills/skill-file-browser.tsx @@ -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(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 ( +
+ {mobileShowTree ? ( +
+ +
+ ) : ( +
+
+ + {fileContent && ( + <> + {fileContent.path} + {formatSize(fileContent.size)} + + )} +
+
+ +
+
+ )} +
+ ); + } + + // Desktop: side-by-side with resizable divider + return ( +
+
+ +
+ +
+ +
+ {fileContent && ( +
+ {fileContent.path} + {formatSize(fileContent.size)} +
+ )} +
+ +
+
+
+ ); +} diff --git a/ui/web/src/pages/skills/skill-file-helpers.ts b/ui/web/src/pages/skills/skill-file-helpers.ts new file mode 100644 index 00000000..7232ee3c --- /dev/null +++ b/ui/web/src/pages/skills/skill-file-helpers.ts @@ -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 = { + 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(); + + 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`; +} diff --git a/ui/web/src/pages/skills/skill-file-tree.tsx b/ui/web/src/pages/skills/skill-file-tree.tsx new file mode 100644 index 00000000..0ec3546f --- /dev/null +++ b/ui/web/src/pages/skills/skill-file-tree.tsx @@ -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 ; + if (CODE_EXTENSIONS.has(ext)) return ; + return ; +} + +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 ( +
+ + {expanded && node.children.map((child) => ( + + ))} +
+ ); + } + + return ( + + ); +} + +export function FileTreePanel({ + tree, + filesLoading, + activePath, + onSelect, +}: { + tree: TreeNode[]; + filesLoading: boolean; + activePath: string | null; + onSelect: (path: string) => void; +}) { + if (filesLoading) { + return ( +
+ +
+ ); + } + if (tree.length === 0) { + return

No files found.

; + } + return ( + <> + {tree.map((node) => ( + + ))} + + ); +} diff --git a/ui/web/src/pages/skills/skill-file-viewers.tsx b/ui/web/src/pages/skills/skill-file-viewers.tsx new file mode 100644 index 00000000..00988ccf --- /dev/null +++ b/ui/web/src/pages/skills/skill-file-viewers.tsx @@ -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(null); + + const highlighted = useMemo(() => { + if (language && hljs.getLanguage(language)) { + try { + return hljs.highlight(content, { language }).value; + } catch { /* fallback */ } + } + return null; + }, [content, language]); + + return ( +
+
+ {language || "text"} + +
+
+        {highlighted ? (
+          
+        ) : (
+          {content}
+        )}
+      
+
+ ); +} + +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
{content}
; + const body = rows.slice(1); + + return ( +
+
+ csv ({body.length} rows) + +
+
+ + + + {header.map((col, i) => ( + + ))} + + + + {body.map((row, i) => ( + + {header.map((_, j) => ( + + ))} + + ))} + +
+ {col} +
+ {row[j] ?? ""} +
+
+
+ ); +} + +export function FileContentBody({ path, content }: { path: string; content: string }) { + const ext = extOf(path); + const displayContent = ext === "md" ? stripFrontmatter(content) : content; + + if (ext === "md") return ; + if (ext === "csv") return ; + if (CODE_EXTENSIONS.has(ext)) return ; + return ( +
+      {displayContent}
+    
+ ); +} + +export function FileContentPanel({ + fileContent, + contentLoading, +}: { + fileContent: { content: string; path: string; size: number } | null; + contentLoading: boolean; +}) { + if (contentLoading) { + return ( +
+ +
+ ); + } + if (fileContent) { + return ; + } + return ( +
+ Select a file to view its content +
+ ); +}