import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { CompanyPortabilityCollisionStrategy, CompanyPortabilityPreviewResult, CompanyPortabilitySource, } from "@paperclipai/shared"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useToast } from "../context/ToastContext"; import { companiesApi } from "../api/companies"; import { queryKeys } from "../lib/queryKeys"; import { MarkdownBody } from "../components/MarkdownBody"; import { Button } from "@/components/ui/button"; import { EmptyState } from "../components/EmptyState"; import { cn } from "../lib/utils"; import { ChevronDown, ChevronRight, Download, FileCode2, FileText, Folder, FolderOpen, Github, Link2, Package, Upload, } from "lucide-react"; import { Field } from "../components/agent-config-primitives"; // ── Tree types ──────────────────────────────────────────────────────── type FileTreeNode = { name: string; path: string; kind: "dir" | "file"; children: FileTreeNode[]; action?: string | null; }; const TREE_BASE_INDENT = 16; const TREE_STEP_INDENT = 24; const TREE_ROW_HEIGHT_CLASS = "min-h-9"; // ── Tree helpers ────────────────────────────────────────────────────── 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) => { 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; } /** Build a map from file path → planned action (create/update/skip) using the manifest + plan */ function buildActionMap(preview: CompanyPortabilityPreviewResult): Map { const map = new Map(); const manifest = preview.manifest; for (const ap of preview.plan.agentPlans) { const agent = manifest.agents.find((a) => a.slug === ap.slug); if (agent) { const path = ensureMarkdownPath(agent.path); map.set(path, ap.action); } } for (const pp of preview.plan.projectPlans) { const project = manifest.projects.find((p) => p.slug === pp.slug); if (project) { const path = ensureMarkdownPath(project.path); map.set(path, pp.action); } } for (const ip of preview.plan.issuePlans) { const issue = manifest.issues.find((i) => i.slug === ip.slug); if (issue) { const path = ensureMarkdownPath(issue.path); map.set(path, ip.action); } } for (const skill of manifest.skills) { const path = ensureMarkdownPath(skill.path); map.set(path, "create"); // Also mark skill file inventory for (const file of skill.fileInventory) { if (preview.files[file.path]) { map.set(file.path, "create"); } } } // Company file if (manifest.company) { const path = ensureMarkdownPath(manifest.company.path); map.set(path, preview.plan.companyAction === "none" ? "skip" : preview.plan.companyAction); } return map; } function ensureMarkdownPath(p: string): string { return p.endsWith(".md") ? p : `${p}.md`; } const ACTION_COLORS: Record = { create: "text-emerald-500 border-emerald-500/30", update: "text-blue-500 border-blue-500/30", skip: "text-muted-foreground border-border", 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 (
{Object.entries(data).map(([key, value]) => (
{FRONTMATTER_FIELD_LABELS[key] ?? key}
{Array.isArray(value) ? (
{value.map((item) => ( {item} ))}
) : ( {value} )}
))}
); } // ── File tree component ─────────────────────────────────────────────── 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; }) { 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"} )}
); })}
); } // ── Preview pane ────────────────────────────────────────────────────── function ImportPreviewPane({ selectedFile, content, action, }: { selectedFile: string | null; content: string | null; action: string | null; }) { if (!selectedFile || content === null) { return ( ); } const isMarkdown = selectedFile.endsWith(".md"); const parsed = isMarkdown ? parseFrontmatter(content) : null; const actionColor = action ? (ACTION_COLORS[action] ?? ACTION_COLORS.skip) : ""; return (
{selectedFile}
{action && ( {action} )}
{parsed ? ( <> {parsed.body.trim() && {parsed.body}} ) : isMarkdown ? ( {content} ) : (
            {content}
          
)}
); } // ── Helpers ─────────────────────────────────────────────────────────── async function readLocalPackageSelection(fileList: FileList): Promise<{ rootPath: string | null; files: Record; }> { const files: Record = {}; let rootPath: string | null = null; for (const file of Array.from(fileList)) { const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath?.replace( /\\/g, "/", ) || file.name; const isMarkdown = relativePath.endsWith(".md"); const isPaperclipYaml = relativePath.endsWith(".paperclip.yaml") || relativePath.endsWith(".paperclip.yml"); if (!isMarkdown && !isPaperclipYaml) continue; const topLevel = relativePath.split("/")[0] ?? null; if (!rootPath && topLevel) rootPath = topLevel; files[relativePath] = await file.text(); } if (Object.keys(files).length === 0) { throw new Error("No package files were found in the selected folder."); } return { rootPath, files }; } // ── Main page ───────────────────────────────────────────────────────── export function CompanyImport() { const { selectedCompanyId, selectedCompany, setSelectedCompanyId, } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const { pushToast } = useToast(); const queryClient = useQueryClient(); const packageInputRef = useRef(null); // Source state const [sourceMode, setSourceMode] = useState<"github" | "url" | "local">("github"); const [importUrl, setImportUrl] = useState(""); const [localPackage, setLocalPackage] = useState<{ rootPath: string | null; files: Record; } | null>(null); // Target state const [targetMode, setTargetMode] = useState<"existing" | "new">("existing"); const [collisionStrategy, setCollisionStrategy] = useState("rename"); const [newCompanyName, setNewCompanyName] = useState(""); // Preview state const [importPreview, setImportPreview] = 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: "Import" }, ]); }, [setBreadcrumbs]); function buildSource(): CompanyPortabilitySource | null { if (sourceMode === "local") { if (!localPackage) return null; return { type: "inline", rootPath: localPackage.rootPath, files: localPackage.files }; } const url = importUrl.trim(); if (!url) return null; if (sourceMode === "github") return { type: "github", url }; return { type: "url", url }; } // Preview mutation const previewMutation = useMutation({ mutationFn: () => { const source = buildSource(); if (!source) throw new Error("No source configured."); return companiesApi.importPreview({ source, include: { company: true, agents: true, projects: true, issues: true }, target: targetMode === "new" ? { mode: "new_company", newCompanyName: newCompanyName || null } : { mode: "existing_company", companyId: selectedCompanyId! }, collisionStrategy, }); }, onSuccess: (result) => { setImportPreview(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, buildActionMap(result)); 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: "Preview failed", body: err instanceof Error ? err.message : "Failed to preview import.", }); }, }); // Apply mutation const importMutation = useMutation({ mutationFn: () => { const source = buildSource(); if (!source) throw new Error("No source configured."); return companiesApi.importBundle({ source, include: { company: true, agents: true, projects: true, issues: true }, target: targetMode === "new" ? { mode: "new_company", newCompanyName: newCompanyName || null } : { mode: "existing_company", companyId: selectedCompanyId! }, collisionStrategy, }); }, onSuccess: async (result) => { await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); if (result.company.action === "created") { setSelectedCompanyId(result.company.id); } pushToast({ tone: "success", title: "Import complete", body: `${result.company.name}: ${result.agents.length} agent${result.agents.length === 1 ? "" : "s"} processed.`, }); // Reset setImportPreview(null); setLocalPackage(null); setImportUrl(""); }, onError: (err) => { pushToast({ tone: "error", title: "Import failed", body: err instanceof Error ? err.message : "Failed to apply import.", }); }, }); async function handleChooseLocalPackage(e: ChangeEvent) { const fileList = e.target.files; if (!fileList || fileList.length === 0) return; try { const pkg = await readLocalPackageSelection(fileList); setLocalPackage(pkg); setImportPreview(null); } catch (err) { pushToast({ tone: "error", title: "Package read failed", body: err instanceof Error ? err.message : "Failed to read folder.", }); } } const actionMap = useMemo( () => (importPreview ? buildActionMap(importPreview) : new Map()), [importPreview], ); const tree = useMemo( () => (importPreview ? buildFileTree(importPreview.files, actionMap) : []), [importPreview, actionMap], ); 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 (!importPreview) return; setCheckedFiles((prev) => { const next = new Set(prev); if (kind === "file") { if (next.has(path)) next.delete(path); else next.add(path); } else { 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(tree, path); if (dirNode) { const childFiles = collectAllPaths(dirNode.children, "file"); 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; }); } const hasSource = sourceMode === "local" ? !!localPackage : importUrl.trim().length > 0; const hasErrors = importPreview ? importPreview.errors.length > 0 : false; const previewContent = selectedFile && importPreview ? (importPreview.files[selectedFile] ?? null) : null; const selectedAction = selectedFile ? (actionMap.get(selectedFile) ?? null) : null; if (!selectedCompanyId) { return ; } return (
{/* Source form section */}

