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 { Download, Github, Link2, Package, Upload, } from "lucide-react"; import { Field } from "../components/agent-config-primitives"; import { type FileTreeNode, type FrontmatterData, buildFileTree, countFiles, collectAllPaths, parseFrontmatter, FRONTMATTER_FIELD_LABELS, PackageFileTree, } from "../components/PackageFileTree"; // ── Import-specific helpers ─────────────────────────────────────────── /** 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-amber-500 border-amber-500/30", overwrite: "text-red-500 border-red-500/30", replace: "text-red-500 border-red-500/30", skip: "text-muted-foreground border-border", none: "text-muted-foreground border-border", }; 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} )}
))}
); } // ── Import file tree customization ─────────────────────────────────── function renderImportFileExtra(node: FileTreeNode, checked: boolean) { if (!node.action) return null; const actionColor = ACTION_COLORS[node.action] ?? ACTION_COLORS.skip; return ( {checked ? node.action : "skip"} ); } function importFileRowClassName(_node: FileTreeNode, checked: boolean) { return !checked ? "opacity-50" : undefined; } // ── 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 + all ancestor dirs of files with conflicts (update action) const am = buildActionMap(result); const tree = buildFileTree(result.files, am); const dirsToExpand = new Set(); for (const node of tree) { if (node.kind === "dir") dirsToExpand.add(node.path); } // Auto-expand directories containing conflicting files so they're visible for (const [filePath, action] of am) { if (action === "update") { const segments = filePath.split("/").filter(Boolean); let current = ""; for (let i = 0; i < segments.length - 1; i++) { current = current ? `${current}/${segments[i]}` : segments[i]; dirsToExpand.add(current); } } } setExpandedDirs(dirsToExpand); // 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 */}
)}
); }