paperclip/ui/src/pages/CompanyExport.tsx
Dotta 8d0581ffb4 refactor: extract shared PackageFileTree component for import/export
Extract duplicated file tree types, helpers (buildFileTree, countFiles,
collectAllPaths, parseFrontmatter), and visual tree component into a
shared PackageFileTree component. Both import and export pages now use
the same underlying tree with consistent alignment and styling.

Import-specific behavior (action badges, unchecked opacity) is handled
via renderFileExtra and fileRowClassName props. Also removes the file
count subtitle from the import sidebar to match the export page.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-16 09:04:22 -05:00

667 lines
23 KiB
TypeScript

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 {
Download,
Package,
Search,
} from "lucide-react";
import {
type FileTreeNode,
type FrontmatterData,
buildFileTree,
countFiles,
collectAllPaths,
parseFrontmatter,
FRONTMATTER_FIELD_LABELS,
PackageFileTree,
} from "../components/PackageFileTree";
/** 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<string>): {
agents: Set<string>;
projects: Set<string>;
tasks: Set<string>;
} {
const agents = new Set<string>();
const projects = new Set<string>();
const tasks = new Set<string>();
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>): 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<string> {
const dirs = new Set<string>();
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<string>): 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<string>,
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<string>,
effectiveFiles: Record<string, string>,
) {
const filteredFiles: Record<string, string> = {};
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);
}
// ── Frontmatter card (export-specific: skill click support) ──────────
function FrontmatterCard({
data,
onSkillClick,
}: {
data: FrontmatterData;
onSkillClick?: (skill: string) => void;
}) {
return (
<div className="rounded-md border border-border bg-accent/20 px-4 py-3 mb-4">
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1.5 text-sm">
{Object.entries(data).map(([key, value]) => (
<div key={key} className="contents">
<dt className="text-muted-foreground whitespace-nowrap py-0.5">
{FRONTMATTER_FIELD_LABELS[key] ?? key}
</dt>
<dd className="py-0.5">
{Array.isArray(value) ? (
<div className="flex flex-wrap gap-1.5">
{value.map((item) => (
<button
key={item}
type="button"
className={cn(
"inline-flex items-center rounded-md border border-border bg-background px-2 py-0.5 text-xs",
key === "skills" && onSkillClick && "cursor-pointer hover:bg-accent/50 hover:border-foreground/30 transition-colors",
)}
onClick={() => key === "skills" && onSkillClick?.(item)}
>
{item}
</button>
))}
</div>
) : (
<span>{value}</span>
)}
</dd>
</div>
))}
</dl>
</div>
);
}
// ── Preview pane ──────────────────────────────────────────────────────
function ExportPreviewPane({
selectedFile,
content,
onSkillClick,
}: {
selectedFile: string | null;
content: string | null;
onSkillClick?: (skill: string) => void;
}) {
if (!selectedFile || content === null) {
return (
<EmptyState icon={Package} message="Select a file to preview its contents." />
);
}
const isMarkdown = selectedFile.endsWith(".md");
const parsed = isMarkdown ? parseFrontmatter(content) : null;
return (
<div className="min-w-0">
<div className="border-b border-border px-5 py-3">
<div className="truncate font-mono text-sm">{selectedFile}</div>
</div>
<div className="min-h-[560px] px-5 py-5">
{parsed ? (
<>
<FrontmatterCard data={parsed.data} onSkillClick={onSkillClick} />
{parsed.body.trim() && <MarkdownBody>{parsed.body}</MarkdownBody>}
</>
) : isMarkdown ? (
<MarkdownBody>{content}</MarkdownBody>
) : (
<pre className="overflow-x-auto whitespace-pre-wrap break-words border-0 bg-transparent p-0 font-mono text-sm text-foreground">
<code>{content}</code>
</pre>
)}
</div>
</div>
);
}
// ── Main page ─────────────────────────────────────────────────────────
export function CompanyExport() {
const { selectedCompanyId, selectedCompany } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToast();
const [exportData, setExportData] = useState<CompanyPortabilityExportResult | null>(null);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
const [checkedFiles, setCheckedFiles] = useState<Set<string>>(new Set());
const [treeSearch, setTreeSearch] = useState("");
const [taskLimit, setTaskLimit] = useState(TASKS_PAGE_SIZE);
const savedExpandedRef = useRef<Set<string> | 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<string>();
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<string>();
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<string, string>;
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 <EmptyState icon={Package} message="Select a company to export." />;
}
if (exportMutation.isPending && !exportData) {
return <PageSkeleton variant="detail" />;
}
if (!exportData) {
return <EmptyState icon={Package} message="Loading export data..." />;
}
const previewContent = selectedFile ? (effectiveFiles[selectedFile] ?? null) : null;
return (
<div>
{/* Sticky top action bar */}
<div className="sticky top-0 z-10 border-b border-border bg-background px-5 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-4 text-sm">
<span className="font-medium">
{selectedCompany?.name ?? "Company"} export
</span>
<span className="text-muted-foreground">
{selectedCount} / {totalFiles} file{totalFiles === 1 ? "" : "s"} selected
</span>
{warnings.length > 0 && (
<span className="text-amber-500">
{warnings.length} warning{warnings.length === 1 ? "" : "s"}
</span>
)}
</div>
<Button
size="sm"
onClick={handleDownload}
disabled={selectedCount === 0}
>
<Download className="mr-1.5 h-3.5 w-3.5" />
Export {selectedCount} file{selectedCount === 1 ? "" : "s"}
</Button>
</div>
</div>
{/* Warnings */}
{warnings.length > 0 && (
<div className="mx-5 mt-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3">
{warnings.map((w) => (
<div key={w} className="text-xs text-amber-500">{w}</div>
))}
</div>
)}
{/* Two-column layout */}
<div className="grid h-[calc(100vh-12rem)] gap-0 xl:grid-cols-[19rem_minmax(0,1fr)]">
<aside className="flex flex-col border-r border-border overflow-hidden">
<div className="border-b border-border px-4 py-3 shrink-0">
<h2 className="text-base font-semibold">Package files</h2>
</div>
<div className="border-b border-border px-3 py-2 shrink-0">
<div className="flex items-center gap-2 rounded-md border border-border px-2 py-1">
<Search className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<input
type="text"
value={treeSearch}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder="Search files..."
className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<PackageFileTree
nodes={displayTree}
selectedFile={selectedFile}
expandedDirs={expandedDirs}
checkedFiles={checkedFiles}
onToggleDir={handleToggleDir}
onSelectFile={setSelectedFile}
onToggleCheck={handleToggleCheck}
/>
{totalTaskChildren > visibleTaskChildren && !treeSearch && (
<div className="px-4 py-2">
<button
type="button"
onClick={() => setTaskLimit((prev) => prev + TASKS_PAGE_SIZE)}
className="w-full rounded-md border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent/30 hover:text-foreground transition-colors"
>
Show more issues ({visibleTaskChildren} of {totalTaskChildren})
</button>
</div>
)}
</div>
</aside>
<div className="min-w-0 overflow-y-auto pl-6">
<ExportPreviewPane selectedFile={selectedFile} content={previewContent} onSkillClick={handleSkillClick} />
</div>
</div>
</div>
);
}