mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 03:10:38 +09:00
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>
667 lines
23 KiB
TypeScript
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>
|
|
);
|
|
}
|