import { useEffect, useMemo, 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, } 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) => { if (a.kind !== b.kind) return a.kind === "dir" ? -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; } // ── 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 (
); })}
); } // ── Preview pane ────────────────────────────────────────────────────── function ExportPreviewPane({ selectedFile, content, }: { selectedFile: string | null; content: string | null; }) { if (!selectedFile || content === null) { return ( ); } const isMarkdown = selectedFile.endsWith(".md"); return (
{selectedFile}
{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()); 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 by default const allFiles = new Set(Object.keys(result.files)); setCheckedFiles(allFiles); // 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 totalFiles = useMemo(() => countFiles(tree), [tree]); const selectedCount = checkedFiles.size; 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 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 {exportData.warnings.length > 0 && ( {exportData.warnings.length} warning{exportData.warnings.length === 1 ? "" : "s"} )}
{/* Warnings */} {exportData.warnings.length > 0 && (
{exportData.warnings.map((w) => (
{w}
))}
)} {/* Two-column layout */}
); }