diff --git a/src/App.tsx b/src/App.tsx index d942ca2..487477d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,14 +1,18 @@ import { HomeShell } from "./home/HomeShell"; import { IdeShell } from "./ide/shell/IdeShell"; import { Toasts } from "./ide/shell/Toasts"; +import { CommandPalette } from "./palette/CommandPalette"; +import { useCommandPaletteShortcut } from "./palette/useCommandPaletteShortcut"; import { useIdeStore } from "./state/ideStore"; import "./styles/global.css"; function App() { const view = useIdeStore((s) => s.view); + useCommandPaletteShortcut(); return ( <> {view === "home" ? : } + ); diff --git a/src/home/sections/HomeSearch.tsx b/src/home/sections/HomeSearch.tsx index cf5e09b..88aac7e 100644 --- a/src/home/sections/HomeSearch.tsx +++ b/src/home/sections/HomeSearch.tsx @@ -1,16 +1,49 @@ +import { useRef } from "react"; + +import { shortcutLabel } from "../../palette/formatShortcut"; +import { usePaletteStore } from "../../palette/paletteStore"; import { IconSearch } from "../icons"; export function HomeSearch() { + const openPalette = usePaletteStore((s) => s.openPalette); + const paletteOpen = usePaletteStore((s) => s.open); + const barRef = useRef(null); + + const handleClick = () => { + // Measure where the search bar sits so the palette can animate from + // that exact position — the FLIP technique. Custom properties are set + // on and consumed by @keyframes palette-enter in global.css. + const rect = barRef.current?.getBoundingClientRect(); + if (rect) { + const root = document.documentElement; + const paletteTop = 88; // palette resting position (CSS `top: 88px`) + const paletteCenterX = window.innerWidth / 2; + const paletteWidth = Math.min(720, window.innerWidth - 48); + + root.style.setProperty("--palette-dy", `${rect.top - paletteTop}px`); + root.style.setProperty("--palette-dx", `${rect.left + rect.width / 2 - paletteCenterX}px`); + root.style.setProperty("--palette-sx", `${rect.width / paletteWidth}`); + } + openPalette(); + }; + return ( -
+
- Ctrl K + {shortcutLabel("K")}
); } diff --git a/src/palette/CommandPalette.test.tsx b/src/palette/CommandPalette.test.tsx new file mode 100644 index 0000000..b30081d --- /dev/null +++ b/src/palette/CommandPalette.test.tsx @@ -0,0 +1,123 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { act } from "react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { useProjectStore } from "../projects/projectStore"; +import { useProjectModalStore } from "../projects/ui/modalStore"; +import { useIdeStore } from "../state/ideStore"; +import { CommandPalette } from "./CommandPalette"; +import { usePaletteStore } from "./paletteStore"; + +describe("CommandPalette", () => { + beforeEach(() => { + // Reset all stores the palette reads so tests are order-independent. + useProjectStore.setState({ projects: [], activeProjectId: null }); + useProjectModalStore.setState({ + createProject: null, + renameProject: null, + deleteProject: null, + }); + useIdeStore.setState({ view: "home", openFiles: [], activeFile: null }); + usePaletteStore.setState({ open: false, query: "" }); + }); + + afterEach(() => { + // vitest doesn't auto-cleanup testing-library renders (no `globals: true` + // in `vitest.config.ts`), so we do it manually to stop previous mounts + // from leaking their DOM into the next test. + cleanup(); + }); + + test("renders nothing when the palette is closed", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + test("renders nothing when the view is not home even if the store is open", () => { + useIdeStore.setState({ view: "ide" }); + usePaletteStore.setState({ open: true }); + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + test("renders the dialog with the input focused when opened on home", () => { + usePaletteStore.setState({ open: true }); + render(); + const input = screen.getByRole("textbox", { name: /rechercher/i }); + expect(input).toBeTruthy(); + expect(document.activeElement).toBe(input); + }); + + test("typing filters the visible items", () => { + usePaletteStore.setState({ open: true }); + const { container } = render(); + const input = screen.getByRole("textbox", { name: /rechercher/i }) as HTMLInputElement; + // "Nouveau projet" is a command title; typing "nouv" should keep it. + // The row renders with `` around the matched substring, so the + // title is split across text nodes — use the aggregated textContent. + fireEvent.change(input, { target: { value: "nouv" } }); + expect(container.textContent).toContain("Nouveau projet"); + // Typing a query that matches nothing should hit the empty-state copy. + fireEvent.change(input, { target: { value: "xyzzy" } }); + expect(container.textContent).toContain("Aucun résultat"); + }); + + test("Enter invokes the selected command and closes the palette", () => { + const runSpy = vi.fn(); + useProjectModalStore.setState({ + openCreate: runSpy, + openRename: () => {}, + openDelete: () => {}, + closeAll: () => {}, + } as unknown as ReturnType); + usePaletteStore.setState({ open: true, query: "nouv" }); + render(); + const input = screen.getByRole("textbox", { name: /rechercher/i }) as HTMLInputElement; + act(() => { + fireEvent.keyDown(input, { key: "Enter" }); + }); + expect(runSpy).toHaveBeenCalledTimes(1); + expect(usePaletteStore.getState().open).toBe(false); + }); + + test("Escape closes the palette via useModalA11y", () => { + usePaletteStore.setState({ open: true }); + render(); + act(() => { + fireEvent.keyDown(document, { key: "Escape" }); + }); + expect(usePaletteStore.getState().open).toBe(false); + }); + + test("arrow down moves the selected row across groups", () => { + // Seed a project so the "Reprendre" group has at least one row, giving + // us two groups (Reprendre + Commandes + Leçons) to navigate across. + useProjectStore.setState({ + projects: [ + { + id: "p1", + name: "alpha", + path: "/tmp/alpha", + kind: "blank", + tags: [], + createdAt: 0, + lastOpenedAt: 1, + fileCount: 0, + lineCount: 0, + }, + ], + activeProjectId: null, + }); + usePaletteStore.setState({ open: true }); + render(); + const firstRow = document.querySelector(".palette-item--sel"); + expect(firstRow?.textContent).toContain("alpha"); + const input = screen.getByRole("textbox", { name: /rechercher/i }) as HTMLInputElement; + act(() => { + fireEvent.keyDown(input, { key: "ArrowDown" }); + }); + const nextRow = document.querySelector(".palette-item--sel"); + expect(nextRow?.textContent).not.toContain("alpha"); + }); +}); diff --git a/src/palette/CommandPalette.tsx b/src/palette/CommandPalette.tsx new file mode 100644 index 0000000..8229d1a --- /dev/null +++ b/src/palette/CommandPalette.tsx @@ -0,0 +1,195 @@ +import { useEffect, useRef, useState, type ReactElement } from "react"; + +import { useModalA11y } from "../hooks/useModalA11y"; +import { useProjectStore } from "../projects/projectStore"; +import { useIdeStore } from "../state/ideStore"; +import { vfs } from "../vfs/VirtualFS"; +import { filterPaletteItems } from "./filterPaletteItems"; +import { highlightMatch } from "./highlightMatch"; +import { usePaletteStore } from "./paletteStore"; +import { buildAllGroups } from "./sources"; +import type { PaletteGroup, PaletteItem } from "./types"; + +// Top-level entry: decides whether the dialog should mount at all. We keep +// the hook-laden body in a child so the keydown listener / focus trap from +// `useModalA11y` only run while the palette is actually on screen. +export function CommandPalette(): ReactElement | null { + const open = usePaletteStore((s) => s.open); + const view = useIdeStore((s) => s.view); + if (!open || view !== "home") return null; + return ; +} + +function PaletteDialog(): ReactElement { + const query = usePaletteStore((s) => s.query); + const setQuery = usePaletteStore((s) => s.setQuery); + const closePalette = usePaletteStore((s) => s.closePalette); + + // Subscribing to the relevant slices forces a re-render when the live data + // changes while the palette is open (e.g. a project finishes loading). The + // returned values are unused — `buildAllGroups()` reads fresh state below. + useProjectStore((s) => s.projects); + useProjectStore((s) => s.activeProjectId); + + const [, bumpVfsVersion] = useState(0); + useEffect(() => { + return vfs.on("change", (event) => { + // Only structural changes alter the file list; plain writes fire on + // every editor keystroke and don't change anything we care about here. + if (event.kind !== "write") bumpVfsVersion((v) => v + 1); + }); + }, []); + + const groups: PaletteGroup[] = buildAllGroups(); + const filteredGroups: PaletteGroup[] = groups + .map((group) => ({ ...group, items: filterPaletteItems(group.items, query) })) + .filter((group) => group.items.length > 0); + const flatItems: PaletteItem[] = filteredGroups.flatMap((g) => g.items); + + const [selectedIndex, setSelectedIndex] = useState(0); + // Reset selection when the query changes — React 19's supported pattern for + // "derive state from a prop change" without an effect. Comparing a mirror + // state against the incoming query is lint-safe because setState happens + // during render rather than inside a useEffect body. + const [lastQuery, setLastQuery] = useState(query); + if (lastQuery !== query) { + setLastQuery(query); + setSelectedIndex(0); + } + // Clamp at render time so a shrunk filtered list can't reference an + // out-of-range row. Keeps `selectedIndex` as the user's intent while + // `activeIndex` is what we render and run against. + const activeIndex = flatItems.length === 0 ? 0 : Math.min(selectedIndex, flatItems.length - 1); + + const dialogRef = useRef(null); + const inputRef = useRef(null); + useModalA11y(dialogRef, { onClose: closePalette, initialFocus: inputRef }); + + const runSelected = () => { + const item = flatItems[activeIndex]; + if (!item) return; + // Close first so the view flip happens against a clean palette state; any + // downstream action (setView, openFile, openCreate) runs after. + closePalette(); + void item.onSelect(); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + setSelectedIndex((i) => Math.min(i + 1, Math.max(flatItems.length - 1, 0))); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + setSelectedIndex((i) => Math.max(i - 1, 0)); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + runSelected(); + } + }; + + const hasQuery = query.trim().length > 0; + + return ( + <> +