import { useState, useEffect, useRef, useCallback } from "react"; import { useNavigate } from "react-router-dom"; import { useVault } from "../App"; import { searchVault, listTemplates, createFromTemplate, exportNoteHtml, type SearchResult, type TemplateInfo } from "../lib/commands"; interface CommandItem { id: string; icon: string; label: string; hint?: string; action: () => void; } export function CommandPalette({ open, onClose }: { open: boolean; onClose: () => void }) { const { vaultPath, notes, navigateToNote, currentNote } = useVault(); const navigate = useNavigate(); const [query, setQuery] = useState(""); const [selectedIndex, setSelectedIndex] = useState(0); const [searchResults, setSearchResults] = useState([]); const [templates, setTemplates] = useState([]); const inputRef = useRef(null); const listRef = useRef(null); // Load templates when opened useEffect(() => { if (open && vaultPath) { listTemplates(vaultPath).then(setTemplates).catch(() => setTemplates([])); } }, [open, vaultPath]); // Focus input when opened useEffect(() => { if (open) { setQuery(""); setSelectedIndex(0); setSearchResults([]); setTimeout(() => inputRef.current?.focus(), 50); } }, [open]); // Debounced search useEffect(() => { if (!query.trim() || query.length < 2) { setSearchResults([]); return; } const timer = setTimeout(async () => { try { const results = await searchVault(vaultPath, query); setSearchResults(results.slice(0, 10)); } catch { setSearchResults([]); } }, 200); return () => clearTimeout(timer); }, [query, vaultPath]); // Build command list const commands: CommandItem[] = []; if (!query.trim()) { commands.push( { id: "daily", icon: "📅", label: "Open Daily Note", hint: "⌘D", action: () => { navigate("/daily"); onClose(); } }, { id: "graph", icon: "🔮", label: "Graph View", hint: "⌘G", action: () => { navigate("/graph"); onClose(); } }, { id: "new", icon: "✏️", label: "New Note", hint: "⌘N", action: () => { const name = prompt("Note name:"); if (name?.trim()) { navigateToNote(name.trim()); onClose(); } } }, ); // Template commands if (templates.length > 0) { for (const t of templates) { commands.push({ id: `template-${t.path}`, icon: "📋", label: `New from template: ${t.name}`, hint: "_templates", action: async () => { const name = prompt(`Note name (from "${t.name}" template):`); if (name?.trim()) { try { const path = await createFromTemplate(vaultPath, t.path, name.trim()); navigate(`/note/${encodeURIComponent(path)}`); onClose(); } catch (e) { console.error("Template creation failed:", e); } } }, }); } } // v0.4 commands commands.push( { id: "calendar", icon: "📆", label: "Calendar View", action: () => { navigate("/calendar"); onClose(); } }, { id: "theme", icon: "🎨", label: "Change Theme", hint: "⌘T", action: () => { onClose(); } }, ); if (currentNote) { commands.push({ id: "export-html", icon: "📤", label: "Export as HTML", action: async () => { try { const html = await exportNoteHtml(vaultPath, currentNote); const blob = new Blob([html], { type: "text/html" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = currentNote.replace(/\.md$/, ".html"); a.click(); URL.revokeObjectURL(url); onClose(); } catch (e) { console.error("Export failed:", e); } }, }); } // v0.5 commands commands.push( { id: "kanban", icon: "📋", label: "Kanban Board", action: () => { navigate("/kanban"); onClose(); } }, { id: "focus", icon: "🧘", label: "Focus Mode", hint: "⌘⇧F", action: () => { onClose(); } }, { id: "search-replace", icon: "🔍", label: "Search & Replace", hint: "⌘H", action: () => { onClose(); } }, ); if (currentNote) { commands.push({ id: "export-pdf", icon: "📄", label: "Export as PDF", action: () => { onClose(); setTimeout(() => window.print(), 200); }, }); } // v0.6 commands commands.push( { id: "flashcards", icon: "🎴", label: "Flashcards", action: () => { navigate("/flashcards"); onClose(); } }, { id: "custom-css", icon: "🎨", label: "Custom CSS", action: () => { onClose(); } }, { id: "save-workspace", icon: "💾", label: "Save Workspace", action: () => { const name = prompt("Workspace name:"); if (name?.trim()) { onClose(); } } }, ); // v0.7 commands commands.push( { id: "database", icon: "📊", label: "Database View", action: () => { navigate("/database"); onClose(); } }, { id: "whiteboard", icon: "🎨", label: "New Whiteboard", action: () => { const name = prompt("Canvas name:"); if (name?.trim()) { navigate(`/whiteboard/${encodeURIComponent(name.trim())}`); onClose(); } } }, { id: "git-sync", icon: "🔀", label: "Git Sync", action: () => { onClose(); } }, ); // v0.8 commands commands.push( { id: "timeline", icon: "📅", label: "Timeline", action: () => { navigate("/timeline"); onClose(); } }, { id: "random-note", icon: "🎲", label: "Random Note", action: async () => { try { const { randomNote } = await import("../lib/commands"); const name = await randomNote(vaultPath); navigate(`/note/${encodeURIComponent(name)}`); } catch { } onClose(); } }, ); // v0.9 commands commands.push( { id: "analytics", icon: "📊", label: "Graph Analytics", action: () => { navigate("/analytics"); onClose(); } }, { id: "import-export", icon: "📦", label: "Import / Export", action: () => { onClose(); } }, { id: "shortcuts", icon: "⌨️", label: "Keyboard Shortcuts", action: () => { onClose(); } }, ); } // Note matches const flatNotes = flattenNoteNames(notes); const queryLower = query.toLowerCase(); if (query.trim()) { const nameMatches = flatNotes .filter(n => n.name.toLowerCase().includes(queryLower)) .slice(0, 5); for (const note of nameMatches) { commands.push({ id: `note-${note.path}`, icon: "📄", label: note.name, hint: note.path, action: () => { navigate(`/note/${encodeURIComponent(note.path)}`); onClose(); }, }); } // Content search results for (const result of searchResults) { // Don't duplicate filename matches if (commands.some(c => c.id === `note-${result.path}`)) continue; commands.push({ id: `search-${result.path}`, icon: "🔍", label: result.name, hint: result.context.substring(0, 80), action: () => { navigate(`/note/${encodeURIComponent(result.path)}`); onClose(); }, }); } } // Clamp selection const maxIndex = Math.max(0, commands.length - 1); const safeIndex = Math.min(selectedIndex, maxIndex); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === "ArrowDown") { e.preventDefault(); setSelectedIndex(i => Math.min(i + 1, maxIndex)); } else if (e.key === "ArrowUp") { e.preventDefault(); setSelectedIndex(i => Math.max(i - 1, 0)); } else if (e.key === "Enter") { e.preventDefault(); commands[safeIndex]?.action(); } else if (e.key === "Escape") { e.preventDefault(); onClose(); } }, [commands, safeIndex, maxIndex, onClose]); // Scroll selected item into view useEffect(() => { const list = listRef.current; if (!list) return; const item = list.children[safeIndex] as HTMLElement | undefined; item?.scrollIntoView({ block: "nearest" }); }, [safeIndex]); if (!open) return null; return (
e.stopPropagation()} onKeyDown={handleKeyDown}> {/* Search input */}
{ setQuery(e.target.value); setSelectedIndex(0); }} /> ESC
{/* Results */}
{commands.length === 0 && query.trim() && (
No results for "{query}"
)} {commands.map((cmd, i) => ( ))}
{/* Footer */}
↑↓ navigate ↵ select esc close
); } /* ── Helpers ────────────────────────────────────────────────── */ function flattenNoteNames( entries: { name: string; path: string; is_dir: boolean; children?: any[] }[] ): { name: string; path: string }[] { const result: { name: string; path: string }[] = []; for (const entry of entries) { if (entry.is_dir && entry.children) { result.push(...flattenNoteNames(entry.children)); } else if (!entry.is_dir) { result.push({ name: entry.name, path: entry.path }); } } return result; }