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 <noreply@paperclip.ing>
This commit is contained in:
parent
c6ea491000
commit
2f7da835de
3 changed files with 463 additions and 204 deletions
|
|
@ -186,6 +186,8 @@ export interface CompanyPortabilityPreviewResult {
|
||||||
projectPlans: CompanyPortabilityPreviewProjectPlan[];
|
projectPlans: CompanyPortabilityPreviewProjectPlan[];
|
||||||
issuePlans: CompanyPortabilityPreviewIssuePlan[];
|
issuePlans: CompanyPortabilityPreviewIssuePlan[];
|
||||||
};
|
};
|
||||||
|
manifest: CompanyPortabilityManifest;
|
||||||
|
files: Record<string, string>;
|
||||||
envInputs: CompanyPortabilityEnvInput[];
|
envInputs: CompanyPortabilityEnvInput[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
errors: string[];
|
errors: string[];
|
||||||
|
|
|
||||||
|
|
@ -2064,6 +2064,8 @@ export function companyPortabilityService(db: Db) {
|
||||||
projectPlans,
|
projectPlans,
|
||||||
issuePlans,
|
issuePlans,
|
||||||
},
|
},
|
||||||
|
manifest,
|
||||||
|
files: source.files,
|
||||||
envInputs: manifest.envInputs ?? [],
|
envInputs: manifest.envInputs ?? [],
|
||||||
warnings,
|
warnings,
|
||||||
errors,
|
errors,
|
||||||
|
|
|
||||||
|
|
@ -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 { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import type {
|
import type {
|
||||||
CompanyPortabilityCollisionStrategy,
|
CompanyPortabilityCollisionStrategy,
|
||||||
|
|
@ -10,6 +10,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { useToast } from "../context/ToastContext";
|
import { useToast } from "../context/ToastContext";
|
||||||
import { companiesApi } from "../api/companies";
|
import { companiesApi } from "../api/companies";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { MarkdownBody } from "../components/MarkdownBody";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
|
@ -17,169 +18,338 @@ import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Download,
|
Download,
|
||||||
|
FileCode2,
|
||||||
|
FileText,
|
||||||
|
Folder,
|
||||||
|
FolderOpen,
|
||||||
Github,
|
Github,
|
||||||
Link2,
|
Link2,
|
||||||
|
Package,
|
||||||
Upload,
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Field } from "../components/agent-config-primitives";
|
import { Field } from "../components/agent-config-primitives";
|
||||||
|
|
||||||
// ── Preview tree types ────────────────────────────────────────────────
|
// ── Tree types ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type PreviewTreeNode = {
|
type FileTreeNode = {
|
||||||
name: string;
|
name: string;
|
||||||
kind: "section" | "item";
|
path: string;
|
||||||
action?: string;
|
kind: "dir" | "file";
|
||||||
reason?: string | null;
|
children: FileTreeNode[];
|
||||||
detail?: string;
|
action?: string | null;
|
||||||
children: PreviewTreeNode[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const TREE_BASE_INDENT = 16;
|
const TREE_BASE_INDENT = 16;
|
||||||
const TREE_STEP_INDENT = 24;
|
const TREE_STEP_INDENT = 24;
|
||||||
const TREE_ROW_HEIGHT_CLASS = "min-h-9";
|
const TREE_ROW_HEIGHT_CLASS = "min-h-9";
|
||||||
|
|
||||||
// ── Build preview tree from preview result ────────────────────────────
|
// ── Tree helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function buildPreviewTree(preview: CompanyPortabilityPreviewResult): PreviewTreeNode[] {
|
function buildFileTree(files: Record<string, string>, actionMap: Map<string, string>): FileTreeNode[] {
|
||||||
const sections: PreviewTreeNode[] = [];
|
const root: FileTreeNode = { name: "", path: "", kind: "dir", children: [] };
|
||||||
|
|
||||||
// Company section
|
for (const filePath of Object.keys(files)) {
|
||||||
if (preview.plan.companyAction !== "none") {
|
const segments = filePath.split("/").filter(Boolean);
|
||||||
sections.push({
|
let current = root;
|
||||||
name: "Company",
|
let currentPath = "";
|
||||||
kind: "section",
|
for (let i = 0; i < segments.length; i++) {
|
||||||
children: [
|
const segment = segments[i];
|
||||||
{
|
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
||||||
name: preview.targetCompanyName ?? "New company",
|
const isLeaf = i === segments.length - 1;
|
||||||
kind: "item",
|
let next = current.children.find((c) => c.name === segment);
|
||||||
action: preview.plan.companyAction,
|
if (!next) {
|
||||||
detail: `Target: ${preview.targetCompanyName ?? "new"}`,
|
next = {
|
||||||
|
name: segment,
|
||||||
|
path: currentPath,
|
||||||
|
kind: isLeaf ? "file" : "dir",
|
||||||
children: [],
|
children: [],
|
||||||
},
|
action: isLeaf ? (actionMap.get(filePath) ?? null) : null,
|
||||||
],
|
};
|
||||||
});
|
current.children.push(next);
|
||||||
|
}
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agents section
|
function sortNode(node: FileTreeNode) {
|
||||||
if (preview.plan.agentPlans.length > 0) {
|
node.children.sort((a, b) => {
|
||||||
sections.push({
|
if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1;
|
||||||
name: `Agents (${preview.plan.agentPlans.length})`,
|
return a.name.localeCompare(b.name);
|
||||||
kind: "section",
|
|
||||||
children: preview.plan.agentPlans.map((ap) => ({
|
|
||||||
name: `${ap.slug} → ${ap.plannedName}`,
|
|
||||||
kind: "item" as const,
|
|
||||||
action: ap.action,
|
|
||||||
reason: ap.reason,
|
|
||||||
children: [],
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
|
node.children.forEach(sortNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Projects section
|
sortNode(root);
|
||||||
if (preview.plan.projectPlans.length > 0) {
|
return root.children;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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<string> {
|
||||||
|
const paths = new Set<string>();
|
||||||
|
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<string, string> {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
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<string, string> = {
|
||||||
|
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<string, string | string[]>;
|
||||||
|
|
||||||
|
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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<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) => (
|
||||||
|
<span
|
||||||
|
key={item}
|
||||||
|
className="inline-flex items-center rounded-md border border-border bg-background px-2 py-0.5 text-xs"
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span>{value}</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── File tree component ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ImportFileTree({
|
||||||
nodes,
|
nodes,
|
||||||
selectedItem,
|
selectedFile,
|
||||||
expandedSections,
|
expandedDirs,
|
||||||
onToggleSection,
|
checkedFiles,
|
||||||
onSelectItem,
|
onToggleDir,
|
||||||
|
onSelectFile,
|
||||||
|
onToggleCheck,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
}: {
|
}: {
|
||||||
nodes: PreviewTreeNode[];
|
nodes: FileTreeNode[];
|
||||||
selectedItem: string | null;
|
selectedFile: string | null;
|
||||||
expandedSections: Set<string>;
|
expandedDirs: Set<string>;
|
||||||
onToggleSection: (name: string) => void;
|
checkedFiles: Set<string>;
|
||||||
onSelectItem: (name: string) => void;
|
onToggleDir: (path: string) => void;
|
||||||
|
onSelectFile: (path: string) => void;
|
||||||
|
onToggleCheck: (path: string, kind: "file" | "dir") => void;
|
||||||
depth?: number;
|
depth?: number;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{nodes.map((node) => {
|
{nodes.map((node) => {
|
||||||
if (node.kind === "section") {
|
const expanded = node.kind === "dir" && expandedDirs.has(node.path);
|
||||||
const expanded = expandedSections.has(node.name);
|
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 (
|
return (
|
||||||
<div key={node.name}>
|
<div key={node.path}>
|
||||||
<button
|
<div
|
||||||
type="button"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex w-full items-center gap-2 pr-3 text-left text-sm font-medium text-muted-foreground hover:bg-accent/30 hover:text-foreground",
|
"group grid w-full grid-cols-[auto_minmax(0,1fr)_2.25rem] items-center gap-x-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground",
|
||||||
TREE_ROW_HEIGHT_CLASS,
|
TREE_ROW_HEIGHT_CLASS,
|
||||||
)}
|
)}
|
||||||
style={{ paddingLeft: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT}px` }}
|
|
||||||
onClick={() => onToggleSection(node.name)}
|
|
||||||
>
|
>
|
||||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
<label
|
||||||
|
className="flex items-center pl-2"
|
||||||
|
style={{ paddingLeft: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT - 8}px` }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allChecked}
|
||||||
|
ref={(el) => { if (el) el.indeterminate = someChecked && !allChecked; }}
|
||||||
|
onChange={() => onToggleCheck(node.path, "dir")}
|
||||||
|
className="mr-2 accent-foreground"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex min-w-0 items-center gap-2 py-1 text-left"
|
||||||
|
onClick={() => onToggleDir(node.path)}
|
||||||
|
>
|
||||||
|
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||||
|
{expanded ? (
|
||||||
|
<FolderOpen className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Folder className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{node.name}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-9 w-9 items-center justify-center self-center rounded-sm text-muted-foreground opacity-70 transition-[background-color,color,opacity] hover:bg-accent hover:text-foreground group-hover:opacity-100"
|
||||||
|
onClick={() => onToggleDir(node.path)}
|
||||||
|
>
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<ChevronDown className="h-3.5 w-3.5" />
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronRight className="h-3.5 w-3.5" />
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
</span>
|
</button>
|
||||||
<span className="truncate">{node.name}</span>
|
</div>
|
||||||
</button>
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<ImportPreviewTree
|
<ImportFileTree
|
||||||
nodes={node.children}
|
nodes={node.children}
|
||||||
selectedItem={selectedItem}
|
selectedFile={selectedFile}
|
||||||
expandedSections={expandedSections}
|
expandedDirs={expandedDirs}
|
||||||
onToggleSection={onToggleSection}
|
checkedFiles={checkedFiles}
|
||||||
onSelectItem={onSelectItem}
|
onToggleDir={onToggleDir}
|
||||||
|
onSelectFile={onSelectFile}
|
||||||
|
onToggleCheck={onToggleCheck}
|
||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -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 (
|
return (
|
||||||
<button
|
<div
|
||||||
key={node.name}
|
key={node.path}
|
||||||
type="button"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-2 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground",
|
"flex w-full items-center gap-2 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground cursor-pointer",
|
||||||
TREE_ROW_HEIGHT_CLASS,
|
TREE_ROW_HEIGHT_CLASS,
|
||||||
node.name === selectedItem && "text-foreground bg-accent/20",
|
node.path === selectedFile && "text-foreground bg-accent/20",
|
||||||
|
!checked && "opacity-50",
|
||||||
)}
|
)}
|
||||||
style={{ paddingLeft: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT}px` }}
|
style={{
|
||||||
onClick={() => onSelectItem(node.name)}
|
paddingInlineStart: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT - 8}px`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="flex-1 truncate">{node.name}</span>
|
<label className="flex items-center pl-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => onToggleCheck(node.path, "file")}
|
||||||
|
className="mr-2 accent-foreground"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-2 py-1 text-left"
|
||||||
|
onClick={() => onSelectFile(node.path)}
|
||||||
|
>
|
||||||
|
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||||
|
<FileIcon className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{node.name}</span>
|
||||||
|
</button>
|
||||||
{node.action && (
|
{node.action && (
|
||||||
<span className="shrink-0 rounded-full border border-border px-2 py-0.5 text-xs uppercase tracking-wide text-muted-foreground">
|
<span className={cn(
|
||||||
{node.action}
|
"shrink-0 rounded-full border px-2 py-0.5 text-[10px] uppercase tracking-wide",
|
||||||
|
actionColor,
|
||||||
|
)}>
|
||||||
|
{checked ? node.action : "skip"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Import detail pane ────────────────────────────────────────────────
|
// ── Preview pane ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ImportDetailPane({
|
function ImportPreviewPane({
|
||||||
selectedItem,
|
selectedFile,
|
||||||
previewTree,
|
content,
|
||||||
|
action,
|
||||||
}: {
|
}: {
|
||||||
selectedItem: string | null;
|
selectedFile: string | null;
|
||||||
previewTree: PreviewTreeNode[];
|
content: string | null;
|
||||||
|
action: string | null;
|
||||||
}) {
|
}) {
|
||||||
if (!selectedItem) {
|
if (!selectedFile || content === null) {
|
||||||
return (
|
return (
|
||||||
<EmptyState icon={Download} message="Select an item to see its details." />
|
<EmptyState icon={Package} message="Select a file to preview its contents." />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the selected node
|
const isMarkdown = selectedFile.endsWith(".md");
|
||||||
let found: PreviewTreeNode | null = null;
|
const parsed = isMarkdown ? parseFrontmatter(content) : null;
|
||||||
for (const section of previewTree) {
|
const actionColor = action ? (ACTION_COLORS[action] ?? ACTION_COLORS.skip) : "";
|
||||||
for (const child of section.children) {
|
|
||||||
if (child.name === selectedItem) {
|
|
||||||
found = child;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (found) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!found) {
|
|
||||||
return (
|
|
||||||
<EmptyState icon={Download} message="Item not found." />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="border-b border-border px-5 py-4">
|
<div className="border-b border-border px-5 py-3">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="truncate font-mono text-sm">{selectedFile}</div>
|
||||||
<h2 className="truncate text-lg font-semibold">{found.name}</h2>
|
{action && (
|
||||||
</div>
|
<span className={cn(
|
||||||
{found.action && (
|
"shrink-0 rounded-full border px-2 py-0.5 text-xs uppercase tracking-wide",
|
||||||
<span className="shrink-0 rounded-full border border-border px-3 py-1 text-xs uppercase tracking-wide text-muted-foreground">
|
actionColor,
|
||||||
{found.action}
|
)}>
|
||||||
|
{action}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-5 py-5 space-y-3">
|
<div className="min-h-[560px] px-5 py-5">
|
||||||
{found.detail && (
|
{parsed ? (
|
||||||
<div className="text-sm text-muted-foreground">{found.detail}</div>
|
<>
|
||||||
)}
|
<FrontmatterCard data={parsed.data} />
|
||||||
{found.reason && (
|
{parsed.body.trim() && <MarkdownBody>{parsed.body}</MarkdownBody>}
|
||||||
<div className="text-sm">{found.reason}</div>
|
</>
|
||||||
|
) : 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -329,8 +518,9 @@ export function CompanyImport() {
|
||||||
// Preview state
|
// Preview state
|
||||||
const [importPreview, setImportPreview] =
|
const [importPreview, setImportPreview] =
|
||||||
useState<CompanyPortabilityPreviewResult | null>(null);
|
useState<CompanyPortabilityPreviewResult | null>(null);
|
||||||
const [selectedItem, setSelectedItem] = useState<string | null>(null);
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
|
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
|
||||||
|
const [checkedFiles, setCheckedFiles] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBreadcrumbs([
|
setBreadcrumbs([
|
||||||
|
|
@ -367,10 +557,19 @@ export function CompanyImport() {
|
||||||
},
|
},
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
setImportPreview(result);
|
setImportPreview(result);
|
||||||
// Expand all sections by default
|
// Check all files by default
|
||||||
const sections = buildPreviewTree(result).map((s) => s.name);
|
const allFiles = new Set(Object.keys(result.files));
|
||||||
setExpandedSections(new Set(sections));
|
setCheckedFiles(allFiles);
|
||||||
setSelectedItem(null);
|
// Expand top-level dirs
|
||||||
|
const tree = buildFileTree(result.files, buildActionMap(result));
|
||||||
|
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) => {
|
onError: (err) => {
|
||||||
pushToast({
|
pushToast({
|
||||||
|
|
@ -436,11 +635,70 @@ export function CompanyImport() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const previewTree = importPreview ? buildPreviewTree(importPreview) : [];
|
const actionMap = useMemo(
|
||||||
|
() => (importPreview ? buildActionMap(importPreview) : new Map<string, string>()),
|
||||||
|
[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 =
|
const hasSource =
|
||||||
sourceMode === "local" ? !!localPackage : importUrl.trim().length > 0;
|
sourceMode === "local" ? !!localPackage : importUrl.trim().length > 0;
|
||||||
const hasErrors = importPreview ? importPreview.errors.length > 0 : false;
|
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) {
|
if (!selectedCompanyId) {
|
||||||
return <EmptyState icon={Download} message="Select a company to import into." />;
|
return <EmptyState icon={Download} message="Select a company to import into." />;
|
||||||
}
|
}
|
||||||
|
|
@ -521,7 +779,7 @@ export function CompanyImport() {
|
||||||
label={sourceMode === "github" ? "GitHub URL" : "Package URL"}
|
label={sourceMode === "github" ? "GitHub URL" : "Package URL"}
|
||||||
hint={
|
hint={
|
||||||
sourceMode === "github"
|
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."
|
: "Point directly at COMPANY.md or a directory that contains it."
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -559,8 +817,8 @@ export function CompanyImport() {
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field
|
<Field
|
||||||
label="Collision strategy"
|
label="Default collision strategy"
|
||||||
hint="Controls what happens when imported agent slugs already exist."
|
hint="Controls what happens when imported slugs already exist."
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
||||||
|
|
@ -615,13 +873,10 @@ export function CompanyImport() {
|
||||||
Import preview
|
Import preview
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
Target: {importPreview.targetCompanyName ?? "new company"}
|
{selectedCount} / {totalFiles} file{totalFiles === 1 ? "" : "s"} selected
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
Strategy: {importPreview.collisionStrategy}
|
|
||||||
</span>
|
</span>
|
||||||
{importPreview.warnings.length > 0 && (
|
{importPreview.warnings.length > 0 && (
|
||||||
<span className="text-amber-600">
|
<span className="text-amber-500">
|
||||||
{importPreview.warnings.length} warning{importPreview.warnings.length === 1 ? "" : "s"}
|
{importPreview.warnings.length} warning{importPreview.warnings.length === 1 ? "" : "s"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -634,10 +889,12 @@ export function CompanyImport() {
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => importMutation.mutate()}
|
onClick={() => importMutation.mutate()}
|
||||||
disabled={importMutation.isPending || hasErrors}
|
disabled={importMutation.isPending || hasErrors || selectedCount === 0}
|
||||||
>
|
>
|
||||||
<Download className="mr-1.5 h-3.5 w-3.5" />
|
<Download className="mr-1.5 h-3.5 w-3.5" />
|
||||||
{importMutation.isPending ? "Importing..." : "Apply import"}
|
{importMutation.isPending
|
||||||
|
? "Importing..."
|
||||||
|
: `Import ${selectedCount} file${selectedCount === 1 ? "" : "s"}`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -664,32 +921,30 @@ export function CompanyImport() {
|
||||||
<div className="grid min-h-[calc(100vh-16rem)] gap-0 xl:grid-cols-[19rem_minmax(0,1fr)]">
|
<div className="grid min-h-[calc(100vh-16rem)] gap-0 xl:grid-cols-[19rem_minmax(0,1fr)]">
|
||||||
<aside className="border-r border-border">
|
<aside className="border-r border-border">
|
||||||
<div className="border-b border-border px-4 py-3">
|
<div className="border-b border-border px-4 py-3">
|
||||||
<h2 className="text-base font-semibold">Import plan</h2>
|
<h2 className="text-base font-semibold">Package files</h2>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{importPreview.plan.agentPlans.length} agent{importPreview.plan.agentPlans.length === 1 ? "" : "s"},
|
{totalFiles} file{totalFiles === 1 ? "" : "s"} ·
|
||||||
|
{" "}{importPreview.plan.agentPlans.length} agent{importPreview.plan.agentPlans.length === 1 ? "" : "s"},
|
||||||
|
{" "}{importPreview.manifest.skills.length} skill{importPreview.manifest.skills.length === 1 ? "" : "s"},
|
||||||
{" "}{importPreview.plan.projectPlans.length} project{importPreview.plan.projectPlans.length === 1 ? "" : "s"},
|
{" "}{importPreview.plan.projectPlans.length} project{importPreview.plan.projectPlans.length === 1 ? "" : "s"},
|
||||||
{" "}{importPreview.plan.issuePlans.length} task{importPreview.plan.issuePlans.length === 1 ? "" : "s"}
|
{" "}{importPreview.plan.issuePlans.length} task{importPreview.plan.issuePlans.length === 1 ? "" : "s"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ImportPreviewTree
|
<ImportFileTree
|
||||||
nodes={previewTree}
|
nodes={tree}
|
||||||
selectedItem={selectedItem}
|
selectedFile={selectedFile}
|
||||||
expandedSections={expandedSections}
|
expandedDirs={expandedDirs}
|
||||||
onToggleSection={(name) => {
|
checkedFiles={checkedFiles}
|
||||||
setExpandedSections((prev) => {
|
onToggleDir={handleToggleDir}
|
||||||
const next = new Set(prev);
|
onSelectFile={setSelectedFile}
|
||||||
if (next.has(name)) next.delete(name);
|
onToggleCheck={handleToggleCheck}
|
||||||
else next.add(name);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onSelectItem={setSelectedItem}
|
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
<div className="min-w-0 pl-6">
|
<div className="min-w-0 pl-6">
|
||||||
<ImportDetailPane
|
<ImportPreviewPane
|
||||||
selectedItem={selectedItem}
|
selectedFile={selectedFile}
|
||||||
previewTree={previewTree}
|
content={previewContent}
|
||||||
|
action={selectedAction}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue