From 2f7da835de540cd747908aad185b378e74e35e78 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 15 Mar 2026 15:00:32 -0500 Subject: [PATCH] Redesign import page: file-browser UX with rich preview - Add `files` and `manifest` to CompanyPortabilityPreviewResult so the import UI can show actual file contents and metadata - Rewrite import preview as a file/folder tree (matching export page design language) with per-file checkboxes to include/exclude items - Show action badges (create/update/skip) on each file based on the import plan, with unchecked files dimmed and badged as "skip" - Add rich frontmatter preview: clicking a file shows parsed frontmatter as structured data (name, title, reportsTo, skills) plus markdown body - Include skills count in the sidebar summary - Update import button to show dynamic file count that updates on check/uncheck - Both /tree/ and /blob/ GitHub URLs already supported by backend Co-Authored-By: Paperclip --- .../shared/src/types/company-portability.ts | 2 + server/src/services/company-portability.ts | 2 + ui/src/pages/CompanyImport.tsx | 663 ++++++++++++------ 3 files changed, 463 insertions(+), 204 deletions(-) diff --git a/packages/shared/src/types/company-portability.ts b/packages/shared/src/types/company-portability.ts index 97318877..9e699b6a 100644 --- a/packages/shared/src/types/company-portability.ts +++ b/packages/shared/src/types/company-portability.ts @@ -186,6 +186,8 @@ export interface CompanyPortabilityPreviewResult { projectPlans: CompanyPortabilityPreviewProjectPlan[]; issuePlans: CompanyPortabilityPreviewIssuePlan[]; }; + manifest: CompanyPortabilityManifest; + files: Record; envInputs: CompanyPortabilityEnvInput[]; warnings: string[]; errors: string[]; diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 44a409ee..68487364 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -2064,6 +2064,8 @@ export function companyPortabilityService(db: Db) { projectPlans, issuePlans, }, + manifest, + files: source.files, envInputs: manifest.envInputs ?? [], warnings, errors, diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index b88c4051..795f20fb 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type ChangeEvent } from "react"; +import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { CompanyPortabilityCollisionStrategy, @@ -10,6 +10,7 @@ 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"; @@ -17,169 +18,338 @@ import { ChevronDown, ChevronRight, Download, + FileCode2, + FileText, + Folder, + FolderOpen, Github, Link2, + Package, Upload, } from "lucide-react"; import { Field } from "../components/agent-config-primitives"; -// ── Preview tree types ──────────────────────────────────────────────── +// ── Tree types ──────────────────────────────────────────────────────── -type PreviewTreeNode = { +type FileTreeNode = { name: string; - kind: "section" | "item"; - action?: string; - reason?: string | null; - detail?: string; - children: PreviewTreeNode[]; + 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"; -// ── Build preview tree from preview result ──────────────────────────── +// ── Tree helpers ────────────────────────────────────────────────────── -function buildPreviewTree(preview: CompanyPortabilityPreviewResult): PreviewTreeNode[] { - const sections: PreviewTreeNode[] = []; +function buildFileTree(files: Record, actionMap: Map): FileTreeNode[] { + const root: FileTreeNode = { name: "", path: "", kind: "dir", children: [] }; - // Company section - if (preview.plan.companyAction !== "none") { - sections.push({ - name: "Company", - kind: "section", - children: [ - { - name: preview.targetCompanyName ?? "New company", - kind: "item", - action: preview.plan.companyAction, - detail: `Target: ${preview.targetCompanyName ?? "new"}`, + 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; + } } - // Agents section - if (preview.plan.agentPlans.length > 0) { - sections.push({ - name: `Agents (${preview.plan.agentPlans.length})`, - kind: "section", - children: preview.plan.agentPlans.map((ap) => ({ - name: `${ap.slug} → ${ap.plannedName}`, - kind: "item" as const, - action: ap.action, - reason: ap.reason, - children: [], - })), + 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); } - // Projects section - if (preview.plan.projectPlans.length > 0) { - sections.push({ - name: `Projects (${preview.plan.projectPlans.length})`, - kind: "section", - children: preview.plan.projectPlans.map((pp) => ({ - name: `${pp.slug} → ${pp.plannedName}`, - kind: "item" as const, - action: pp.action, - reason: pp.reason, - children: [], - })), - }); - } - - // Issues section - if (preview.plan.issuePlans.length > 0) { - sections.push({ - name: `Tasks (${preview.plan.issuePlans.length})`, - kind: "section", - children: preview.plan.issuePlans.map((ip) => ({ - name: `${ip.slug} → ${ip.plannedTitle}`, - kind: "item" as const, - action: ip.action, - reason: ip.reason, - children: [], - })), - }); - } - - // Env inputs section - if (preview.envInputs.length > 0) { - sections.push({ - name: `Environment inputs (${preview.envInputs.length})`, - kind: "section", - children: preview.envInputs.map((ei) => ({ - name: ei.key + (ei.agentSlug ? ` (${ei.agentSlug})` : ""), - kind: "item" as const, - action: ei.requirement, - detail: [ - ei.kind, - ei.requirement, - ei.defaultValue !== null ? `default: ${JSON.stringify(ei.defaultValue)}` : null, - ei.portability === "system_dependent" ? "system-dependent" : null, - ] - .filter(Boolean) - .join(" · "), - reason: ei.description, - children: [], - })), - }); - } - - return sections; + sortNode(root); + return root.children; } -// ── Preview tree component ──────────────────────────────────────────── +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 ImportPreviewTree({ +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) { + 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, - selectedItem, - expandedSections, - onToggleSection, - onSelectItem, + selectedFile, + expandedDirs, + checkedFiles, + onToggleDir, + onSelectFile, + onToggleCheck, depth = 0, }: { - nodes: PreviewTreeNode[]; - selectedItem: string | null; - expandedSections: Set; - onToggleSection: (name: string) => void; - onSelectItem: (name: string) => void; + 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) => { - if (node.kind === "section") { - const expanded = expandedSections.has(node.name); + 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 && ( - )} @@ -187,84 +357,103 @@ function ImportPreviewTree({ ); } + 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 && ( - - {node.action} + + {checked ? node.action : "skip"} )} - +
); })} ); } -// ── Import detail pane ──────────────────────────────────────────────── +// ── Preview pane ────────────────────────────────────────────────────── -function ImportDetailPane({ - selectedItem, - previewTree, +function ImportPreviewPane({ + selectedFile, + content, + action, }: { - selectedItem: string | null; - previewTree: PreviewTreeNode[]; + selectedFile: string | null; + content: string | null; + action: string | null; }) { - if (!selectedItem) { + if (!selectedFile || content === null) { return ( - + ); } - // Find the selected node - let found: PreviewTreeNode | null = null; - for (const section of previewTree) { - for (const child of section.children) { - if (child.name === selectedItem) { - found = child; - break; - } - } - if (found) break; - } - - if (!found) { - return ( - - ); - } + const isMarkdown = selectedFile.endsWith(".md"); + const parsed = isMarkdown ? parseFrontmatter(content) : null; + const actionColor = action ? (ACTION_COLORS[action] ?? ACTION_COLORS.skip) : ""; return (
-
-
-
-

