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:
parent
2f7da835de
commit
cf30ddb924
3 changed files with 112 additions and 32 deletions
|
|
@ -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" ||
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"} ·
|
{totalFiles} file{totalFiles === 1 ? "" : "s"} ·
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue