diff --git a/ui/src/components/PackageFileTree.tsx b/ui/src/components/PackageFileTree.tsx new file mode 100644 index 00000000..0a449022 --- /dev/null +++ b/ui/src/components/PackageFileTree.tsx @@ -0,0 +1,305 @@ +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, + 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; + depth?: number; +}) { + 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) => checkedFiles.has(p)); + const someChecked = [...childFiles].some((p) => checkedFiles.has(p)); + return ( +
+
+ + + +
+ {expanded && ( + + )} +
+ ); + } + + const FileIcon = fileIcon(node.name); + const checked = checkedFiles.has(node.path); + const extraClassName = fileRowClassName?.(node, checked); + return ( +
+ + + {renderFileExtra?.(node, checked)} +
+ ); + })} +
+ ); +} diff --git a/ui/src/pages/CompanyExport.tsx b/ui/src/pages/CompanyExport.tsx index 555d5335..c6da8ae1 100644 --- a/ui/src/pages/CompanyExport.tsx +++ b/ui/src/pages/CompanyExport.tsx @@ -12,95 +12,20 @@ import { MarkdownBody } from "../components/MarkdownBody"; import { cn } from "../lib/utils"; import { createZipArchive } from "../lib/zip"; import { - ChevronDown, - ChevronRight, Download, - FileCode2, - FileText, - Folder, - FolderOpen, Package, Search, } from "lucide-react"; - -// ── Tree types ──────────────────────────────────────────────────────── - -type FileTreeNode = { - name: string; - path: string; - kind: "dir" | "file"; - children: FileTreeNode[]; -}; - -const TREE_BASE_INDENT = 16; -const TREE_STEP_INDENT = 24; -const TREE_ROW_HEIGHT_CLASS = "min-h-9"; - -// ── Helpers ─────────────────────────────────────────────────────────── - -function buildFileTree(files: Record): 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: [], - }; - 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; -} - -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; -} - -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; -} +import { + type FileTreeNode, + type FrontmatterData, + buildFileTree, + countFiles, + collectAllPaths, + parseFrontmatter, + FRONTMATTER_FIELD_LABELS, + PackageFileTree, +} from "../components/PackageFileTree"; /** Returns true if the path looks like a task file (e.g. tasks/slug/TASK.md or projects/x/tasks/slug/TASK.md) */ function isTaskPath(filePath: string): boolean { @@ -353,210 +278,7 @@ function downloadZip( window.setTimeout(() => URL.revokeObjectURL(url), 1000); } -// ── File tree component ─────────────────────────────────────────────── - -function ExportFileTree({ - nodes, - selectedFile, - expandedDirs, - checkedFiles, - onToggleDir, - onSelectFile, - onToggleCheck, - 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; - depth?: number; -}) { - 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) => checkedFiles.has(p)); - const someChecked = [...childFiles].some((p) => checkedFiles.has(p)); - return ( -
-
- - - -
- {expanded && ( - - )} -
- ); - } - - const FileIcon = fileIcon(node.name); - const checked = checkedFiles.has(node.path); - return ( -
- - -
- ); - })} -
- ); -} - -// ── Frontmatter helpers ─────────────────────────────────────────────── - -type FrontmatterData = Record; - -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; - - // List item under current key - if (trimmed.startsWith("- ") && currentKey) { - if (!currentList) currentList = []; - currentList.push(trimmed.slice(2).trim().replace(/^["']|["']$/g, "")); - continue; - } - - // Flush previous list - 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, ""); - // Skip null values - if (val === "null") { - currentKey = null; - continue; - } - if (val) { - data[key] = val; - currentKey = null; - } else { - currentKey = key; - } - } - } - - // Flush trailing list - if (currentKey && currentList) { - data[currentKey] = currentList; - } - - return Object.keys(data).length > 0 ? { data, body } : null; -} - -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", -}; +// ── Frontmatter card (export-specific: skill click support) ────────── function FrontmatterCard({ data, @@ -914,7 +636,7 @@ export function CompanyExport() {
- , 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) => { - 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; -} - -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; -} - -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; -} +// ── Import-specific helpers ─────────────────────────────────────────── /** Build a map from file path → planned action (create/update/skip) using the manifest + plan */ function buildActionMap(preview: CompanyPortabilityPreviewResult): Map { @@ -171,75 +97,6 @@ const ACTION_COLORS: Record = { none: "text-muted-foreground border-border", }; -// ── Frontmatter helpers ─────────────────────────────────────────────── - -type FrontmatterData = Record; - -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; -} - -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", -}; - function FrontmatterCard({ data }: { data: FrontmatterData }) { return (
@@ -272,146 +129,25 @@ function FrontmatterCard({ data }: { data: FrontmatterData }) { ); } -// ── File tree component ─────────────────────────────────────────────── +// ── Import file tree customization ─────────────────────────────────── -function ImportFileTree({ - nodes, - selectedFile, - expandedDirs, - checkedFiles, - onToggleDir, - onSelectFile, - onToggleCheck, - 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; - depth?: number; -}) { +function renderImportFileExtra(node: FileTreeNode, checked: boolean) { + if (!node.action) return null; + const actionColor = ACTION_COLORS[node.action] ?? ACTION_COLORS.skip; 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) => checkedFiles.has(p)); - const someChecked = [...childFiles].some((p) => checkedFiles.has(p)); - return ( -
-
- - - -
- {expanded && ( - - )} -
- ); - } - - const FileIcon = fileIcon(node.name); - const checked = checkedFiles.has(node.path); - const actionColor = node.action ? (ACTION_COLORS[node.action] ?? ACTION_COLORS.skip) : ""; - return ( -
- - - {node.action && ( - - {checked ? node.action : "skip"} - - )} -
- ); - })} -
+ + {checked ? node.action : "skip"} + ); } +function importFileRowClassName(_node: FileTreeNode, checked: boolean) { + return !checked ? "opacity-50" : undefined; +} + // ── Preview pane ────────────────────────────────────────────────────── function ImportPreviewPane({ @@ -942,16 +678,9 @@ export function CompanyImport() {