/** * AddCollectionDialog - Dialog for adding new document collections. * * Features: * - Path input with folder icon * - Auto-generates name from folder path * - Collapsible advanced options (pattern, exclude) * - Shows IndexingProgress after creation */ import { AlertCircleIcon, CheckCircle2Icon, ChevronDownIcon, FolderIcon, FolderPlusIcon, Loader2Icon, } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { apiFetch } from "../hooks/use-api"; import { cn } from "../lib/utils"; import { IndexingProgress } from "./IndexingProgress"; import { Button } from "./ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "./ui/collapsible"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "./ui/dialog"; import { Input } from "./ui/input"; export interface AddCollectionDialogProps { /** Whether the dialog is open */ open: boolean; /** Callback when open state changes */ onOpenChange: (open: boolean) => void; /** Callback when collection created successfully */ onSuccess?: () => void; /** Optional path to prefill when opening from onboarding shortcuts */ initialPath?: string; } interface CreateCollectionResponse { collection: { name: string; path: string; }; jobId: string; } interface ImportPreview { path: string; suggestedName: string; folderType: "obsidian-vault" | "notes-folder" | "mixed-docs" | "binary-heavy"; counts: { markdown: number; text: number; pdf: number; office: number; other: number; folders: number; scannedFiles: number; truncated: boolean; }; signals: string[]; guidance: string[]; conflicts: string[]; } type DialogState = "form" | "submitting" | "success" | "error"; /** Extract folder name from path */ function getFolderName(path: string): string { const trimmed = path.replace(/\/+$/, ""); const parts = trimmed.split("/"); return parts.at(-1) || ""; } /** Validate path is absolute */ function isAbsolutePath(path: string): boolean { return path.startsWith("/") || /^[A-Z]:\\/.test(path); } export function AddCollectionDialog({ initialPath, open, onOpenChange, onSuccess, }: AddCollectionDialogProps) { // Form state const [path, setPath] = useState(""); const [name, setName] = useState(""); const [pattern, setPattern] = useState("**/*"); const [exclude, setExclude] = useState("node_modules/**"); const [advancedOpen, setAdvancedOpen] = useState(false); // Submission state const [state, setState] = useState("form"); const [error, setError] = useState(null); const [jobId, setJobId] = useState(null); const [createdName, setCreatedName] = useState(null); const [preview, setPreview] = useState(null); const [previewLoading, setPreviewLoading] = useState(false); // Auto-fill name from path const derivedName = name || getFolderName(path); // Reset form when dialog closes useEffect(() => { if (!open) { const timer = setTimeout(() => { setPath(""); setName(""); setPattern("**/*"); setExclude("node_modules/**"); setAdvancedOpen(false); setState("form"); setError(null); setJobId(null); setCreatedName(null); setPreview(null); setPreviewLoading(false); }, 200); return () => clearTimeout(timer); } }, [open]); useEffect(() => { if (open && initialPath && state === "form") { setPath(initialPath); } }, [initialPath, open, state]); // Validation const pathError = path && !isAbsolutePath(path) ? "Path must be absolute" : null; const previewConflict = preview?.conflicts[0] ?? null; const isValid = path.trim() && !pathError && !previewConflict; useEffect(() => { if (!open || !path.trim() || pathError) { setPreview(null); setPreviewLoading(false); return; } let cancelled = false; const timer = setTimeout(() => { setPreviewLoading(true); void apiFetch<{ preview: ImportPreview }>("/api/import/preview", { method: "POST", body: JSON.stringify({ path: path.trim(), name: derivedName || undefined, }), }).then(({ data }) => { if (cancelled) { return; } setPreview(data?.preview ?? null); setPreviewLoading(false); }); }, 250); return () => { cancelled = true; clearTimeout(timer); }; }, [derivedName, open, path, pathError]); // Submit handler const handleSubmit = useCallback(async () => { if (!isValid) return; setState("submitting"); setError(null); const { data, error: err } = await apiFetch( "/api/collections", { method: "POST", body: JSON.stringify({ path: path.trim(), name: derivedName || undefined, pattern: pattern.trim() || "**/*", exclude: exclude.trim() || undefined, }), } ); if (err) { setState("error"); setError(err); return; } if (data) { setState("success"); setJobId(data.jobId); setCreatedName(data.collection.name); } }, [isValid, path, derivedName, pattern, exclude]); // Handle indexing complete const handleIndexComplete = () => { onSuccess?.(); onOpenChange(false); }; // Keyboard submit const handleKeyDown = (e: React.KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && isValid) { e.preventDefault(); void handleSubmit(); } }; return ( {state === "success" ? "Collection added" : "Add collection"} {state === "form" && ( Add a folder to index. Documents will be searchable immediately. )} {/* Form state */} {(state === "form" || state === "submitting") && (
{/* Path input */}
setPath(e.target.value)} placeholder="/Users/you/Documents/notes" value={path} />
{pathError && (

{pathError}

)} {path && !pathError && (

Will be indexed as:{" "} {derivedName || "unnamed"}

)}
{(previewLoading || preview) && (
Import preview
{previewLoading && ( )}
{preview && (

{preview.folderType === "obsidian-vault" ? "Looks like an Obsidian vault." : preview.folderType === "notes-folder" ? "Looks like a note-heavy folder." : preview.folderType === "binary-heavy" ? "Looks binary-heavy." : "Looks like a mixed work-doc folder."}

{preview.counts.markdown} markdown
{preview.counts.text} text
{preview.counts.pdf} pdf
{preview.counts.office} office docs
{preview.signals.length > 0 && (
{preview.signals.map((signal) => (

{signal}

))}
)} {preview.guidance.map((item) => (

{item}

))} {preview.conflicts.map((conflict) => (

{conflict}

))}
)}
)} {/* Name override */}
setName(e.target.value)} placeholder={getFolderName(path) || "my-notes"} value={name} />
{/* Advanced options */} {/* Pattern */}
setPattern(e.target.value)} placeholder="**/*" value={pattern} />

Default: **/* (all supported document files)

{/* Exclude */}
setExclude(e.target.value)} placeholder="node_modules/**, .git/**" value={exclude} />
)} {/* Error state */} {state === "error" && error && (

Failed to add collection

{error}

)} {/* Success state */} {state === "success" && (

Collection "{createdName}" added

Indexing documents...

{jobId && (
)}
)} {/* Footer */} {(state === "form" || state === "submitting") && ( <> )} {state === "success" && ( )}
); }