import type { ReactNode } from "react"; import { cn } from "../lib/utils"; import { ChevronDown, ChevronRight, FileCode2, FileText, Folder, FolderOpen, } from "lucide-react"; // ── Tree types ──────────────────────────────────────────────────────── export type FileTreeNode = { name: string; path: string; kind: "dir" | "file"; children: FileTreeNode[]; /** Optional per-node metadata (e.g. import action) */ action?: string | null; }; const TREE_BASE_INDENT = 16; const TREE_STEP_INDENT = 24; const TREE_ROW_HEIGHT_CLASS = "min-h-9"; // ── Helpers ─────────────────────────────────────────────────────────── export function buildFileTree( files: Record, actionMap?: Map, ): FileTreeNode[] { const root: FileTreeNode = { name: "", path: "", kind: "dir", children: [] }; for (const filePath of Object.keys(files)) { const segments = filePath.split("/").filter(Boolean); let current = root; let currentPath = ""; for (let i = 0; i < segments.length; i++) { const segment = segments[i]; currentPath = currentPath ? `${currentPath}/${segment}` : segment; const isLeaf = i === segments.length - 1; let next = current.children.find((c) => c.name === segment); if (!next) { next = { name: segment, path: currentPath, kind: isLeaf ? "file" : "dir", children: [], action: isLeaf ? (actionMap?.get(filePath) ?? null) : null, }; current.children.push(next); } current = next; } } function sortNode(node: FileTreeNode) { node.children.sort((a, b) => { // Files before directories so PROJECT.md appears above tasks/ if (a.kind !== b.kind) return a.kind === "file" ? -1 : 1; return a.name.localeCompare(b.name); }); node.children.forEach(sortNode); } sortNode(root); return root.children; } export function countFiles(nodes: FileTreeNode[]): number { let count = 0; for (const node of nodes) { if (node.kind === "file") count++; else count += countFiles(node.children); } return count; } export function collectAllPaths( nodes: FileTreeNode[], type: "file" | "dir" | "all" = "all", ): Set { const paths = new Set(); for (const node of nodes) { if (type === "all" || node.kind === type) paths.add(node.path); for (const p of collectAllPaths(node.children, type)) paths.add(p); } return paths; } function fileIcon(name: string) { if (name.endsWith(".yaml") || name.endsWith(".yml")) return FileCode2; return FileText; } // ── Frontmatter helpers ─────────────────────────────────────────────── export type FrontmatterData = Record; export function parseFrontmatter(content: string): { data: FrontmatterData; body: string } | null { const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); if (!match) return null; const data: FrontmatterData = {}; const rawYaml = match[1]; const body = match[2]; let currentKey: string | null = null; let currentList: string[] | null = null; for (const line of rawYaml.split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; if (trimmed.startsWith("- ") && currentKey) { if (!currentList) currentList = []; currentList.push(trimmed.slice(2).trim().replace(/^["']|["']$/g, "")); continue; } if (currentKey && currentList) { data[currentKey] = currentList; currentList = null; currentKey = null; } const kvMatch = trimmed.match(/^([a-zA-Z_][\w-]*)\s*:\s*(.*)$/); if (kvMatch) { const key = kvMatch[1]; const val = kvMatch[2].trim().replace(/^["']|["']$/g, ""); if (val === "null") { currentKey = null; continue; } if (val) { data[key] = val; currentKey = null; } else { currentKey = key; } } } if (currentKey && currentList) { data[currentKey] = currentList; } return Object.keys(data).length > 0 ? { data, body } : null; } export const FRONTMATTER_FIELD_LABELS: Record = { name: "Name", title: "Title", kind: "Kind", reportsTo: "Reports to", skills: "Skills", status: "Status", description: "Description", priority: "Priority", assignee: "Assignee", project: "Project", targetDate: "Target date", }; // ── File tree component ─────────────────────────────────────────────── export function PackageFileTree({ nodes, selectedFile, expandedDirs, checkedFiles, onToggleDir, onSelectFile, onToggleCheck, renderFileExtra, fileRowClassName, showCheckboxes = true, depth = 0, }: { nodes: FileTreeNode[]; selectedFile: string | null; expandedDirs: Set; checkedFiles?: Set; onToggleDir: (path: string) => void; onSelectFile: (path: string) => void; onToggleCheck?: (path: string, kind: "file" | "dir") => void; /** Optional extra content rendered at the end of each file row (e.g. action badge) */ renderFileExtra?: (node: FileTreeNode, checked: boolean) => ReactNode; /** Optional additional className for file rows */ fileRowClassName?: (node: FileTreeNode, checked: boolean) => string | undefined; showCheckboxes?: boolean; depth?: number; }) { const effectiveCheckedFiles = checkedFiles ?? new Set(); return (
{nodes.map((node) => { const expanded = node.kind === "dir" && expandedDirs.has(node.path); if (node.kind === "dir") { const childFiles = collectAllPaths(node.children, "file"); const allChecked = [...childFiles].every((p) => effectiveCheckedFiles.has(p)); const someChecked = [...childFiles].some((p) => effectiveCheckedFiles.has(p)); return (
{showCheckboxes && ( )}
{expanded && ( )}
); } const FileIcon = fileIcon(node.name); const checked = effectiveCheckedFiles.has(node.path); const extraClassName = fileRowClassName?.(node, checked); return (
onSelectFile(node.path)} > {showCheckboxes && ( )} {renderFileExtra?.(node, checked)}
); })}
); }