mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
1304 lines
47 KiB
TypeScript
1304 lines
47 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
import type {
|
|
Agent,
|
|
CompanyPortabilityFileEntry,
|
|
CompanyPortabilityExportPreviewResult,
|
|
CompanyPortabilityExportResult,
|
|
CompanyPortabilityManifest,
|
|
Project,
|
|
} from "@paperclipai/shared";
|
|
import { useNavigate, useLocation } from "@/lib/router";
|
|
import { useCompany } from "../context/CompanyContext";
|
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
|
import { useToastActions } from "../context/ToastContext";
|
|
import { agentsApi } from "../api/agents";
|
|
import { authApi } from "../api/auth";
|
|
import { companiesApi } from "../api/companies";
|
|
import { projectsApi } from "../api/projects";
|
|
import { Button } from "@/components/ui/button";
|
|
import { EmptyState } from "../components/EmptyState";
|
|
import { PageSkeleton } from "../components/PageSkeleton";
|
|
import { MarkdownBody } from "../components/MarkdownBody";
|
|
import { toCompanyRelativePath } from "@/lib/company-routes";
|
|
import { cn } from "../lib/utils";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { formatBytes } from "../lib/issue-output";
|
|
import { createZipArchive, estimateZipArchiveSize } from "../lib/zip";
|
|
import {
|
|
type ExportCategoryKey,
|
|
type ExportCategorySelection,
|
|
EXPORT_CATEGORY_LABELS,
|
|
EXPORT_CATEGORY_ORDER,
|
|
buildDefaultExportCategorySelection,
|
|
buildExportCheckedFiles,
|
|
countExportFilesByCategory,
|
|
isAttachmentsCategoryEnabled,
|
|
} from "../lib/company-export-selection";
|
|
import { useAgentOrder } from "../hooks/useAgentOrder";
|
|
import { useProjectOrder } from "../hooks/useProjectOrder";
|
|
import { buildPortableSidebarOrder } from "../lib/company-portability-sidebar";
|
|
import { getPortableFileDataUrl, getPortableFileText, isPortableImageFile } from "../lib/portable-files";
|
|
import {
|
|
Download,
|
|
LoaderCircle,
|
|
Package,
|
|
RotateCcw,
|
|
Search,
|
|
X,
|
|
} from "lucide-react";
|
|
import {
|
|
type FileTreeNode,
|
|
type FileTreeTone,
|
|
type FrontmatterData,
|
|
buildFileTree,
|
|
countFiles,
|
|
collectAllPaths,
|
|
parseFrontmatter,
|
|
FRONTMATTER_FIELD_LABELS,
|
|
FileTree,
|
|
} from "../components/FileTree";
|
|
|
|
/**
|
|
* 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>;
|
|
routines: 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, routines: new Set(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", "routines"]);
|
|
const sidebarSections = new Set(["agents", "projects"]);
|
|
|
|
let currentSection: string | null = null; // top-level key (e.g. "agents")
|
|
let currentEntry: string | null = null; // slug under that section
|
|
let includeEntry = true;
|
|
let currentSidebarList: string | null = null;
|
|
let currentSidebarHeaderLine: string | null = null;
|
|
let currentSidebarBuffer: string[] = [];
|
|
// Collect entries per section so we can omit empty section headers
|
|
let sectionHeaderLine: string | null = null;
|
|
let sectionBuffer: string[] = [];
|
|
|
|
function flushSidebarSection() {
|
|
if (currentSidebarHeaderLine !== null && currentSidebarBuffer.length > 0) {
|
|
sectionBuffer.push(currentSidebarHeaderLine);
|
|
sectionBuffer.push(...currentSidebarBuffer);
|
|
}
|
|
currentSidebarHeaderLine = null;
|
|
currentSidebarBuffer = [];
|
|
}
|
|
|
|
function flushSection() {
|
|
flushSidebarSection();
|
|
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 if (key === "sidebar") {
|
|
currentSection = key;
|
|
currentSidebarList = null;
|
|
sectionHeaderLine = line;
|
|
continue;
|
|
} else {
|
|
currentSection = null;
|
|
out.push(line);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (currentSection === "sidebar") {
|
|
const sidebarMatch = line.match(/^ ([\w-]+):\s*$/);
|
|
if (sidebarMatch && !line.startsWith(" ")) {
|
|
flushSidebarSection();
|
|
const sidebarKey = sidebarMatch[1];
|
|
currentSidebarList = sidebarKey && sidebarSections.has(sidebarKey) ? sidebarKey : null;
|
|
currentSidebarHeaderLine = currentSidebarList ? line : null;
|
|
continue;
|
|
}
|
|
|
|
const sidebarEntryMatch = line.match(/^ - ["']?([^"'\n]+)["']?\s*$/);
|
|
if (sidebarEntryMatch && currentSidebarList) {
|
|
const slug = sidebarEntryMatch[1];
|
|
const sectionSlugs = slugs[currentSidebarList as keyof typeof slugs];
|
|
if (slug && sectionSlugs.has(slug)) {
|
|
currentSidebarBuffer.push(line);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (currentSidebarList) {
|
|
currentSidebarBuffer.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();
|
|
|
|
let filtered = out.join("\n");
|
|
const logoPathMatch = filtered.match(/^\s{2}logoPath:\s*["']?([^"'\n]+)["']?\s*$/m);
|
|
if (logoPathMatch && !checkedFiles.has(logoPathMatch[1]!)) {
|
|
filtered = filtered.replace(/^\s{2}logoPath:\s*["']?([^"'\n]+)["']?\s*\n?/m, "");
|
|
}
|
|
|
|
return filtered;
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
|
|
const TASKS_PAGE_SIZE = 10;
|
|
|
|
/**
|
|
* Paginate children of `tasks/` directories: show up to `limit` entries,
|
|
* but always include children that match the search query or contain the
|
|
* currently previewed file (so deep links stay visible).
|
|
* Returns the paginated tree and the total count of task children.
|
|
*/
|
|
function paginateTaskNodes(
|
|
nodes: FileTreeNode[],
|
|
limit: number,
|
|
searchQuery: string,
|
|
selectedFile: string | null,
|
|
): { 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 (search-matched or previewed) vs rest
|
|
const pinned: FileTreeNode[] = [];
|
|
const rest: FileTreeNode[] = [];
|
|
const lower = searchQuery.toLowerCase();
|
|
|
|
for (const child of node.children) {
|
|
const childFiles = collectAllPaths([child], "file");
|
|
const containsSelected = selectedFile !== null && childFiles.has(selectedFile);
|
|
const isSearchMatch = searchQuery && (
|
|
child.name.toLowerCase().includes(lower) ||
|
|
child.path.toLowerCase().includes(lower) ||
|
|
[...childFiles].some((p) => p.toLowerCase().includes(lower))
|
|
);
|
|
if (containsSelected || 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 };
|
|
}
|
|
|
|
/**
|
|
* Build the file map the zip download will contain: the exported files
|
|
* restricted to the selected set, preferring the client-side effective
|
|
* content (regenerated README.md, filtered .paperclip.yaml) when present.
|
|
* The download size estimate runs this same filter so the number shown
|
|
* matches what actually gets zipped.
|
|
*/
|
|
function filterExportForDownload(
|
|
files: Record<string, CompanyPortabilityFileEntry>,
|
|
selectedFiles: Set<string>,
|
|
effectiveFiles: Record<string, CompanyPortabilityFileEntry>,
|
|
): Record<string, CompanyPortabilityFileEntry> {
|
|
const filteredFiles: Record<string, CompanyPortabilityFileEntry> = {};
|
|
for (const path of Object.keys(files)) {
|
|
if (selectedFiles.has(path)) filteredFiles[path] = effectiveFiles[path] ?? files[path]!;
|
|
}
|
|
return filteredFiles;
|
|
}
|
|
|
|
function downloadZip(
|
|
exported: CompanyPortabilityExportResult,
|
|
selectedFiles: Set<string>,
|
|
effectiveFiles: Record<string, CompanyPortabilityFileEntry>,
|
|
) {
|
|
const filteredFiles = filterExportForDownload(exported.files, selectedFiles, effectiveFiles);
|
|
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-(--gtc-5) 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>
|
|
);
|
|
}
|
|
|
|
// ── Client-side README generation ────────────────────────────────────
|
|
|
|
const ROLE_LABELS: Record<string, string> = {
|
|
ceo: "CEO", cto: "CTO", cmo: "CMO", cfo: "CFO", coo: "COO",
|
|
vp: "VP", manager: "Manager", engineer: "Engineer", agent: "Agent",
|
|
};
|
|
|
|
/**
|
|
* Regenerate README.md content based on the currently checked files.
|
|
* Only counts/lists entities whose files are in the checked set.
|
|
*/
|
|
function generateReadmeFromSelection(
|
|
manifest: CompanyPortabilityManifest,
|
|
checkedFiles: Set<string>,
|
|
companyName: string,
|
|
companyDescription: string | null,
|
|
): string {
|
|
const slugs = checkedSlugs(checkedFiles);
|
|
|
|
const agents = manifest.agents.filter((a) => slugs.agents.has(a.slug));
|
|
const projects = manifest.projects.filter((p) => slugs.projects.has(p.slug));
|
|
const tasks = manifest.issues.filter((t) => slugs.tasks.has(t.slug));
|
|
const skills = manifest.skills.filter((s) => {
|
|
// Skill files live under skills/{key}/...
|
|
return [...checkedFiles].some((f) => f.startsWith(`skills/${s.key}/`) || f.startsWith(`skills/`) && f.includes(`/${s.slug}/`));
|
|
});
|
|
|
|
const lines: string[] = [];
|
|
lines.push(`# ${companyName}`);
|
|
lines.push("");
|
|
if (companyDescription) {
|
|
lines.push(`> ${companyDescription}`);
|
|
lines.push("");
|
|
}
|
|
// Org chart image (generated during export as images/org-chart.png)
|
|
if (agents.length > 0) {
|
|
lines.push("");
|
|
lines.push("");
|
|
}
|
|
|
|
lines.push("## What's Inside");
|
|
lines.push("");
|
|
lines.push("This is an [Agent Company](https://paperclip.ing) package.");
|
|
lines.push("");
|
|
|
|
const counts: Array<[string, number]> = [];
|
|
if (agents.length > 0) counts.push(["Agents", agents.length]);
|
|
if (projects.length > 0) counts.push(["Projects", projects.length]);
|
|
if (skills.length > 0) counts.push(["Skills", skills.length]);
|
|
if (tasks.length > 0) counts.push(["Tasks", tasks.length]);
|
|
|
|
if (counts.length > 0) {
|
|
lines.push("| Content | Count |");
|
|
lines.push("|---------|-------|");
|
|
for (const [label, count] of counts) {
|
|
lines.push(`| ${label} | ${count} |`);
|
|
}
|
|
lines.push("");
|
|
}
|
|
|
|
if (agents.length > 0) {
|
|
lines.push("### Agents");
|
|
lines.push("");
|
|
lines.push("| Agent | Role | Reports To |");
|
|
lines.push("|-------|------|------------|");
|
|
for (const agent of agents) {
|
|
const roleLabel = ROLE_LABELS[agent.role] ?? agent.role;
|
|
const reportsTo = agent.reportsToSlug ?? "\u2014";
|
|
lines.push(`| ${agent.name} | ${roleLabel} | ${reportsTo} |`);
|
|
}
|
|
lines.push("");
|
|
}
|
|
|
|
if (projects.length > 0) {
|
|
lines.push("### Projects");
|
|
lines.push("");
|
|
for (const project of projects) {
|
|
const desc = project.description ? ` \u2014 ${project.description}` : "";
|
|
lines.push(`- **${project.name}**${desc}`);
|
|
}
|
|
lines.push("");
|
|
}
|
|
|
|
lines.push("## Getting Started");
|
|
lines.push("");
|
|
lines.push("```bash");
|
|
lines.push("npx paperclipai company import this-github-url-or-folder");
|
|
lines.push("```");
|
|
lines.push("");
|
|
lines.push("See [Paperclip](https://paperclip.ing) for more information.");
|
|
lines.push("");
|
|
lines.push("---");
|
|
lines.push(`Exported from [Paperclip](https://paperclip.ing) on ${new Date().toISOString().split("T")[0]}`);
|
|
lines.push("");
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
// ── Preview pane ──────────────────────────────────────────────────────
|
|
|
|
export function resolveExportPreviewImageSrc(input: {
|
|
src: string;
|
|
selectedFile: string;
|
|
allFiles: Record<string, CompanyPortabilityFileEntry>;
|
|
orgChartPreviewUrl?: string;
|
|
}): string | null {
|
|
const { src, selectedFile, allFiles, orgChartPreviewUrl } = input;
|
|
if (/^(?:https?:|data:)/i.test(src)) return null;
|
|
|
|
// Preview generation deliberately avoids the comparatively expensive PNG
|
|
// renderer. Use the independently generated SVG endpoint so the README
|
|
// never shows a broken image; downloaded bundles still contain the PNG.
|
|
if (src.replace(/^\.\//, "") === "images/org-chart.png" && orgChartPreviewUrl) {
|
|
return orgChartPreviewUrl;
|
|
}
|
|
|
|
const dir = selectedFile.includes("/") ? selectedFile.slice(0, selectedFile.lastIndexOf("/") + 1) : "";
|
|
const resolved = dir + src;
|
|
const entry = allFiles[resolved] ?? allFiles[src];
|
|
if (!entry) return null;
|
|
return getPortableFileDataUrl(resolved in allFiles ? resolved : src, entry);
|
|
}
|
|
|
|
function ExportPreviewPane({
|
|
selectedFile,
|
|
content,
|
|
allFiles,
|
|
orgChartPreviewUrl,
|
|
onSkillClick,
|
|
}: {
|
|
selectedFile: string | null;
|
|
content: CompanyPortabilityFileEntry | null;
|
|
allFiles: Record<string, CompanyPortabilityFileEntry>;
|
|
orgChartPreviewUrl?: string;
|
|
onSkillClick?: (skill: string) => void;
|
|
}) {
|
|
if (!selectedFile || content === null) {
|
|
return (
|
|
<EmptyState icon={Package} message="Select a file to preview its contents." />
|
|
);
|
|
}
|
|
|
|
const textContent = getPortableFileText(content);
|
|
const isMarkdown = selectedFile.endsWith(".md") && textContent !== null;
|
|
const parsed = isMarkdown && textContent ? parseFrontmatter(textContent) : null;
|
|
const imageSrc = isPortableImageFile(selectedFile, content) ? getPortableFileDataUrl(selectedFile, content) : null;
|
|
|
|
// Resolve relative image paths within the export package (e.g. images/org-chart.png)
|
|
const resolveImageSrc = isMarkdown
|
|
? (src: string) => resolveExportPreviewImageSrc({ src, selectedFile, allFiles, orgChartPreviewUrl })
|
|
: undefined;
|
|
|
|
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-(--sz-560px) px-5 py-5">
|
|
{parsed ? (
|
|
<>
|
|
<FrontmatterCard data={parsed.data} onSkillClick={onSkillClick} />
|
|
{parsed.body.trim() && <MarkdownBody resolveImageSrc={resolveImageSrc} softBreaks={false} linkIssueReferences={false}>{parsed.body}</MarkdownBody>}
|
|
</>
|
|
) : isMarkdown ? (
|
|
<MarkdownBody resolveImageSrc={resolveImageSrc} softBreaks={false} linkIssueReferences={false}>{textContent ?? ""}</MarkdownBody>
|
|
) : imageSrc ? (
|
|
<div className="flex min-h-(--sz-520px) items-center justify-center rounded-lg border border-border bg-accent/10 p-6">
|
|
<img src={imageSrc} alt={selectedFile} className="max-h-(--sz-480px) max-w-full object-contain" />
|
|
</div>
|
|
) : textContent !== null ? (
|
|
<pre className="overflow-x-auto whitespace-pre-wrap break-words border-0 bg-transparent p-0 font-mono text-sm text-foreground">
|
|
<code>{textContent}</code>
|
|
</pre>
|
|
) : (
|
|
<div className="rounded-lg border border-border bg-accent/10 px-4 py-3 text-sm text-muted-foreground">
|
|
Binary asset preview is not available for this file type.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Main page ─────────────────────────────────────────────────────────
|
|
|
|
/** Extract the file path from the current URL pathname (after /company/export/files/) */
|
|
function filePathFromLocation(pathname: string): string | null {
|
|
const marker = "/company/export/files/";
|
|
const relativePathname = toCompanyRelativePath(pathname);
|
|
const idx = relativePathname.indexOf(marker);
|
|
if (idx === -1) return null;
|
|
const filePath = decodeURIComponent(relativePathname.slice(idx + marker.length));
|
|
return filePath || null;
|
|
}
|
|
|
|
/** Expand all ancestor directories for a given file path */
|
|
function expandAncestors(filePath: string): string[] {
|
|
const parts = filePath.split("/").slice(0, -1);
|
|
const dirs: string[] = [];
|
|
let current = "";
|
|
for (const part of parts) {
|
|
current = current ? `${current}/${part}` : part;
|
|
dirs.push(current);
|
|
}
|
|
return dirs;
|
|
}
|
|
|
|
interface ExportPreviewMutationInput {
|
|
includeIssues: boolean;
|
|
includeSkills: boolean;
|
|
requestId: number;
|
|
signal: AbortSignal;
|
|
}
|
|
|
|
function isAbortError(error: unknown): boolean {
|
|
return error instanceof DOMException
|
|
? error.name === "AbortError"
|
|
: error instanceof Error && error.name === "AbortError";
|
|
}
|
|
|
|
function previewErrorMessage(error: unknown): string {
|
|
return error instanceof Error ? error.message : "Failed to load export data.";
|
|
}
|
|
|
|
export function CompanyExport() {
|
|
const { selectedCompanyId, selectedCompany } = useCompany();
|
|
const { setBreadcrumbs } = useBreadcrumbs();
|
|
const { pushToast } = useToastActions();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const initialFileFromUrl = useRef(filePathFromLocation(location.pathname));
|
|
const { data: session } = useQuery({
|
|
queryKey: queryKeys.auth.session,
|
|
queryFn: () => authApi.getSession(),
|
|
});
|
|
const { data: agents = [] } = useQuery({
|
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
|
queryFn: () => agentsApi.list(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
const { data: projects = [] } = useQuery({
|
|
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
|
queryFn: () => projectsApi.list(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
const { data: fidelityReport } = useQuery({
|
|
queryKey: queryKeys.companies.exportFidelity(selectedCompanyId!),
|
|
queryFn: () => companiesApi.exportFidelity(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
|
|
const [exportData, setExportData] = useState<CompanyPortabilityExportPreviewResult | null>(null);
|
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
|
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
|
|
const [categories, setCategories] = useState<ExportCategorySelection>(() => ({
|
|
...buildDefaultExportCategorySelection(),
|
|
// Task history is the expensive part of a large-company export. Keep it
|
|
// opt-in so opening this settings page uses the lightweight preview path.
|
|
tasks: false,
|
|
routines: false,
|
|
attachments: false,
|
|
}));
|
|
const [treeSearch, setTreeSearch] = useState("");
|
|
const [taskLimit, setTaskLimit] = useState(TASKS_PAGE_SIZE);
|
|
const [previewCancelled, setPreviewCancelled] = useState(false);
|
|
const savedExpandedRef = useRef<Set<string> | null>(null);
|
|
const previewAbortControllerRef = useRef<AbortController | null>(null);
|
|
const previewRequestIdRef = useRef(0);
|
|
const previewCompanyIdRef = useRef<string | null>(null);
|
|
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
|
const visibleAgents = useMemo(
|
|
() => agents.filter((agent: Agent) => agent.status !== "terminated"),
|
|
[agents],
|
|
);
|
|
const visibleProjects = useMemo(
|
|
() => projects,
|
|
[projects],
|
|
);
|
|
const { orderedAgents } = useAgentOrder({
|
|
agents: visibleAgents,
|
|
companyId: selectedCompanyId,
|
|
userId: currentUserId,
|
|
});
|
|
const { orderedProjects } = useProjectOrder({
|
|
projects: visibleProjects,
|
|
companyId: selectedCompanyId,
|
|
userId: currentUserId,
|
|
});
|
|
const sidebarOrder = useMemo(
|
|
() => buildPortableSidebarOrder({
|
|
agents: visibleAgents,
|
|
orderedAgents,
|
|
projects: visibleProjects,
|
|
orderedProjects,
|
|
}),
|
|
[orderedAgents, orderedProjects, visibleAgents, visibleProjects],
|
|
);
|
|
const includeIssues = categories.tasks || categories.routines;
|
|
|
|
// Navigate-aware file selection: updates state + URL without page reload.
|
|
// `replace` = true skips history entry (used for initial load); false = pushes (used for clicks).
|
|
const selectFile = useCallback(
|
|
(filePath: string | null, replace = false) => {
|
|
setSelectedFile(filePath);
|
|
if (filePath) {
|
|
navigate(`/company/export/files/${encodeURI(filePath)}`, { replace });
|
|
} else {
|
|
navigate("/company/export", { replace });
|
|
}
|
|
},
|
|
[navigate],
|
|
);
|
|
|
|
// Sync selectedFile from URL on browser back/forward
|
|
useEffect(() => {
|
|
if (!exportData) return;
|
|
const urlFile = filePathFromLocation(location.pathname);
|
|
if (urlFile && urlFile in exportData.files && urlFile !== selectedFile) {
|
|
setSelectedFile(urlFile);
|
|
// Expand ancestors so the file is visible in the tree
|
|
setExpandedDirs((prev) => {
|
|
const next = new Set(prev);
|
|
for (const dir of expandAncestors(urlFile)) next.add(dir);
|
|
return next;
|
|
});
|
|
} else if (!urlFile && selectedFile) {
|
|
setSelectedFile(null);
|
|
}
|
|
}, [location.pathname, exportData]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
useEffect(() => {
|
|
setBreadcrumbs([
|
|
{ label: selectedCompany?.name ?? "Organization", href: "/dashboard" },
|
|
{ label: "Settings", href: "/company/settings" },
|
|
{ label: "Export" },
|
|
]);
|
|
}, [selectedCompany?.name, setBreadcrumbs]);
|
|
|
|
const exportPreviewMutation = useMutation({
|
|
mutationFn: ({ includeIssues: withIssues, includeSkills, signal }: ExportPreviewMutationInput) =>
|
|
companiesApi.exportPreview(selectedCompanyId!, {
|
|
include: { company: true, agents: true, projects: true, issues: withIssues, skills: includeSkills },
|
|
sidebarOrder,
|
|
}, { signal }),
|
|
onSuccess: (result, request) => {
|
|
if (request.requestId !== previewRequestIdRef.current) return;
|
|
setExportData(result);
|
|
// Expand top-level dirs (except tasks — collapsed by default)
|
|
const tree = buildFileTree(result.files);
|
|
const topDirs = new Set<string>();
|
|
for (const node of tree) {
|
|
if (node.kind === "dir" && node.name !== "tasks") topDirs.add(node.path);
|
|
}
|
|
|
|
// If URL contains a deep-linked file path, select it and expand ancestors
|
|
const urlFile = initialFileFromUrl.current;
|
|
if (urlFile && urlFile in result.files) {
|
|
setSelectedFile(urlFile);
|
|
const ancestors = expandAncestors(urlFile);
|
|
setExpandedDirs(new Set([...topDirs, ...ancestors]));
|
|
} else {
|
|
// Default to README.md if present, otherwise fall back to first file
|
|
const defaultFile = "README.md" in result.files
|
|
? "README.md"
|
|
: Object.keys(result.files)[0];
|
|
if (defaultFile) {
|
|
selectFile(defaultFile, true);
|
|
}
|
|
setExpandedDirs(topDirs);
|
|
}
|
|
},
|
|
onError: (err, request) => {
|
|
if (request.requestId !== previewRequestIdRef.current || isAbortError(err)) return;
|
|
pushToast({
|
|
tone: "error",
|
|
title: "Export failed",
|
|
body: previewErrorMessage(err),
|
|
});
|
|
},
|
|
});
|
|
|
|
function startPreviewRequest() {
|
|
if (!selectedCompanyId) return;
|
|
previewAbortControllerRef.current?.abort();
|
|
const controller = new AbortController();
|
|
const requestId = previewRequestIdRef.current + 1;
|
|
previewRequestIdRef.current = requestId;
|
|
previewAbortControllerRef.current = controller;
|
|
setPreviewCancelled(false);
|
|
exportPreviewMutation.mutate({
|
|
includeIssues,
|
|
includeSkills: categories.skills,
|
|
requestId,
|
|
signal: controller.signal,
|
|
});
|
|
}
|
|
|
|
const downloadMutation = useMutation({
|
|
mutationFn: () =>
|
|
companiesApi.exportBundle(selectedCompanyId!, {
|
|
include: { company: true, agents: true, projects: true, issues: includeIssues, skills: categories.skills },
|
|
selectedFiles: Array.from(checkedFiles).sort(),
|
|
sidebarOrder,
|
|
}),
|
|
onSuccess: (result) => {
|
|
const resultCheckedFiles = new Set(Object.keys(result.files));
|
|
downloadZip(result, resultCheckedFiles, result.files);
|
|
pushToast({
|
|
tone: "success",
|
|
title: "Export downloaded",
|
|
body: `${resultCheckedFiles.size} file${resultCheckedFiles.size === 1 ? "" : "s"} exported as ${result.rootPath}.zip`,
|
|
});
|
|
},
|
|
onError: (err) => {
|
|
pushToast({
|
|
tone: "error",
|
|
title: "Export failed",
|
|
body: err instanceof Error ? err.message : "Failed to build export package.",
|
|
});
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!selectedCompanyId) return;
|
|
if (previewCompanyIdRef.current !== selectedCompanyId) {
|
|
previewCompanyIdRef.current = selectedCompanyId;
|
|
setExportData(null);
|
|
setSelectedFile(null);
|
|
}
|
|
startPreviewRequest();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [selectedCompanyId, includeIssues, categories.skills]);
|
|
|
|
useEffect(() => () => {
|
|
previewRequestIdRef.current += 1;
|
|
previewAbortControllerRef.current?.abort();
|
|
}, []);
|
|
|
|
const tree = useMemo(
|
|
() => (exportData ? buildFileTree(exportData.files) : []),
|
|
[exportData],
|
|
);
|
|
|
|
// The checked set is derived from the category toggles; the file tree is
|
|
// a pure browser on top of it.
|
|
const checkedFiles = useMemo(
|
|
() =>
|
|
exportData
|
|
? buildExportCheckedFiles({
|
|
filePaths: Object.keys(exportData.files),
|
|
issues: exportData.manifest.issues,
|
|
categories,
|
|
embeddedAssets: exportData.manifest.embeddedAssets ?? [],
|
|
})
|
|
: new Set<string>(),
|
|
[exportData, categories],
|
|
);
|
|
|
|
const categoryCounts = useMemo(
|
|
() =>
|
|
exportData
|
|
? countExportFilesByCategory(Object.keys(exportData.files), exportData.manifest.issues)
|
|
: null,
|
|
[exportData],
|
|
);
|
|
|
|
const { displayTree, totalTaskChildren, visibleTaskChildren } = useMemo(() => {
|
|
let result = tree;
|
|
if (treeSearch) result = filterTree(result, treeSearch);
|
|
const paginated = paginateTaskNodes(result, taskLimit, treeSearch, selectedFile);
|
|
return {
|
|
displayTree: paginated.nodes,
|
|
totalTaskChildren: paginated.totalTaskChildren,
|
|
visibleTaskChildren: paginated.visibleTaskChildren,
|
|
};
|
|
}, [tree, treeSearch, taskLimit, selectedFile]);
|
|
|
|
// Files the current toggles exclude render dimmed, so a toggle's effect is
|
|
// visible in the tree. Directories dim once none of their files export.
|
|
const fileTones = useMemo(() => {
|
|
const tones: Record<string, FileTreeTone | undefined> = {};
|
|
|
|
function walk(node: FileTreeNode): boolean {
|
|
if (node.kind === "file") {
|
|
const included = checkedFiles.has(node.path);
|
|
if (!included) tones[node.path] = "muted";
|
|
return included;
|
|
}
|
|
let anyIncluded = false;
|
|
for (const child of node.children) {
|
|
if (walk(child)) anyIncluded = true;
|
|
}
|
|
if (!anyIncluded) tones[node.path] = "muted";
|
|
return anyIncluded;
|
|
}
|
|
|
|
for (const node of tree) walk(node);
|
|
return tones;
|
|
}, [tree, checkedFiles]);
|
|
|
|
// Recompute .paperclip.yaml and README.md content whenever checked files
|
|
// change so the preview & download always reflect the current selection.
|
|
const effectiveFiles = useMemo(() => {
|
|
if (!exportData) return {} as Record<string, CompanyPortabilityFileEntry>;
|
|
const filtered = { ...exportData.files };
|
|
|
|
// Filter .paperclip.yaml
|
|
const yamlPath = exportData.paperclipExtensionPath;
|
|
if (yamlPath && typeof exportData.files[yamlPath] === "string") {
|
|
filtered[yamlPath] = filterPaperclipYaml(exportData.files[yamlPath], checkedFiles);
|
|
}
|
|
|
|
// Regenerate README.md based on checked selection
|
|
if (typeof exportData.files["README.md"] === "string") {
|
|
const companyName = exportData.manifest.company?.name ?? selectedCompany?.name ?? "Company";
|
|
const companyDescription = exportData.manifest.company?.description ?? null;
|
|
filtered["README.md"] = generateReadmeFromSelection(
|
|
exportData.manifest,
|
|
checkedFiles,
|
|
companyName,
|
|
companyDescription,
|
|
);
|
|
}
|
|
|
|
return filtered;
|
|
}, [exportData, checkedFiles, selectedCompany?.name]);
|
|
|
|
// The zip is STORE-only, so its size is exactly computable from the filtered
|
|
// file map. Depends only on the selection (not e.g. the tree search), so it
|
|
// recomputes per toggle change rather than per keystroke.
|
|
const estimatedZipBytes = useMemo(() => {
|
|
if (!exportData) return 0;
|
|
return estimateZipArchiveSize(
|
|
filterExportForDownload(exportData.files, checkedFiles, effectiveFiles),
|
|
exportData.rootPath,
|
|
);
|
|
}, [exportData, checkedFiles, effectiveFiles]);
|
|
|
|
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 handleToggleCategory(key: ExportCategoryKey) {
|
|
setCategories((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
}
|
|
|
|
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(skillKey: string) {
|
|
if (!exportData) return;
|
|
const manifestSkill = exportData.manifest.skills.find(
|
|
(skill) => skill.key === skillKey || skill.slug === skillKey,
|
|
);
|
|
const skillPath = manifestSkill?.path ?? `skills/${skillKey}/SKILL.md`;
|
|
if (!(skillPath in exportData.files)) return;
|
|
selectFile(skillPath);
|
|
setExpandedDirs((prev) => {
|
|
const next = new Set(prev);
|
|
next.add("skills");
|
|
const parts = skillPath.split("/").slice(0, -1);
|
|
let current = "";
|
|
for (const part of parts) {
|
|
current = current ? `${current}/${part}` : part;
|
|
next.add(current);
|
|
}
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function handleDownload() {
|
|
if (!exportData || checkedFiles.size === 0 || downloadMutation.isPending) return;
|
|
downloadMutation.mutate();
|
|
}
|
|
|
|
function handleCancelPreview() {
|
|
previewRequestIdRef.current += 1;
|
|
previewAbortControllerRef.current?.abort();
|
|
previewAbortControllerRef.current = null;
|
|
exportPreviewMutation.reset();
|
|
setPreviewCancelled(true);
|
|
}
|
|
|
|
if (!selectedCompanyId) {
|
|
return <EmptyState icon={Package} message="Select an organization to export." />;
|
|
}
|
|
|
|
if (exportPreviewMutation.isPending && !exportData) {
|
|
return <PageSkeleton variant="detail" />;
|
|
}
|
|
|
|
if (previewCancelled && !exportData) {
|
|
return (
|
|
<EmptyState
|
|
icon={Package}
|
|
title="Export preview cancelled"
|
|
message="The preview request was cancelled. Your export settings are unchanged."
|
|
action="Retry preview"
|
|
onAction={startPreviewRequest}
|
|
hideActionIcon
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (exportPreviewMutation.isError && !exportData) {
|
|
return (
|
|
<EmptyState
|
|
icon={Package}
|
|
title="Export preview failed"
|
|
message={previewErrorMessage(exportPreviewMutation.error)}
|
|
description="Retry the preview. You do not need to reload this page."
|
|
action="Retry preview"
|
|
onAction={startPreviewRequest}
|
|
hideActionIcon
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (!exportData) {
|
|
return (
|
|
<EmptyState
|
|
icon={Package}
|
|
title="Export preview unavailable"
|
|
message="No export preview is loaded."
|
|
action="Load preview"
|
|
onAction={startPreviewRequest}
|
|
hideActionIcon
|
|
/>
|
|
);
|
|
}
|
|
|
|
const previewContent = selectedFile
|
|
? (() => {
|
|
return effectiveFiles[selectedFile] ?? null;
|
|
})()
|
|
: null;
|
|
|
|
return (
|
|
<div className="max-w-6xl">
|
|
{/* 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 flex-wrap items-center gap-4 text-sm">
|
|
<span className="font-medium">
|
|
{selectedCompany?.name ?? "Organization"} export
|
|
</span>
|
|
<span className="text-muted-foreground">
|
|
Exporting {selectedCount.toLocaleString()} of {totalFiles.toLocaleString()} file{totalFiles === 1 ? "" : "s"}
|
|
{selectedCount > 0 && ` (~${formatBytes(estimatedZipBytes)})`}
|
|
</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
|
|
|| downloadMutation.isPending
|
|
|| exportPreviewMutation.isPending
|
|
|| exportPreviewMutation.isError
|
|
|| previewCancelled
|
|
}
|
|
>
|
|
<Download className="mr-1.5 h-3.5 w-3.5" />
|
|
{downloadMutation.isPending
|
|
? "Building export..."
|
|
: `Export ${selectedCount.toLocaleString()} 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>
|
|
)}
|
|
|
|
{/* Export fidelity: data the bundle will not carry */}
|
|
{fidelityReport && fidelityReport.warnings.length > 0 && (
|
|
<div className="mx-5 mt-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3">
|
|
<h3 className="mb-1.5 text-xs font-medium">Not included in this export</h3>
|
|
{fidelityReport.warnings.map((warning) => (
|
|
<div
|
|
key={warning.code}
|
|
className={cn(
|
|
"text-xs",
|
|
warning.severity === "blocker" ? "font-medium text-destructive" : "text-amber-500",
|
|
)}
|
|
>
|
|
{warning.message}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Two-column layout */}
|
|
<div className="grid gap-4 xl:h-(--sz-calc-30) xl:grid-cols-(--gtc-25) xl:gap-0">
|
|
<aside className="flex max-h-(--sz-24rem) flex-col overflow-hidden border-b border-border xl:max-h-none xl:border-b-0 xl:border-r">
|
|
<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-4 py-3 shrink-0">
|
|
<h3 className="mb-2 text-xs font-medium text-muted-foreground">What to include</h3>
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5" role="group" aria-label="What to include">
|
|
{EXPORT_CATEGORY_ORDER.map((key) => {
|
|
const isAttachments = key === "attachments";
|
|
const disabled = isAttachments && !isAttachmentsCategoryEnabled(categories);
|
|
const checked = categories[key] && !disabled;
|
|
const count = categoryCounts?.[key] ?? 0;
|
|
const countLoaded = exportData.manifest.includes.issues
|
|
|| (key !== "tasks" && key !== "routines" && key !== "attachments");
|
|
return (
|
|
<label
|
|
key={key}
|
|
className={cn(
|
|
"flex items-center gap-2 text-sm",
|
|
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
|
|
)}
|
|
title={
|
|
disabled
|
|
? "Attachments travel with tasks and routines; re-enable one of them to include attachments."
|
|
: undefined
|
|
}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={checked}
|
|
disabled={disabled}
|
|
onChange={() => handleToggleCategory(key)}
|
|
className="accent-foreground"
|
|
data-export-category={key}
|
|
/>
|
|
<span className="min-w-0 truncate">{EXPORT_CATEGORY_LABELS[key]}</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{countLoaded ? count.toLocaleString() : "—"}
|
|
</span>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
<p className="mt-2 text-xs text-muted-foreground">
|
|
Task and routine history is opt-in because it can be large.
|
|
</p>
|
|
</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"
|
|
data-page-search-target="true"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto">
|
|
<FileTree
|
|
nodes={displayTree}
|
|
selectedFile={selectedFile}
|
|
expandedDirs={expandedDirs}
|
|
onToggleDir={handleToggleDir}
|
|
onSelectFile={selectFile}
|
|
fileTones={fileTones}
|
|
showCheckboxes={false}
|
|
wrapLabels={false}
|
|
/>
|
|
{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 tasks ({visibleTaskChildren} of {totalTaskChildren})
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
<div className="relative min-w-0 overflow-y-auto xl:pl-6">
|
|
<ExportPreviewPane
|
|
selectedFile={selectedFile}
|
|
content={previewContent}
|
|
allFiles={effectiveFiles}
|
|
orgChartPreviewUrl={`/api/companies/${encodeURIComponent(selectedCompanyId)}/org.svg`}
|
|
onSkillClick={handleSkillClick}
|
|
/>
|
|
{exportPreviewMutation.isPending ? (
|
|
<div
|
|
className="absolute inset-0 z-10 flex min-h-(--sz-520px) items-center justify-center bg-background/90 px-6 text-center"
|
|
role="status"
|
|
aria-live="polite"
|
|
aria-busy="true"
|
|
data-export-preview-state="loading"
|
|
>
|
|
<div className="flex max-w-md flex-col items-center gap-3">
|
|
<LoaderCircle className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
<div>
|
|
<p className="text-sm font-medium">Updating export preview…</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
Large task histories can take a minute. You can untick Tasks or cancel this update.
|
|
</p>
|
|
</div>
|
|
<Button type="button" variant="outline" size="sm" onClick={handleCancelPreview}>
|
|
<X />
|
|
Cancel update
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : previewCancelled ? (
|
|
<div
|
|
className="absolute inset-0 z-10 flex min-h-(--sz-520px) items-center justify-center bg-background/90 px-6 text-center"
|
|
data-export-preview-state="cancelled"
|
|
>
|
|
<div className="flex max-w-md flex-col items-center gap-3">
|
|
<div>
|
|
<p className="text-sm font-medium">Preview update cancelled</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
The previous preview remains available. Retry when you are ready.
|
|
</p>
|
|
</div>
|
|
<Button type="button" variant="outline" size="sm" onClick={startPreviewRequest}>
|
|
<RotateCcw />
|
|
Retry preview
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : exportPreviewMutation.isError ? (
|
|
<div
|
|
className="absolute inset-0 z-10 flex min-h-(--sz-520px) items-center justify-center bg-background/90 px-6 text-center"
|
|
role="alert"
|
|
data-export-preview-state="error"
|
|
>
|
|
<div className="flex max-w-md flex-col items-center gap-3">
|
|
<div>
|
|
<p className="text-sm font-medium text-destructive">Export preview failed</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
{previewErrorMessage(exportPreviewMutation.error)}
|
|
</p>
|
|
</div>
|
|
<Button type="button" variant="outline" size="sm" onClick={startPreviewRequest}>
|
|
<RotateCcw />
|
|
Retry preview
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|