refactor: extract shared PackageFileTree component for import/export
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>
This commit is contained in:
305
ui/src/components/PackageFileTree.tsx
Normal file
305
ui/src/components/PackageFileTree.tsx
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
FileCode2,
|
||||||
|
FileText,
|
||||||
|
Folder,
|
||||||
|
FolderOpen,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
// ── Tree types ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type FileTreeNode = {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
kind: "dir" | "file";
|
||||||
|
children: FileTreeNode[];
|
||||||
|
/** Optional per-node metadata (e.g. import action) */
|
||||||
|
action?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TREE_BASE_INDENT = 16;
|
||||||
|
const TREE_STEP_INDENT = 24;
|
||||||
|
const TREE_ROW_HEIGHT_CLASS = "min-h-9";
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function buildFileTree(
|
||||||
|
files: Record<string, string>,
|
||||||
|
actionMap?: Map<string, string>,
|
||||||
|
): FileTreeNode[] {
|
||||||
|
const root: FileTreeNode = { name: "", path: "", kind: "dir", children: [] };
|
||||||
|
|
||||||
|
for (const filePath of Object.keys(files)) {
|
||||||
|
const segments = filePath.split("/").filter(Boolean);
|
||||||
|
let current = root;
|
||||||
|
let currentPath = "";
|
||||||
|
for (let i = 0; i < segments.length; i++) {
|
||||||
|
const segment = segments[i];
|
||||||
|
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
||||||
|
const isLeaf = i === segments.length - 1;
|
||||||
|
let next = current.children.find((c) => c.name === segment);
|
||||||
|
if (!next) {
|
||||||
|
next = {
|
||||||
|
name: segment,
|
||||||
|
path: currentPath,
|
||||||
|
kind: isLeaf ? "file" : "dir",
|
||||||
|
children: [],
|
||||||
|
action: isLeaf ? (actionMap?.get(filePath) ?? null) : null,
|
||||||
|
};
|
||||||
|
current.children.push(next);
|
||||||
|
}
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortNode(node: FileTreeNode) {
|
||||||
|
node.children.sort((a, b) => {
|
||||||
|
// Files before directories so PROJECT.md appears above tasks/
|
||||||
|
if (a.kind !== b.kind) return a.kind === "file" ? -1 : 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
node.children.forEach(sortNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
sortNode(root);
|
||||||
|
return root.children;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Frontmatter helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type FrontmatterData = Record<string, string | string[]>;
|
||||||
|
|
||||||
|
export 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 === "null") {
|
||||||
|
currentKey = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── File tree component ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function PackageFileTree({
|
||||||
|
nodes,
|
||||||
|
selectedFile,
|
||||||
|
expandedDirs,
|
||||||
|
checkedFiles,
|
||||||
|
onToggleDir,
|
||||||
|
onSelectFile,
|
||||||
|
onToggleCheck,
|
||||||
|
renderFileExtra,
|
||||||
|
fileRowClassName,
|
||||||
|
depth = 0,
|
||||||
|
}: {
|
||||||
|
nodes: FileTreeNode[];
|
||||||
|
selectedFile: string | null;
|
||||||
|
expandedDirs: Set<string>;
|
||||||
|
checkedFiles: Set<string>;
|
||||||
|
onToggleDir: (path: string) => void;
|
||||||
|
onSelectFile: (path: string) => void;
|
||||||
|
onToggleCheck: (path: string, kind: "file" | "dir") => void;
|
||||||
|
/** Optional extra content rendered at the end of each file row (e.g. action badge) */
|
||||||
|
renderFileExtra?: (node: FileTreeNode, checked: boolean) => ReactNode;
|
||||||
|
/** Optional additional className for file rows */
|
||||||
|
fileRowClassName?: (node: FileTreeNode, checked: boolean) => string | undefined;
|
||||||
|
depth?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{nodes.map((node) => {
|
||||||
|
const expanded = node.kind === "dir" && expandedDirs.has(node.path);
|
||||||
|
if (node.kind === "dir") {
|
||||||
|
const childFiles = collectAllPaths(node.children, "file");
|
||||||
|
const allChecked = [...childFiles].every((p) => checkedFiles.has(p));
|
||||||
|
const someChecked = [...childFiles].some((p) => checkedFiles.has(p));
|
||||||
|
return (
|
||||||
|
<div key={node.path}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"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,
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
paddingInlineStart: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT - 8}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="flex items-center pl-2">
|
||||||
|
<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 ? (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{expanded && (
|
||||||
|
<PackageFileTree
|
||||||
|
nodes={node.children}
|
||||||
|
selectedFile={selectedFile}
|
||||||
|
expandedDirs={expandedDirs}
|
||||||
|
checkedFiles={checkedFiles}
|
||||||
|
onToggleDir={onToggleDir}
|
||||||
|
onSelectFile={onSelectFile}
|
||||||
|
onToggleCheck={onToggleCheck}
|
||||||
|
renderFileExtra={renderFileExtra}
|
||||||
|
fileRowClassName={fileRowClassName}
|
||||||
|
depth={depth + 1}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileIcon = fileIcon(node.name);
|
||||||
|
const checked = checkedFiles.has(node.path);
|
||||||
|
const extraClassName = fileRowClassName?.(node, checked);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={node.path}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground cursor-pointer",
|
||||||
|
TREE_ROW_HEIGHT_CLASS,
|
||||||
|
node.path === selectedFile && "text-foreground bg-accent/20",
|
||||||
|
extraClassName,
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
paddingInlineStart: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT - 8}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
{renderFileExtra?.(node, checked)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,95 +12,20 @@ import { MarkdownBody } from "../components/MarkdownBody";
|
|||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { createZipArchive } from "../lib/zip";
|
import { createZipArchive } from "../lib/zip";
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
Download,
|
Download,
|
||||||
FileCode2,
|
|
||||||
FileText,
|
|
||||||
Folder,
|
|
||||||
FolderOpen,
|
|
||||||
Package,
|
Package,
|
||||||
Search,
|
Search,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
// ── Tree types ────────────────────────────────────────────────────────
|
type FileTreeNode,
|
||||||
|
type FrontmatterData,
|
||||||
type FileTreeNode = {
|
buildFileTree,
|
||||||
name: string;
|
countFiles,
|
||||||
path: string;
|
collectAllPaths,
|
||||||
kind: "dir" | "file";
|
parseFrontmatter,
|
||||||
children: FileTreeNode[];
|
FRONTMATTER_FIELD_LABELS,
|
||||||
};
|
PackageFileTree,
|
||||||
|
} from "../components/PackageFileTree";
|
||||||
const TREE_BASE_INDENT = 16;
|
|
||||||
const TREE_STEP_INDENT = 24;
|
|
||||||
const TREE_ROW_HEIGHT_CLASS = "min-h-9";
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function buildFileTree(files: Record<string, string>): FileTreeNode[] {
|
|
||||||
const root: FileTreeNode = { name: "", path: "", kind: "dir", children: [] };
|
|
||||||
|
|
||||||
for (const filePath of Object.keys(files)) {
|
|
||||||
const segments = filePath.split("/").filter(Boolean);
|
|
||||||
let current = root;
|
|
||||||
let currentPath = "";
|
|
||||||
for (let i = 0; i < segments.length; i++) {
|
|
||||||
const segment = segments[i];
|
|
||||||
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
|
||||||
const isLeaf = i === segments.length - 1;
|
|
||||||
let next = current.children.find((c) => c.name === segment);
|
|
||||||
if (!next) {
|
|
||||||
next = {
|
|
||||||
name: segment,
|
|
||||||
path: currentPath,
|
|
||||||
kind: isLeaf ? "file" : "dir",
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
current.children.push(next);
|
|
||||||
}
|
|
||||||
current = next;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortNode(node: FileTreeNode) {
|
|
||||||
node.children.sort((a, b) => {
|
|
||||||
// Files before directories so PROJECT.md appears above tasks/
|
|
||||||
if (a.kind !== b.kind) return a.kind === "file" ? -1 : 1;
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
});
|
|
||||||
node.children.forEach(sortNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
sortNode(root);
|
|
||||||
return root.children;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns true if the path looks like a task file (e.g. tasks/slug/TASK.md or projects/x/tasks/slug/TASK.md) */
|
/** 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 {
|
function isTaskPath(filePath: string): boolean {
|
||||||
@@ -353,210 +278,7 @@ function downloadZip(
|
|||||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── File tree component ───────────────────────────────────────────────
|
// ── Frontmatter card (export-specific: skill click support) ──────────
|
||||||
|
|
||||||
function ExportFileTree({
|
|
||||||
nodes,
|
|
||||||
selectedFile,
|
|
||||||
expandedDirs,
|
|
||||||
checkedFiles,
|
|
||||||
onToggleDir,
|
|
||||||
onSelectFile,
|
|
||||||
onToggleCheck,
|
|
||||||
depth = 0,
|
|
||||||
}: {
|
|
||||||
nodes: FileTreeNode[];
|
|
||||||
selectedFile: string | null;
|
|
||||||
expandedDirs: Set<string>;
|
|
||||||
checkedFiles: Set<string>;
|
|
||||||
onToggleDir: (path: string) => void;
|
|
||||||
onSelectFile: (path: string) => void;
|
|
||||||
onToggleCheck: (path: string, kind: "file" | "dir") => void;
|
|
||||||
depth?: number;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{nodes.map((node) => {
|
|
||||||
const expanded = node.kind === "dir" && expandedDirs.has(node.path);
|
|
||||||
if (node.kind === "dir") {
|
|
||||||
const childFiles = collectAllPaths(node.children, "file");
|
|
||||||
const allChecked = [...childFiles].every((p) => checkedFiles.has(p));
|
|
||||||
const someChecked = [...childFiles].some((p) => checkedFiles.has(p));
|
|
||||||
return (
|
|
||||||
<div key={node.path}>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"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,
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
paddingInlineStart: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT - 8}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
className="flex items-center pl-2"
|
|
||||||
>
|
|
||||||
<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 ? (
|
|
||||||
<ChevronDown className="h-3.5 w-3.5" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{expanded && (
|
|
||||||
<ExportFileTree
|
|
||||||
nodes={node.children}
|
|
||||||
selectedFile={selectedFile}
|
|
||||||
expandedDirs={expandedDirs}
|
|
||||||
checkedFiles={checkedFiles}
|
|
||||||
onToggleDir={onToggleDir}
|
|
||||||
onSelectFile={onSelectFile}
|
|
||||||
onToggleCheck={onToggleCheck}
|
|
||||||
depth={depth + 1}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const FileIcon = fileIcon(node.name);
|
|
||||||
const checked = checkedFiles.has(node.path);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={node.path}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center gap-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground cursor-pointer",
|
|
||||||
TREE_ROW_HEIGHT_CLASS,
|
|
||||||
node.path === selectedFile && "text-foreground bg-accent/20",
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
paddingInlineStart: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT - 8}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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;
|
|
||||||
|
|
||||||
// List item under current key
|
|
||||||
if (trimmed.startsWith("- ") && currentKey) {
|
|
||||||
if (!currentList) currentList = [];
|
|
||||||
currentList.push(trimmed.slice(2).trim().replace(/^["']|["']$/g, ""));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flush previous list
|
|
||||||
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, "");
|
|
||||||
// Skip null values
|
|
||||||
if (val === "null") {
|
|
||||||
currentKey = null;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (val) {
|
|
||||||
data[key] = val;
|
|
||||||
currentKey = null;
|
|
||||||
} else {
|
|
||||||
currentKey = key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flush trailing list
|
|
||||||
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({
|
function FrontmatterCard({
|
||||||
data,
|
data,
|
||||||
@@ -914,7 +636,7 @@ export function CompanyExport() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<ExportFileTree
|
<PackageFileTree
|
||||||
nodes={displayTree}
|
nodes={displayTree}
|
||||||
selectedFile={selectedFile}
|
selectedFile={selectedFile}
|
||||||
expandedDirs={expandedDirs}
|
expandedDirs={expandedDirs}
|
||||||
|
|||||||
@@ -15,99 +15,25 @@ 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";
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
Download,
|
Download,
|
||||||
FileCode2,
|
|
||||||
FileText,
|
|
||||||
Folder,
|
|
||||||
FolderOpen,
|
|
||||||
Github,
|
Github,
|
||||||
Link2,
|
Link2,
|
||||||
Package,
|
Package,
|
||||||
Upload,
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Field } from "../components/agent-config-primitives";
|
import { Field } from "../components/agent-config-primitives";
|
||||||
|
import {
|
||||||
|
type FileTreeNode,
|
||||||
|
type FrontmatterData,
|
||||||
|
buildFileTree,
|
||||||
|
countFiles,
|
||||||
|
collectAllPaths,
|
||||||
|
parseFrontmatter,
|
||||||
|
FRONTMATTER_FIELD_LABELS,
|
||||||
|
PackageFileTree,
|
||||||
|
} from "../components/PackageFileTree";
|
||||||
|
|
||||||
// ── Tree types ────────────────────────────────────────────────────────
|
// ── Import-specific helpers ───────────────────────────────────────────
|
||||||
|
|
||||||
type FileTreeNode = {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
kind: "dir" | "file";
|
|
||||||
children: FileTreeNode[];
|
|
||||||
action?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const TREE_BASE_INDENT = 16;
|
|
||||||
const TREE_STEP_INDENT = 24;
|
|
||||||
const TREE_ROW_HEIGHT_CLASS = "min-h-9";
|
|
||||||
|
|
||||||
// ── Tree helpers ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function buildFileTree(files: Record<string, string>, actionMap: Map<string, string>): FileTreeNode[] {
|
|
||||||
const root: FileTreeNode = { name: "", path: "", kind: "dir", children: [] };
|
|
||||||
|
|
||||||
for (const filePath of Object.keys(files)) {
|
|
||||||
const segments = filePath.split("/").filter(Boolean);
|
|
||||||
let current = root;
|
|
||||||
let currentPath = "";
|
|
||||||
for (let i = 0; i < segments.length; i++) {
|
|
||||||
const segment = segments[i];
|
|
||||||
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
|
||||||
const isLeaf = i === segments.length - 1;
|
|
||||||
let next = current.children.find((c) => c.name === segment);
|
|
||||||
if (!next) {
|
|
||||||
next = {
|
|
||||||
name: segment,
|
|
||||||
path: currentPath,
|
|
||||||
kind: isLeaf ? "file" : "dir",
|
|
||||||
children: [],
|
|
||||||
action: isLeaf ? (actionMap.get(filePath) ?? null) : null,
|
|
||||||
};
|
|
||||||
current.children.push(next);
|
|
||||||
}
|
|
||||||
current = next;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortNode(node: FileTreeNode) {
|
|
||||||
node.children.sort((a, b) => {
|
|
||||||
if (a.kind !== b.kind) return a.kind === "file" ? -1 : 1;
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
});
|
|
||||||
node.children.forEach(sortNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
sortNode(root);
|
|
||||||
return root.children;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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 */
|
/** Build a map from file path → planned action (create/update/skip) using the manifest + plan */
|
||||||
function buildActionMap(preview: CompanyPortabilityPreviewResult): Map<string, string> {
|
function buildActionMap(preview: CompanyPortabilityPreviewResult): Map<string, string> {
|
||||||
@@ -171,75 +97,6 @@ const ACTION_COLORS: Record<string, string> = {
|
|||||||
none: "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 === "null") {
|
|
||||||
currentKey = null;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
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 }) {
|
function FrontmatterCard({ data }: { data: FrontmatterData }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-border bg-accent/20 px-4 py-3 mb-4">
|
<div className="rounded-md border border-border bg-accent/20 px-4 py-3 mb-4">
|
||||||
@@ -272,146 +129,25 @@ function FrontmatterCard({ data }: { data: FrontmatterData }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── File tree component ───────────────────────────────────────────────
|
// ── Import file tree customization ───────────────────────────────────
|
||||||
|
|
||||||
function ImportFileTree({
|
function renderImportFileExtra(node: FileTreeNode, checked: boolean) {
|
||||||
nodes,
|
if (!node.action) return null;
|
||||||
selectedFile,
|
const actionColor = ACTION_COLORS[node.action] ?? ACTION_COLORS.skip;
|
||||||
expandedDirs,
|
|
||||||
checkedFiles,
|
|
||||||
onToggleDir,
|
|
||||||
onSelectFile,
|
|
||||||
onToggleCheck,
|
|
||||||
depth = 0,
|
|
||||||
}: {
|
|
||||||
nodes: FileTreeNode[];
|
|
||||||
selectedFile: string | null;
|
|
||||||
expandedDirs: Set<string>;
|
|
||||||
checkedFiles: Set<string>;
|
|
||||||
onToggleDir: (path: string) => void;
|
|
||||||
onSelectFile: (path: string) => void;
|
|
||||||
onToggleCheck: (path: string, kind: "file" | "dir") => void;
|
|
||||||
depth?: number;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<span className={cn(
|
||||||
{nodes.map((node) => {
|
"shrink-0 rounded-full border px-2 py-0.5 text-[10px] uppercase tracking-wide",
|
||||||
const expanded = node.kind === "dir" && expandedDirs.has(node.path);
|
actionColor,
|
||||||
if (node.kind === "dir") {
|
)}>
|
||||||
const childFiles = collectAllPaths(node.children, "file");
|
{checked ? node.action : "skip"}
|
||||||
const allChecked = [...childFiles].every((p) => checkedFiles.has(p));
|
</span>
|
||||||
const someChecked = [...childFiles].some((p) => checkedFiles.has(p));
|
|
||||||
return (
|
|
||||||
<div key={node.path}>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"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,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<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 ? (
|
|
||||||
<ChevronDown className="h-3.5 w-3.5" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{expanded && (
|
|
||||||
<ImportFileTree
|
|
||||||
nodes={node.children}
|
|
||||||
selectedFile={selectedFile}
|
|
||||||
expandedDirs={expandedDirs}
|
|
||||||
checkedFiles={checkedFiles}
|
|
||||||
onToggleDir={onToggleDir}
|
|
||||||
onSelectFile={onSelectFile}
|
|
||||||
onToggleCheck={onToggleCheck}
|
|
||||||
depth={depth + 1}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const FileIcon = fileIcon(node.name);
|
|
||||||
const checked = checkedFiles.has(node.path);
|
|
||||||
const actionColor = node.action ? (ACTION_COLORS[node.action] ?? ACTION_COLORS.skip) : "";
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={node.path}
|
|
||||||
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 cursor-pointer",
|
|
||||||
TREE_ROW_HEIGHT_CLASS,
|
|
||||||
node.path === selectedFile && "text-foreground bg-accent/20",
|
|
||||||
!checked && "opacity-50",
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
paddingInlineStart: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT - 8}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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 && (
|
|
||||||
<span className={cn(
|
|
||||||
"shrink-0 rounded-full border px-2 py-0.5 text-[10px] uppercase tracking-wide",
|
|
||||||
actionColor,
|
|
||||||
)}>
|
|
||||||
{checked ? node.action : "skip"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function importFileRowClassName(_node: FileTreeNode, checked: boolean) {
|
||||||
|
return !checked ? "opacity-50" : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Preview pane ──────────────────────────────────────────────────────
|
// ── Preview pane ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ImportPreviewPane({
|
function ImportPreviewPane({
|
||||||
@@ -942,16 +678,9 @@ export function CompanyImport() {
|
|||||||
<aside className="flex flex-col border-r border-border overflow-hidden">
|
<aside className="flex flex-col border-r border-border overflow-hidden">
|
||||||
<div className="border-b border-border px-4 py-3 shrink-0">
|
<div className="border-b border-border px-4 py-3 shrink-0">
|
||||||
<h2 className="text-base font-semibold">Package files</h2>
|
<h2 className="text-base font-semibold">Package files</h2>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{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.issuePlans.length} task{importPreview.plan.issuePlans.length === 1 ? "" : "s"}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<ImportFileTree
|
<PackageFileTree
|
||||||
nodes={tree}
|
nodes={tree}
|
||||||
selectedFile={selectedFile}
|
selectedFile={selectedFile}
|
||||||
expandedDirs={expandedDirs}
|
expandedDirs={expandedDirs}
|
||||||
@@ -959,6 +688,8 @@ export function CompanyImport() {
|
|||||||
onToggleDir={handleToggleDir}
|
onToggleDir={handleToggleDir}
|
||||||
onSelectFile={setSelectedFile}
|
onSelectFile={setSelectedFile}
|
||||||
onToggleCheck={handleToggleCheck}
|
onToggleCheck={handleToggleCheck}
|
||||||
|
renderFileExtra={renderImportFileExtra}
|
||||||
|
fileRowClassName={importFileRowClassName}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
Reference in New Issue
Block a user