diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3bca7cf..57d2139 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3424,6 +3424,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab7f01e9310a820edd31c80fde3cae445295adde21a3f9416517d7d65015b971" dependencies = [ "paste", + "serde_json", "specta-macros", "thiserror 1.0.69", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1c338f9..c1f7a46 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -30,7 +30,7 @@ dirs = "5" notify = "7" tauri-plugin-global-shortcut = "2" libc = "0.2" -specta = { version = "=2.0.0-rc.22", features = ["derive"] } +specta = { version = "=2.0.0-rc.22", features = ["derive", "serde_json"] } tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] } specta-typescript = "0.0.9" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fbe50cd..a62cf98 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -109,6 +109,9 @@ pub fn run() { tracing::info!("Starting Mission Control"); // Build tauri-specta builder for type-safe IPC + // Note: read_state/write_state/clear_state use serde_json::Value which doesn't + // implement specta::Type, so they're excluded from the builder but kept in + // the invoke_handler via generate_handler! let builder = Builder::::new().commands(collect_commands![ commands::greet, commands::get_lore_status, @@ -116,9 +119,6 @@ pub fn run() { commands::sync_now, commands::reconcile, commands::quick_capture, - commands::read_state, - commands::write_state, - commands::clear_state, ]); // Export TypeScript bindings in debug builds diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx new file mode 100644 index 0000000..80d6959 --- /dev/null +++ b/src/components/CommandPalette.tsx @@ -0,0 +1,362 @@ +/** + * CommandPalette -- ⌘K quick filter and search overlay. + * + * Features: + * - Text search across all items + * - Filter commands (type:, stale:) + * - Keyboard navigation (arrows + Enter) + * - Click to select + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import type { FocusItem, FocusItemType } from "@/lib/types"; + +/** Filter command definition */ +interface FilterCommand { + prefix: string; + options: { value: string; label: string }[]; +} + +const FILTER_COMMANDS: FilterCommand[] = [ + { + prefix: "type:", + options: [ + { value: "mr_review", label: "type:mr_review" }, + { value: "mr_authored", label: "type:mr_authored" }, + { value: "issue", label: "type:issue" }, + { value: "manual", label: "type:manual" }, + ], + }, + { + prefix: "stale:", + options: [ + { value: "1", label: "stale:1d" }, + { value: "3", label: "stale:3d" }, + { value: "7", label: "stale:7d" }, + ], + }, +]; + +/** Filter criteria passed to onFilter */ +export interface FilterCriteria { + type?: FocusItemType; + minAge?: number; +} + +export interface CommandPaletteProps { + isOpen: boolean; + items: FocusItem[]; + onFilter: (criteria: FilterCriteria) => void; + onSelect: (itemId: string) => void; + onClose: () => void; +} + +export function CommandPalette({ + isOpen, + items, + onFilter, + onSelect, + onClose, +}: CommandPaletteProps): React.ReactElement | null { + const [search, setSearch] = useState(""); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const inputRef = useRef(null); + + // Reset state when opening + useEffect(() => { + if (isOpen) { + setSearch(""); + setHighlightedIndex(-1); + requestAnimationFrame(() => { + inputRef.current?.focus(); + }); + } + }, [isOpen]); + + // Handle Escape key + useEffect(() => { + if (!isOpen) return; + + function handleKeyDown(e: KeyboardEvent): void { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + } + } + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose]); + + // Check if search matches a command prefix + const activeCommand = useMemo(() => { + for (const cmd of FILTER_COMMANDS) { + if (search.toLowerCase().startsWith(cmd.prefix)) { + return cmd; + } + } + return null; + }, [search]); + + // Filter items by text search + const filteredItems = useMemo(() => { + if (activeCommand) { + return []; // Commands mode, don't show items + } + if (!search.trim()) { + return items.slice(0, 10); // Show first 10 when empty + } + const lower = search.toLowerCase(); + return items + .filter((item) => item.title.toLowerCase().includes(lower)) + .slice(0, 10); + }, [search, items, activeCommand]); + + // Compute all selectable options (items or command options) + const selectableOptions = useMemo(() => { + if (activeCommand) { + return activeCommand.options.map((opt) => ({ + id: `cmd:${opt.label}`, + label: opt.label, + value: opt.value, + type: "command" as const, + })); + } + return filteredItems.map((item) => ({ + id: item.id, + label: item.title, + value: item.id, + type: "item" as const, + itemType: item.type, + })); + }, [activeCommand, filteredItems]); + + // Reset highlight when options change + useEffect(() => { + setHighlightedIndex(-1); + }, [selectableOptions]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + setHighlightedIndex((prev) => + prev < selectableOptions.length - 1 ? prev + 1 : prev + ); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : prev)); + } else if (e.key === "Enter") { + e.preventDefault(); + const option = selectableOptions[highlightedIndex]; + if (option) { + handleOptionSelect(option); + } else if (selectableOptions.length === 1) { + // Auto-select if only one option + handleOptionSelect(selectableOptions[0]); + } + } + }, + [selectableOptions, highlightedIndex] + ); + + const handleOptionSelect = useCallback( + (option: (typeof selectableOptions)[0]) => { + if (option.type === "command") { + // Parse the command and emit filter + if (option.id.startsWith("cmd:type:")) { + onFilter({ type: option.value as FocusItemType }); + } else if (option.id.startsWith("cmd:stale:")) { + onFilter({ minAge: parseInt(option.value, 10) }); + } + } else { + onSelect(option.value); + } + onClose(); + }, + [onFilter, onSelect, onClose] + ); + + const handleBackdropClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }, + [onClose] + ); + + const getTypeIcon = (type: FocusItemType): string => { + switch (type) { + case "mr_review": + return "⟲"; + case "mr_authored": + return "⟳"; + case "issue": + return "●"; + case "manual": + return "✓"; + } + }; + + if (!isOpen) return null; + + return ( + + + {/* Backdrop */} + + + {/* Card */} + + {/* Search input */} +
+ setSearch(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search or filter... (try type: or stale:)" + className="w-full bg-transparent text-sm text-zinc-100 placeholder-zinc-500 outline-none" + autoFocus + /> +
+ + {/* Results */} +
+ {activeCommand ? ( + // Command options +
+
+ {activeCommand.prefix} +
+ {activeCommand.options.map((opt, index) => ( + + ))} +
+ ) : ( + <> + {/* Items */} + {filteredItems.length > 0 ? ( +
+
+ Items +
+ {filteredItems.map((item, index) => ( + + ))} +
+ ) : search.trim() ? ( +
+ No results found +
+ ) : null} + + {/* Command suggestions (when not searching) */} + {!search.trim() && ( +
+
+ Commands +
+ {FILTER_COMMANDS.map((cmd) => ( + + ))} +
+ )} + + )} +
+ + {/* Footer hint */} +
+

+ ↑↓ to navigate · Enter to select · Esc to close +

+
+
+
+
+ ); +} diff --git a/tests/components/CommandPalette.test.tsx b/tests/components/CommandPalette.test.tsx new file mode 100644 index 0000000..e9dfbd5 --- /dev/null +++ b/tests/components/CommandPalette.test.tsx @@ -0,0 +1,295 @@ +/** + * Tests for CommandPalette component. + * + * TDD: These tests define the expected behavior before implementation. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { CommandPalette } from "@/components/CommandPalette"; +import type { FocusItem } from "@/lib/types"; + +const mockItems: FocusItem[] = [ + { + id: "mr-1", + title: "Fix auth token refresh", + type: "mr_review", + project: "platform/core", + url: "https://gitlab.com/platform/core/-/merge_requests/1", + iid: 1, + updatedAt: "2026-02-25T10:00:00Z", + contextQuote: null, + requestedBy: "alice", + }, + { + id: "mr-2", + title: "Update README", + type: "mr_authored", + project: "platform/docs", + url: "https://gitlab.com/platform/docs/-/merge_requests/2", + iid: 2, + updatedAt: "2026-02-20T10:00:00Z", + contextQuote: null, + requestedBy: null, + }, + { + id: "issue-1", + title: "Auth bug in production", + type: "issue", + project: "platform/core", + url: "https://gitlab.com/platform/core/-/issues/42", + iid: 42, + updatedAt: "2026-02-24T10:00:00Z", + contextQuote: null, + requestedBy: null, + }, + { + id: "manual-1", + title: "Review quarterly goals", + type: "manual", + project: "personal", + url: "", + iid: 0, + updatedAt: null, + contextQuote: null, + requestedBy: null, + }, +]; + +describe("CommandPalette", () => { + const defaultProps = { + isOpen: true, + items: mockItems, + onFilter: vi.fn(), + onSelect: vi.fn(), + onClose: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders when open", () => { + render(); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect( + screen.getByPlaceholderText(/Search or filter/i) + ).toBeInTheDocument(); + }); + + it("does not render when closed", () => { + render(); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("closes on Escape key", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + render(); + + await user.keyboard("{Escape}"); + + expect(onClose).toHaveBeenCalled(); + }); + + it("closes when clicking backdrop", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + render(); + + // Click the backdrop (outer div) + await user.click(screen.getByTestId("command-palette-backdrop")); + + expect(onClose).toHaveBeenCalled(); + }); + + describe("text search", () => { + it("filters items by text", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByRole("textbox"), "auth"); + + // Should show items containing "auth" + expect(screen.getByText("Fix auth token refresh")).toBeInTheDocument(); + expect(screen.getByText("Auth bug in production")).toBeInTheDocument(); + // Should not show items without "auth" + expect(screen.queryByText("Update README")).not.toBeInTheDocument(); + expect( + screen.queryByText("Review quarterly goals") + ).not.toBeInTheDocument(); + }); + + it("shows all items when search is empty", () => { + render(); + + // Should show all items (or top 10) + expect(screen.getByText("Fix auth token refresh")).toBeInTheDocument(); + expect(screen.getByText("Update README")).toBeInTheDocument(); + }); + + it("selects item and closes on click", async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + const onClose = vi.fn(); + render( + + ); + + await user.click(screen.getByText("Fix auth token refresh")); + + expect(onSelect).toHaveBeenCalledWith("mr-1"); + expect(onClose).toHaveBeenCalled(); + }); + + it("selects item on Enter when typing", async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + const onClose = vi.fn(); + render( + + ); + + // Type to filter to one item + await user.type(screen.getByRole("textbox"), "README"); + await user.keyboard("{Enter}"); + + expect(onSelect).toHaveBeenCalledWith("mr-2"); + expect(onClose).toHaveBeenCalled(); + }); + }); + + describe("filter commands", () => { + it("shows filter command options when typing type:", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByRole("textbox"), "type:"); + + expect(screen.getByText("type:mr_review")).toBeInTheDocument(); + expect(screen.getByText("type:issue")).toBeInTheDocument(); + expect(screen.getByText("type:manual")).toBeInTheDocument(); + }); + + it("filters by type when type: command used", async () => { + const user = userEvent.setup(); + const onFilter = vi.fn(); + const onClose = vi.fn(); + render( + + ); + + await user.type(screen.getByRole("textbox"), "type:"); + await user.click(screen.getByText("type:mr_review")); + + expect(onFilter).toHaveBeenCalledWith({ type: "mr_review" }); + expect(onClose).toHaveBeenCalled(); + }); + + it("shows staleness filter options when typing stale:", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByRole("textbox"), "stale:"); + + expect(screen.getByText("stale:1d")).toBeInTheDocument(); + expect(screen.getByText("stale:3d")).toBeInTheDocument(); + expect(screen.getByText("stale:7d")).toBeInTheDocument(); + }); + + it("filters by staleness when stale: command used", async () => { + const user = userEvent.setup(); + const onFilter = vi.fn(); + const onClose = vi.fn(); + render( + + ); + + await user.type(screen.getByRole("textbox"), "stale:"); + await user.click(screen.getByText("stale:7d")); + + expect(onFilter).toHaveBeenCalledWith({ minAge: 7 }); + expect(onClose).toHaveBeenCalled(); + }); + + it("shows command suggestions before typing a command", () => { + render(); + + // Commands section header should exist + expect(screen.getByText("Commands")).toBeInTheDocument(); + // Command buttons should be present + expect(screen.getByText("type:...")).toBeInTheDocument(); + expect(screen.getByText("stale:...")).toBeInTheDocument(); + }); + }); + + describe("keyboard navigation", () => { + it("navigates down with arrow key", async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole("textbox"); + await user.click(input); + await user.keyboard("{ArrowDown}"); + + // First item should be highlighted + const items = screen.getAllByRole("option"); + expect(items[0]).toHaveAttribute("data-highlighted", "true"); + }); + + it("navigates up with arrow key", async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole("textbox"); + await user.click(input); + await user.keyboard("{ArrowDown}{ArrowDown}{ArrowUp}"); + + // First item should be highlighted again + const items = screen.getAllByRole("option"); + expect(items[0]).toHaveAttribute("data-highlighted", "true"); + }); + + it("selects highlighted item on Enter", async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + render(); + + const input = screen.getByRole("textbox"); + await user.click(input); + await user.keyboard("{ArrowDown}{Enter}"); + + expect(onSelect).toHaveBeenCalledWith("mr-1"); + }); + }); + + describe("empty state", () => { + it("shows no results message when no items match", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByRole("textbox"), "xyznonexistent"); + + expect(screen.getByText(/No results found/i)).toBeInTheDocument(); + }); + }); +});