{found.name}

-
- {found.action && ( - - {found.action} +
+
+
{selectedFile}
+ {action && ( + + {action} )}
-
- {found.detail && ( -
{found.detail}
- )} - {found.reason && ( -
{found.reason}
+
+ {parsed ? ( + <> + + {parsed.body.trim() && {parsed.body}} + + ) : isMarkdown ? ( + {content} + ) : ( +
+            {content}
+          
)}
@@ -329,8 +518,9 @@ export function CompanyImport() { // Preview state const [importPreview, setImportPreview] = useState(null); - const [selectedItem, setSelectedItem] = useState(null); - const [expandedSections, setExpandedSections] = useState>(new Set()); + const [selectedFile, setSelectedFile] = useState(null); + const [expandedDirs, setExpandedDirs] = useState>(new Set()); + const [checkedFiles, setCheckedFiles] = useState>(new Set()); useEffect(() => { setBreadcrumbs([ @@ -367,10 +557,19 @@ export function CompanyImport() { }, onSuccess: (result) => { setImportPreview(result); - // Expand all sections by default - const sections = buildPreviewTree(result).map((s) => s.name); - setExpandedSections(new Set(sections)); - setSelectedItem(null); + // 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({ @@ -436,11 +635,70 @@ export function CompanyImport() { } } - const previewTree = importPreview ? buildPreviewTree(importPreview) : []; + 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 ; } @@ -521,7 +779,7 @@ export function CompanyImport() { label={sourceMode === "github" ? "GitHub URL" : "Package URL"} hint={ sourceMode === "github" - ? "Repo root, tree path, or blob URL to COMPANY.md." + ? "Repo tree path or blob URL to COMPANY.md (e.g. github.com/owner/repo/tree/main/company)." : "Point directly at COMPANY.md or a directory that contains it." } > @@ -559,8 +817,8 @@ export function CompanyImport() {