Export/import UX polish: search, scroll, sort, null cleanup

Export page:
- Sort files before directories so PROJECT.md appears above tasks/
- Tasks unchecked by default (only agents, projects, skills checked)
- Add inline search input to filter files in the tree
- Checked files sort above unchecked for easier scanning
- Sidebar scrolls independently from content preview pane

Import page:
- Match file-before-dir sort order
- Independent sidebar/content scrolling
- Skip null values in frontmatter preview

Backend:
- Skip null/undefined fields in exported frontmatter (no more
  "owner: null" in PROJECT.md files)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-03-15 15:54:26 -05:00
parent 2f7da835de
commit cf30ddb924
3 changed files with 112 additions and 32 deletions

View file

@ -580,8 +580,9 @@ function renderYamlBlock(value: unknown, indentLevel: number): string[] {
function renderFrontmatter(frontmatter: Record<string, unknown>) { function renderFrontmatter(frontmatter: Record<string, unknown>) {
const lines: string[] = ["---"]; const lines: string[] = ["---"];
for (const [key, value] of orderedYamlEntries(frontmatter)) { for (const [key, value] of orderedYamlEntries(frontmatter)) {
// Skip null/undefined values — don't export empty fields
if (value === null || value === undefined) continue;
const scalar = const scalar =
value === null ||
typeof value === "string" || typeof value === "string" ||
typeof value === "boolean" || typeof value === "boolean" ||
typeof value === "number" || typeof value === "number" ||

View file

@ -20,6 +20,7 @@ import {
FolderOpen, FolderOpen,
Info, Info,
Package, Package,
Search,
} from "lucide-react"; } from "lucide-react";
// ── Tree types ──────────────────────────────────────────────────────── // ── Tree types ────────────────────────────────────────────────────────
@ -64,7 +65,8 @@ function buildFileTree(files: Record<string, string>): FileTreeNode[] {
function sortNode(node: FileTreeNode) { function sortNode(node: FileTreeNode) {
node.children.sort((a, b) => { node.children.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1; // 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); return a.name.localeCompare(b.name);
}); });
node.children.forEach(sortNode); node.children.forEach(sortNode);
@ -100,6 +102,48 @@ function fileIcon(name: string) {
return FileText; 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) */
function isTaskPath(filePath: string): boolean {
return /(?:^|\/)tasks\//.test(filePath);
}
/** 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);
}
/** 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);
});
}
// ── Tar helpers (reused from CompanySettings) ───────────────────────── // ── Tar helpers (reused from CompanySettings) ─────────────────────────
function createTarArchive(files: Record<string, string>, rootPath: string): Uint8Array { function createTarArchive(files: Record<string, string>, rootPath: string): Uint8Array {
@ -345,6 +389,11 @@ function parseFrontmatter(content: string): { data: FrontmatterData; body: strin
if (kvMatch) { if (kvMatch) {
const key = kvMatch[1]; const key = kvMatch[1];
const val = kvMatch[2].trim().replace(/^["']|["']$/g, ""); const val = kvMatch[2].trim().replace(/^["']|["']$/g, "");
// Skip null values
if (val === "null") {
currentKey = null;
continue;
}
if (val) { if (val) {
data[key] = val; data[key] = val;
currentKey = null; currentKey = null;
@ -460,6 +509,7 @@ export function CompanyExport() {
const [selectedFile, setSelectedFile] = useState<string | null>(null); const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set()); const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
const [checkedFiles, setCheckedFiles] = useState<Set<string>>(new Set()); const [checkedFiles, setCheckedFiles] = useState<Set<string>>(new Set());
const [treeSearch, setTreeSearch] = useState("");
useEffect(() => { useEffect(() => {
setBreadcrumbs([ setBreadcrumbs([
@ -476,9 +526,12 @@ export function CompanyExport() {
}), }),
onSuccess: (result) => { onSuccess: (result) => {
setExportData(result); setExportData(result);
// Check all files by default // Check all files EXCEPT tasks by default
const allFiles = new Set(Object.keys(result.files)); const checked = new Set<string>();
setCheckedFiles(allFiles); for (const filePath of Object.keys(result.files)) {
if (!isTaskPath(filePath)) checked.add(filePath);
}
setCheckedFiles(checked);
// Expand top-level dirs // Expand top-level dirs
const tree = buildFileTree(result.files); const tree = buildFileTree(result.files);
const topDirs = new Set<string>(); const topDirs = new Set<string>();
@ -512,6 +565,12 @@ export function CompanyExport() {
[exportData], [exportData],
); );
const displayTree = useMemo(() => {
let result = tree;
if (treeSearch) result = filterTree(result, treeSearch);
return sortByChecked(result, checkedFiles);
}, [tree, treeSearch, checkedFiles]);
const totalFiles = useMemo(() => countFiles(tree), [tree]); const totalFiles = useMemo(() => countFiles(tree), [tree]);
const selectedCount = checkedFiles.size; const selectedCount = checkedFiles.size;
@ -656,25 +715,39 @@ export function CompanyExport() {
)} )}
{/* Two-column layout */} {/* Two-column layout */}
<div className="grid min-h-[calc(100vh-12rem)] gap-0 xl:grid-cols-[19rem_minmax(0,1fr)]"> <div className="grid h-[calc(100vh-12rem)] gap-0 xl:grid-cols-[19rem_minmax(0,1fr)]">
<aside className="border-r border-border"> <aside className="flex flex-col border-r border-border overflow-hidden">
<div className="border-b border-border px-4 py-3"> <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"> <p className="text-xs text-muted-foreground">
{totalFiles} file{totalFiles === 1 ? "" : "s"} in {exportData.rootPath} {totalFiles} file{totalFiles === 1 ? "" : "s"} in {exportData.rootPath}
</p> </p>
</div> </div>
<ExportFileTree <div className="border-b border-border px-3 py-2 shrink-0">
nodes={tree} <div className="flex items-center gap-2 rounded-md border border-border px-2 py-1">
selectedFile={selectedFile} <Search className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
expandedDirs={expandedDirs} <input
checkedFiles={checkedFiles} type="text"
onToggleDir={handleToggleDir} value={treeSearch}
onSelectFile={setSelectedFile} onChange={(e) => setTreeSearch(e.target.value)}
onToggleCheck={handleToggleCheck} 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">
<ExportFileTree
nodes={displayTree}
selectedFile={selectedFile}
expandedDirs={expandedDirs}
checkedFiles={checkedFiles}
onToggleDir={handleToggleDir}
onSelectFile={setSelectedFile}
onToggleCheck={handleToggleCheck}
/>
</div>
</aside> </aside>
<div className="min-w-0 pl-6"> <div className="min-w-0 overflow-y-auto pl-6">
<ExportPreviewPane selectedFile={selectedFile} content={previewContent} /> <ExportPreviewPane selectedFile={selectedFile} content={previewContent} />
</div> </div>
</div> </div>

View file

@ -73,7 +73,7 @@ function buildFileTree(files: Record<string, string>, actionMap: Map<string, str
function sortNode(node: FileTreeNode) { function sortNode(node: FileTreeNode) {
node.children.sort((a, b) => { node.children.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1; if (a.kind !== b.kind) return a.kind === "file" ? -1 : 1;
return a.name.localeCompare(b.name); return a.name.localeCompare(b.name);
}); });
node.children.forEach(sortNode); node.children.forEach(sortNode);
@ -204,6 +204,10 @@ function parseFrontmatter(content: string): { data: FrontmatterData; body: strin
if (kvMatch) { if (kvMatch) {
const key = kvMatch[1]; const key = kvMatch[1];
const val = kvMatch[2].trim().replace(/^["']|["']$/g, ""); const val = kvMatch[2].trim().replace(/^["']|["']$/g, "");
if (val === "null") {
currentKey = null;
continue;
}
if (val) { if (val) {
data[key] = val; data[key] = val;
currentKey = null; currentKey = null;
@ -918,9 +922,9 @@ export function CompanyImport() {
)} )}
{/* Two-column layout */} {/* Two-column layout */}
<div className="grid min-h-[calc(100vh-16rem)] gap-0 xl:grid-cols-[19rem_minmax(0,1fr)]"> <div className="grid h-[calc(100vh-16rem)] gap-0 xl:grid-cols-[19rem_minmax(0,1fr)]">
<aside className="border-r border-border"> <aside className="flex flex-col border-r border-border overflow-hidden">
<div className="border-b border-border px-4 py-3"> <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"> <p className="text-xs text-muted-foreground">
{totalFiles} file{totalFiles === 1 ? "" : "s"} &middot; {totalFiles} file{totalFiles === 1 ? "" : "s"} &middot;
@ -930,17 +934,19 @@ export function CompanyImport() {
{" "}{importPreview.plan.issuePlans.length} task{importPreview.plan.issuePlans.length === 1 ? "" : "s"} {" "}{importPreview.plan.issuePlans.length} task{importPreview.plan.issuePlans.length === 1 ? "" : "s"}
</p> </p>
</div> </div>
<ImportFileTree <div className="flex-1 overflow-y-auto">
nodes={tree} <ImportFileTree
selectedFile={selectedFile} nodes={tree}
expandedDirs={expandedDirs} selectedFile={selectedFile}
checkedFiles={checkedFiles} expandedDirs={expandedDirs}
onToggleDir={handleToggleDir} checkedFiles={checkedFiles}
onSelectFile={setSelectedFile} onToggleDir={handleToggleDir}
onToggleCheck={handleToggleCheck} onSelectFile={setSelectedFile}
/> onToggleCheck={handleToggleCheck}
/>
</div>
</aside> </aside>
<div className="min-w-0 pl-6"> <div className="min-w-0 overflow-y-auto pl-6">
<ImportPreviewPane <ImportPreviewPane
selectedFile={selectedFile} selectedFile={selectedFile}
content={previewContent} content={previewContent}