import { useEffect, useMemo, useRef, useState } from "react"; import { useMutation } from "@tanstack/react-query"; import type { CompanyPortabilityExportResult } from "@paperclipai/shared"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useToast } from "../context/ToastContext"; import { companiesApi } from "../api/companies"; import { Button } from "@/components/ui/button"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; 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; } /** 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 { return /(?:^|\/)tasks\//.test(filePath); } /** * Extract the set of agent/project/task slugs that are "checked" based on * which file paths are in the checked set. * agents/{slug}/AGENT.md → agents slug * projects/{slug}/PROJECT.md → projects slug * tasks/{slug}/TASK.md → tasks slug */ function checkedSlugs(checkedFiles: Set): { agents: Set; projects: Set; tasks: Set; } { const agents = new Set(); const projects = new Set(); const tasks = new Set(); for (const p of checkedFiles) { const agentMatch = p.match(/^agents\/([^/]+)\//); if (agentMatch) agents.add(agentMatch[1]); const projectMatch = p.match(/^projects\/([^/]+)\//); if (projectMatch) projects.add(projectMatch[1]); const taskMatch = p.match(/^tasks\/([^/]+)\//); if (taskMatch) tasks.add(taskMatch[1]); } return { agents, projects, tasks }; } /** * Filter .paperclip.yaml content so it only includes entries whose * corresponding files are checked. Works by line-level YAML parsing * since the file has a known, simple structure produced by our own * renderYamlBlock. */ function filterPaperclipYaml(yaml: string, checkedFiles: Set): string { const slugs = checkedSlugs(checkedFiles); const lines = yaml.split("\n"); const out: string[] = []; // Sections whose entries are slug-keyed and should be filtered const filterableSections = new Set(["agents", "projects", "tasks"]); let currentSection: string | null = null; // top-level key (e.g. "agents") let currentEntry: string | null = null; // slug under that section let includeEntry = true; // Collect entries per section so we can omit empty section headers let sectionHeaderLine: string | null = null; let sectionBuffer: string[] = []; function flushSection() { if (sectionHeaderLine !== null && sectionBuffer.length > 0) { out.push(sectionHeaderLine); out.push(...sectionBuffer); } sectionHeaderLine = null; sectionBuffer = []; } for (const line of lines) { // Detect top-level key (no indentation) const topMatch = line.match(/^([a-zA-Z_][\w-]*):\s*(.*)$/); if (topMatch && !line.startsWith(" ")) { // Flush previous section flushSection(); currentEntry = null; includeEntry = true; const key = topMatch[0].split(":")[0]; if (filterableSections.has(key)) { currentSection = key; sectionHeaderLine = line; continue; } else { currentSection = null; out.push(line); continue; } } // Inside a filterable section if (currentSection && filterableSections.has(currentSection)) { // 2-space indented key = entry slug (slugs may start with digits/hyphens) const entryMatch = line.match(/^ ([\w][\w-]*):\s*(.*)$/); if (entryMatch && !line.startsWith(" ")) { const slug = entryMatch[1]; currentEntry = slug; const sectionSlugs = slugs[currentSection as keyof typeof slugs]; includeEntry = sectionSlugs.has(slug); if (includeEntry) sectionBuffer.push(line); continue; } // Deeper indented line belongs to current entry if (currentEntry !== null) { if (includeEntry) sectionBuffer.push(line); continue; } // Shouldn't happen in well-formed output, but pass through sectionBuffer.push(line); continue; } // Outside filterable sections — pass through out.push(line); } // Flush last section flushSection(); return out.join("\n"); } /** Filter tree nodes whose path (or descendant paths) match a search string */ function filterTree(nodes: FileTreeNode[], query: string): FileTreeNode[] { if (!query) return nodes; const lower = query.toLowerCase(); return nodes .map((node) => { if (node.kind === "file") { return node.name.toLowerCase().includes(lower) || node.path.toLowerCase().includes(lower) ? node : null; } const filteredChildren = filterTree(node.children, query); return filteredChildren.length > 0 ? { ...node, children: filteredChildren } : null; }) .filter((n): n is FileTreeNode => n !== null); } /** Collect all ancestor dir paths for files that match a filter */ function collectMatchedParentDirs(nodes: FileTreeNode[], query: string): Set { const dirs = new Set(); const lower = query.toLowerCase(); function walk(node: FileTreeNode, ancestors: string[]) { if (node.kind === "file") { if (node.name.toLowerCase().includes(lower) || node.path.toLowerCase().includes(lower)) { for (const a of ancestors) dirs.add(a); } } else { for (const child of node.children) { walk(child, [...ancestors, node.path]); } } } for (const node of nodes) walk(node, []); return dirs; } /** Sort tree: checked files first, then unchecked */ function sortByChecked(nodes: FileTreeNode[], checkedFiles: Set): FileTreeNode[] { return nodes.map((node) => { if (node.kind === "dir") { return { ...node, children: sortByChecked(node.children, checkedFiles) }; } return node; }).sort((a, b) => { if (a.kind !== b.kind) return a.kind === "file" ? -1 : 1; if (a.kind === "file" && b.kind === "file") { const aChecked = checkedFiles.has(a.path); const bChecked = checkedFiles.has(b.path); if (aChecked !== bChecked) return aChecked ? -1 : 1; } return a.name.localeCompare(b.name); }); } const TASKS_PAGE_SIZE = 10; /** * Paginate children of `tasks/` directories: show up to `limit` entries, * but always include children that are checked or match the search query. * Returns the paginated tree and the total count of task children. */ function paginateTaskNodes( nodes: FileTreeNode[], limit: number, checkedFiles: Set, searchQuery: string, ): { nodes: FileTreeNode[]; totalTaskChildren: number; visibleTaskChildren: number } { let totalTaskChildren = 0; let visibleTaskChildren = 0; const result = nodes.map((node) => { // Only paginate direct children of "tasks" directories if (node.kind === "dir" && node.name === "tasks") { totalTaskChildren = node.children.length; // Partition children: pinned (checked or search-matched) vs rest const pinned: FileTreeNode[] = []; const rest: FileTreeNode[] = []; const lower = searchQuery.toLowerCase(); for (const child of node.children) { const childFiles = collectAllPaths([child], "file"); const isChecked = [...childFiles].some((p) => checkedFiles.has(p)); const isSearchMatch = searchQuery && ( child.name.toLowerCase().includes(lower) || child.path.toLowerCase().includes(lower) || [...childFiles].some((p) => p.toLowerCase().includes(lower)) ); if (isChecked || isSearchMatch) { pinned.push(child); } else { rest.push(child); } } // Show pinned + up to `limit` from rest const remaining = Math.max(0, limit - pinned.length); const visible = [...pinned, ...rest.slice(0, remaining)]; visibleTaskChildren = visible.length; return { ...node, children: visible }; } return node; }); return { nodes: result, totalTaskChildren, visibleTaskChildren }; } function downloadZip( exported: CompanyPortabilityExportResult, selectedFiles: Set, effectiveFiles: Record, ) { const filteredFiles: Record = {}; for (const [path] of Object.entries(exported.files)) { if (selectedFiles.has(path)) filteredFiles[path] = effectiveFiles[path] ?? exported.files[path]; } const zipBytes = createZipArchive(filteredFiles, exported.rootPath); const zipBuffer = new ArrayBuffer(zipBytes.byteLength); new Uint8Array(zipBuffer).set(zipBytes); const blob = new Blob([zipBuffer], { type: "application/zip" }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = `${exported.rootPath}.zip`; document.body.appendChild(anchor); anchor.click(); anchor.remove(); 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", }; function FrontmatterCard({ data, onSkillClick, }: { data: FrontmatterData; onSkillClick?: (skill: string) => void; }) { return (
{Object.entries(data).map(([key, value]) => (
{FRONTMATTER_FIELD_LABELS[key] ?? key}
{Array.isArray(value) ? (
{value.map((item) => ( ))}
) : ( {value} )}
))}
); } // ── Preview pane ────────────────────────────────────────────────────── function ExportPreviewPane({ selectedFile, content, onSkillClick, }: { selectedFile: string | null; content: string | null; onSkillClick?: (skill: string) => void; }) { if (!selectedFile || content === null) { return ( ); } const isMarkdown = selectedFile.endsWith(".md"); const parsed = isMarkdown ? parseFrontmatter(content) : null; return (
{selectedFile}
{parsed ? ( <> {parsed.body.trim() && {parsed.body}} ) : isMarkdown ? ( {content} ) : (
            {content}
          
)}
); } // ── Main page ───────────────────────────────────────────────────────── export function CompanyExport() { const { selectedCompanyId, selectedCompany } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const { pushToast } = useToast(); const [exportData, setExportData] = useState(null); const [selectedFile, setSelectedFile] = useState(null); const [expandedDirs, setExpandedDirs] = useState>(new Set()); const [checkedFiles, setCheckedFiles] = useState>(new Set()); const [treeSearch, setTreeSearch] = useState(""); const [taskLimit, setTaskLimit] = useState(TASKS_PAGE_SIZE); const savedExpandedRef = useRef | null>(null); useEffect(() => { setBreadcrumbs([ { label: "Org Chart", href: "/org" }, { label: "Export" }, ]); }, [setBreadcrumbs]); // Load export data on mount const exportMutation = useMutation({ mutationFn: () => companiesApi.exportBundle(selectedCompanyId!, { include: { company: true, agents: true, projects: true, issues: true }, }), onSuccess: (result) => { setExportData(result); // Check all files EXCEPT tasks by default const checked = new Set(); for (const filePath of Object.keys(result.files)) { if (!isTaskPath(filePath)) checked.add(filePath); } setCheckedFiles(checked); // Expand top-level dirs const tree = buildFileTree(result.files); const topDirs = new Set(); for (const node of tree) { if (node.kind === "dir") topDirs.add(node.path); } setExpandedDirs(topDirs); // Select first file const firstFile = Object.keys(result.files)[0]; if (firstFile) setSelectedFile(firstFile); }, onError: (err) => { pushToast({ tone: "error", title: "Export failed", body: err instanceof Error ? err.message : "Failed to load export data.", }); }, }); useEffect(() => { if (selectedCompanyId && !exportData && !exportMutation.isPending) { exportMutation.mutate(); } // Only run on mount // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedCompanyId]); const tree = useMemo( () => (exportData ? buildFileTree(exportData.files) : []), [exportData], ); const { displayTree, totalTaskChildren, visibleTaskChildren } = useMemo(() => { let result = tree; if (treeSearch) result = filterTree(result, treeSearch); result = sortByChecked(result, checkedFiles); const paginated = paginateTaskNodes(result, taskLimit, checkedFiles, treeSearch); return { displayTree: paginated.nodes, totalTaskChildren: paginated.totalTaskChildren, visibleTaskChildren: paginated.visibleTaskChildren, }; }, [tree, treeSearch, checkedFiles, taskLimit]); // Recompute .paperclip.yaml content whenever checked files change so // the preview & download always reflect the current selection. const effectiveFiles = useMemo(() => { if (!exportData) return {} as Record; const yamlPath = exportData.paperclipExtensionPath; if (!yamlPath || !exportData.files[yamlPath]) return exportData.files; const filtered = { ...exportData.files }; filtered[yamlPath] = filterPaperclipYaml(exportData.files[yamlPath], checkedFiles); return filtered; }, [exportData, checkedFiles]); const totalFiles = useMemo(() => countFiles(tree), [tree]); const selectedCount = checkedFiles.size; // Filter out terminated agent messages — they don't need to be shown const warnings = useMemo(() => { if (!exportData) return [] as string[]; return exportData.warnings.filter((w) => !/terminated agent/i.test(w)); }, [exportData]); function handleToggleDir(path: string) { setExpandedDirs((prev) => { const next = new Set(prev); if (next.has(path)) next.delete(path); else next.add(path); return next; }); } function handleToggleCheck(path: string, kind: "file" | "dir") { if (!exportData) return; setCheckedFiles((prev) => { const next = new Set(prev); if (kind === "file") { if (next.has(path)) next.delete(path); else next.add(path); } else { // Find all child file paths under this dir const dirTree = buildFileTree(exportData.files); const findNode = (nodes: FileTreeNode[], target: string): FileTreeNode | null => { for (const n of nodes) { if (n.path === target) return n; const found = findNode(n.children, target); if (found) return found; } return null; }; const dirNode = findNode(dirTree, path); if (dirNode) { const childFiles = collectAllPaths(dirNode.children, "file"); // Add the dir's own file children for (const child of dirNode.children) { if (child.kind === "file") childFiles.add(child.path); } const allChecked = [...childFiles].every((p) => next.has(p)); for (const f of childFiles) { if (allChecked) next.delete(f); else next.add(f); } } } return next; }); } function handleSearchChange(query: string) { const wasSearching = treeSearch.length > 0; const isSearching = query.length > 0; if (isSearching && !wasSearching) { // Save current expansion state before search savedExpandedRef.current = new Set(expandedDirs); } setTreeSearch(query); if (isSearching) { // Expand all parent dirs of matched files const matchedParents = collectMatchedParentDirs(tree, query); setExpandedDirs((prev) => { const next = new Set(prev); for (const d of matchedParents) next.add(d); return next; }); } else if (wasSearching) { // Restore pre-search expansion state if (savedExpandedRef.current) { setExpandedDirs(savedExpandedRef.current); savedExpandedRef.current = null; } } } function handleSkillClick(skillSlug: string) { if (!exportData) return; // Find the SKILL.md file for this skill slug const skillPath = `skills/${skillSlug}/SKILL.md`; if (!(skillPath in exportData.files)) return; // Select the file and expand parent dirs setSelectedFile(skillPath); setExpandedDirs((prev) => { const next = new Set(prev); next.add("skills"); next.add(`skills/${skillSlug}`); return next; }); } function handleDownload() { if (!exportData) return; downloadZip(exportData, checkedFiles, effectiveFiles); pushToast({ tone: "success", title: "Export downloaded", body: `${selectedCount} file${selectedCount === 1 ? "" : "s"} exported as ${exportData.rootPath}.zip`, }); } if (!selectedCompanyId) { return ; } if (exportMutation.isPending && !exportData) { return ; } if (!exportData) { return ; } const previewContent = selectedFile ? (effectiveFiles[selectedFile] ?? null) : null; return (
{/* Sticky top action bar */}
{selectedCompany?.name ?? "Company"} export {selectedCount} / {totalFiles} file{totalFiles === 1 ? "" : "s"} selected {warnings.length > 0 && ( {warnings.length} warning{warnings.length === 1 ? "" : "s"} )}
{/* Warnings */} {warnings.length > 0 && (
{warnings.map((w) => (
{w}
))}
)} {/* Two-column layout */}
); }