Import source

Choose a GitHub repo, direct URL, or local folder to import from.

{( [ { key: "github", icon: Github, label: "GitHub repo" }, { key: "url", icon: Link2, label: "Direct URL" }, { key: "local", icon: Upload, label: "Local folder" }, ] as const ).map(({ key, icon: Icon, label }) => ( ))}
{sourceMode === "local" ? (
{localPackage && ( {localPackage.rootPath ?? "package"} with{" "} {Object.keys(localPackage.files).length} file {Object.keys(localPackage.files).length === 1 ? "" : "s"} )}
{!localPackage && (

Select a folder that contains COMPANY.md and any referenced AGENTS.md files.

)}
) : ( { setImportUrl(e.target.value); setImportPreview(null); }} /> )}
{targetMode === "existing" && ( )}
{targetMode === "new" && ( setNewCompanyName(e.target.value)} placeholder="Imported Company" /> )}
{/* Preview results */} {importPreview && ( <> {/* Sticky import action bar */}
Import preview {selectedCount} / {totalFiles} file{totalFiles === 1 ? "" : "s"} selected {importPreview.warnings.length > 0 && ( {importPreview.warnings.length} warning{importPreview.warnings.length === 1 ? "" : "s"} )} {importPreview.errors.length > 0 && ( {importPreview.errors.length} error{importPreview.errors.length === 1 ? "" : "s"} )}
{/* Warnings */} {importPreview.warnings.length > 0 && (
{importPreview.warnings.map((w) => (
{w}
))}
)} {/* Errors */} {importPreview.errors.length > 0 && (
{importPreview.errors.map((e) => (
{e}
))}
)} {/* Two-column layout */}
)}
); }