diff --git a/apps/web/features/skills/components/file-tree.tsx b/apps/web/features/skills/components/file-tree.tsx new file mode 100644 index 00000000..a9576b71 --- /dev/null +++ b/apps/web/features/skills/components/file-tree.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useState } from "react"; +import { + ChevronRight, + ChevronDown, + FileText, + File, + Folder, + FolderOpen, +} from "lucide-react"; +import { cn } from "@/lib/utils"; + +// --------------------------------------------------------------------------- +// Tree data structures +// --------------------------------------------------------------------------- + +interface FileTreeNode { + name: string; + path: string; + isDirectory: boolean; + children: FileTreeNode[]; +} + +function buildTree(filePaths: string[]): FileTreeNode[] { + const root: FileTreeNode[] = []; + + for (const filePath of filePaths) { + const parts = filePath.split("/"); + let current = root; + + for (let i = 0; i < parts.length; i++) { + const name = parts[i]!; + const isLast = i === parts.length - 1; + const path = parts.slice(0, i + 1).join("/"); + + let existing = current.find((n) => n.name === name); + + if (!existing) { + existing = { + name, + path, + isDirectory: !isLast, + children: [], + }; + current.push(existing); + } + + if (!isLast) { + current = existing.children; + } + } + } + + function sortNodes(nodes: FileTreeNode[]): FileTreeNode[] { + nodes.sort((a, b) => { + if (a.path === "SKILL.md") return -1; + if (b.path === "SKILL.md") return 1; + if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1; + return a.name.localeCompare(b.name); + }); + for (const node of nodes) { + if (node.isDirectory) sortNodes(node.children); + } + return nodes; + } + + return sortNodes(root); +} + +function getFileIcon(name: string) { + if (name.endsWith(".md") || name.endsWith(".mdx")) return FileText; + return File; +} + +// --------------------------------------------------------------------------- +// Tree node renderer +// --------------------------------------------------------------------------- + +function TreeNodeItem({ + node, + selectedPath, + onSelect, + depth = 0, +}: { + node: FileTreeNode; + selectedPath: string; + onSelect: (path: string) => void; + depth?: number; +}) { + const [expanded, setExpanded] = useState(true); + const isSelected = node.path === selectedPath; + + if (node.isDirectory) { + const FolderIcon = expanded ? FolderOpen : Folder; + const ChevronIcon = expanded ? ChevronDown : ChevronRight; + + return ( +
+ + {expanded && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+ ); + } + + const Icon = getFileIcon(node.name); + + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Public component +// --------------------------------------------------------------------------- + +export function FileTree({ + filePaths, + selectedPath, + onSelect, +}: { + filePaths: string[]; + selectedPath: string; + onSelect: (path: string) => void; +}) { + const tree = buildTree(filePaths); + + if (tree.length === 0) { + return ( +
+ +

No files

+
+ ); + } + + return ( +
+ {tree.map((node) => ( + + ))} +
+ ); +} diff --git a/apps/web/features/skills/components/file-viewer.tsx b/apps/web/features/skills/components/file-viewer.tsx new file mode 100644 index 00000000..226ad583 --- /dev/null +++ b/apps/web/features/skills/components/file-viewer.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { Pencil, Eye } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; +import { Markdown } from "@/components/markdown/Markdown"; + +function isMarkdown(path: string) { + return path.endsWith(".md") || path.endsWith(".mdx"); +} + +// --------------------------------------------------------------------------- +// YAML frontmatter parsing +// --------------------------------------------------------------------------- + +interface Frontmatter { + [key: string]: string; +} + +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/; + +function parseFrontmatter(raw: string): { + frontmatter: Frontmatter | null; + body: string; +} { + const match = FRONTMATTER_RE.exec(raw); + if (!match) return { frontmatter: null, body: raw }; + + const yamlBlock = match[1]!; + const body = raw.slice(match[0].length); + const frontmatter: Frontmatter = {}; + + for (const line of yamlBlock.split("\n")) { + const idx = line.indexOf(":"); + if (idx === -1) continue; + const key = line.slice(0, idx).trim(); + let value = line.slice(idx + 1).trim(); + // Strip surrounding quotes + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + if (key) frontmatter[key] = value; + } + + return { + frontmatter: Object.keys(frontmatter).length > 0 ? frontmatter : null, + body, + }; +} + +// --------------------------------------------------------------------------- +// Frontmatter display +// --------------------------------------------------------------------------- + +function FrontmatterCard({ data }: { data: Frontmatter }) { + return ( +
+
+ {Object.entries(data).map(([key, value]) => ( +
+ + {key} + + {value} +
+ ))} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// File viewer +// --------------------------------------------------------------------------- + +export function FileViewer({ + path, + content, + onChange, +}: { + path: string; + content: string; + onChange: (content: string) => void; +}) { + const [editing, setEditing] = useState(false); + const isMd = isMarkdown(path); + + const { frontmatter, body } = useMemo( + () => (isMd ? parseFrontmatter(content) : { frontmatter: null, body: content }), + [content, isMd], + ); + + return ( +
+ {/* File header */} +
+ + {path} + +
+ {isMd && ( + + setEditing(!editing)} + className="text-muted-foreground" + > + {editing ? ( + + ) : ( + + )} + + } + /> + + {editing ? "Preview" : "Edit"} + + + )} +
+
+ + {/* File content */} +
+ {isMd && !editing ? ( +
+ {frontmatter && } + + {body || "*No content yet*"} + +
+ ) : ( +