Export: tasks in top-level folder, smart search expansion

- Move all tasks to top-level tasks/ folder (no longer nested under
  projects/slug/tasks/). The project slug is still in the frontmatter
  for association.
- Search auto-expands parent dirs of matched files so matches are
  always visible in the tree
- Restores previous expansion state when search is cleared
- All files already loaded in memory — search works across everything
  with no pagination limit

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-03-15 16:39:11 -05:00
parent cf30ddb924
commit ef652a2766
2 changed files with 54 additions and 5 deletions

View File

@@ -1758,9 +1758,8 @@ export function companyPortabilityService(db: Db) {
for (const issue of selectedIssueRows) {
const taskSlug = taskSlugByIssueId.get(issue.id)!;
const projectSlug = issue.projectId ? (projectSlugById.get(issue.projectId) ?? null) : null;
const taskPath = projectSlug
? `projects/${projectSlug}/tasks/${taskSlug}/TASK.md`
: `tasks/${taskSlug}/TASK.md`;
// All tasks go in top-level tasks/ folder, never nested under projects/
const taskPath = `tasks/${taskSlug}/TASK.md`;
const assigneeSlug = issue.assigneeAgentId ? (idToSlug.get(issue.assigneeAgentId) ?? null) : null;
files[taskPath] = buildMarkdown(
{

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import type { CompanyPortabilityExportResult } from "@paperclipai/shared";
import { useCompany } from "../context/CompanyContext";
@@ -126,6 +126,27 @@ function filterTree(nodes: FileTreeNode[], query: string): FileTreeNode[] {
.filter((n): n is FileTreeNode => n !== null);
}
/** Collect all ancestor dir paths for files that match a filter */
function collectMatchedParentDirs(nodes: FileTreeNode[], query: string): Set<string> {
const dirs = new Set<string>();
const lower = query.toLowerCase();
function walk(node: FileTreeNode, ancestors: string[]) {
if (node.kind === "file") {
if (node.name.toLowerCase().includes(lower) || node.path.toLowerCase().includes(lower)) {
for (const a of ancestors) dirs.add(a);
}
} else {
for (const child of node.children) {
walk(child, [...ancestors, node.path]);
}
}
}
for (const node of nodes) walk(node, []);
return dirs;
}
/** Sort tree: checked files first, then unchecked */
function sortByChecked(nodes: FileTreeNode[], checkedFiles: Set<string>): FileTreeNode[] {
return nodes.map((node) => {
@@ -510,6 +531,7 @@ export function CompanyExport() {
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
const [checkedFiles, setCheckedFiles] = useState<Set<string>>(new Set());
const [treeSearch, setTreeSearch] = useState("");
const savedExpandedRef = useRef<Set<string> | null>(null);
useEffect(() => {
setBreadcrumbs([
@@ -634,6 +656,34 @@ export function CompanyExport() {
});
}
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 handleDownload() {
if (!exportData) return;
downloadTar(exportData, checkedFiles);
@@ -729,7 +779,7 @@ export function CompanyExport() {
<input
type="text"
value={treeSearch}
onChange={(e) => setTreeSearch(e.target.value)}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder="Search files..."
className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>