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 { 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); } /** 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 }; } // ── Tar helpers (reused from CompanySettings) ───────────────────────── function createTarArchive(files: Record, rootPath: string): Uint8Array { const encoder = new TextEncoder(); const chunks: Uint8Array[] = []; for (const [relativePath, contents] of Object.entries(files)) { const tarPath = `${rootPath}/${relativePath}`.replace(/\\/g, "/"); const body = encoder.encode(contents); chunks.push(buildTarHeader(tarPath, body.length)); chunks.push(body); const remainder = body.length % 512; if (remainder > 0) chunks.push(new Uint8Array(512 - remainder)); } chunks.push(new Uint8Array(1024)); const totalLength = chunks.reduce((sum, c) => sum + c.length, 0); const archive = new Uint8Array(totalLength); let offset = 0; for (const chunk of chunks) { archive.set(chunk, offset); offset += chunk.length; } return archive; } function buildTarHeader(pathname: string, size: number): Uint8Array { const header = new Uint8Array(512); writeTarString(header, 0, 100, pathname); writeTarOctal(header, 100, 8, 0o644); writeTarOctal(header, 108, 8, 0); writeTarOctal(header, 116, 8, 0); writeTarOctal(header, 124, 12, size); writeTarOctal(header, 136, 12, Math.floor(Date.now() / 1000)); for (let i = 148; i < 156; i++) header[i] = 32; header[156] = "0".charCodeAt(0); writeTarString(header, 257, 6, "ustar"); writeTarString(header, 263, 2, "00"); const checksum = header.reduce((sum, byte) => sum + byte, 0); writeTarChecksum(header, checksum); return header; } function writeTarString(target: Uint8Array, offset: number, length: number, value: string) { const encoded = new TextEncoder().encode(value); target.set(encoded.slice(0, length), offset); } function writeTarOctal(target: Uint8Array, offset: number, length: number, value: number) { const stringValue = value.toString(8).padStart(length - 1, "0"); writeTarString(target, offset, length - 1, stringValue); target[offset + length - 1] = 0; } function writeTarChecksum(target: Uint8Array, checksum: number) { const stringValue = checksum.toString(8).padStart(6, "0"); writeTarString(target, 148, 6, stringValue); target[154] = 0; target[155] = 32; } function downloadTar(exported: CompanyPortabilityExportResult, selectedFiles: Set) { const filteredFiles: Record = {}; for (const [path, content] of Object.entries(exported.files)) { if (selectedFiles.has(path)) filteredFiles[path] = content; } const tarBytes = createTarArchive(filteredFiles, exported.rootPath); const tarBuffer = new ArrayBuffer(tarBytes.byteLength); new Uint8Array(tarBuffer).set(tarBytes); const blob = new Blob([tarBuffer], { type: "application/x-tar" }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = `${exported.rootPath}.tar`; 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]); 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; downloadTar(exportData, checkedFiles); pushToast({ tone: "success", title: "Export downloaded", body: `${selectedCount} file${selectedCount === 1 ? "" : "s"} exported as ${exportData.rootPath}.tar`, }); } if (!selectedCompanyId) { return ; } if (exportMutation.isPending && !exportData) { return ; } if (!exportData) { return ; } const previewContent = selectedFile ? (exportData.files[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 */}
); }