From 3c31e379a1dd6d04ac2748ff102b7df427f4f896 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 16 Mar 2026 08:33:17 -0500 Subject: [PATCH] fix: keep .paperclip.yaml in sync with export file selections When users check/uncheck files in the export preview, the .paperclip.yaml now dynamically filters its agents/projects/tasks sections to only include entries whose corresponding files are checked. This applies to both the preview pane and the downloaded tar archive. Co-Authored-By: Paperclip --- ui/src/pages/CompanyExport.tsx | 136 +++++++++++++++++++++++++++++++-- 1 file changed, 131 insertions(+), 5 deletions(-) diff --git a/ui/src/pages/CompanyExport.tsx b/ui/src/pages/CompanyExport.tsx index 416ea1f3..4fb281cf 100644 --- a/ui/src/pages/CompanyExport.tsx +++ b/ui/src/pages/CompanyExport.tsx @@ -106,6 +106,117 @@ 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): { + agents: Set; + projects: Set; + tasks: Set; +} { + const agents = new Set(); + const projects = new Set(); + const tasks = new Set(); + 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 { + 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; @@ -277,10 +388,14 @@ function writeTarChecksum(target: Uint8Array, checksum: number) { target[155] = 32; } -function downloadTar(exported: CompanyPortabilityExportResult, selectedFiles: Set) { +function downloadTar( + exported: CompanyPortabilityExportResult, + selectedFiles: Set, + effectiveFiles: Record, +) { const filteredFiles: Record = {}; - for (const [path, content] of Object.entries(exported.files)) { - if (selectedFiles.has(path)) filteredFiles[path] = content; + for (const [path] of Object.entries(exported.files)) { + if (selectedFiles.has(path)) filteredFiles[path] = effectiveFiles[path] ?? exported.files[path]; } const tarBytes = createTarArchive(filteredFiles, exported.rootPath); const tarBuffer = new ArrayBuffer(tarBytes.byteLength); @@ -668,6 +783,17 @@ export function CompanyExport() { }; }, [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; + 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; @@ -767,7 +893,7 @@ export function CompanyExport() { function handleDownload() { if (!exportData) return; - downloadTar(exportData, checkedFiles); + downloadTar(exportData, checkedFiles, effectiveFiles); pushToast({ tone: "success", title: "Export downloaded", @@ -787,7 +913,7 @@ export function CompanyExport() { return ; } - const previewContent = selectedFile ? (exportData.files[selectedFile] ?? null) : null; + const previewContent = selectedFile ? (effectiveFiles[selectedFile] ?? null) : null; return (