From 03cc8f971a1afa10f8a3be02061e3031a8e7e2f0 Mon Sep 17 00:00:00 2001 From: srfwb <264158739+srfwb@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:33:43 +0200 Subject: [PATCH 01/12] feat(palette): add group order and labels constants --- src/palette/types.ts | 49 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/palette/types.ts diff --git a/src/palette/types.ts b/src/palette/types.ts new file mode 100644 index 0000000..7fd6b79 --- /dev/null +++ b/src/palette/types.ts @@ -0,0 +1,49 @@ +// One row in the palette dropdown — a project to open, a file to load, a +// command to run, etc. Sources produce these and the overlay renders them. +export interface PaletteItem { + /** Stable per-render identifier used for selection state and React keys. */ + id: string; + /** Group bucket (e.g. "Jump back in", "Files", "Commands"). */ + group: PaletteGroupKey; + /** Main title — what the user reads first. */ + title: string; + /** Optional secondary text shown muted to the right of the title. */ + subtitle?: string; + /** Short label for the type pill on the right (e.g. "file", "project"). */ + pill?: string; + /** Two-letter glyph and tone for the leading icon. */ + icon: { glyph: string; tone: PaletteIconTone }; + /** Lower-case haystack used by the filter — defaults to title + subtitle. */ + searchTokens?: string; + /** Triggered when the user picks the row (Enter or click). */ + onSelect: () => void | Promise; +} + +export type PaletteGroupKey = "jump" | "files" | "commands" | "lessons"; + +export interface PaletteGroup { + key: PaletteGroupKey; + label: string; + items: PaletteItem[]; +} + +// Order groups appear in the palette body — matches the Claude Design handoff. +export const PALETTE_GROUP_ORDER: readonly PaletteGroupKey[] = [ + "jump", + "files", + "commands", + "lessons", +]; + +// Centralised user-facing labels (French). Keep in one place so the overlay +// and the tests can agree. +export const PALETTE_GROUP_LABELS: Record = { + jump: "Reprendre", + files: "Fichiers", + commands: "Commandes", + lessons: "Leçons", +}; + +// Soft palette shared with project cards / template cards so glyphs read at a +// glance. New entries can extend this union. +export type PaletteIconTone = "html" | "css" | "js" | "game" | "blank" | "cmd" | "lesson"; From ad2fa1dd3905e47510dc33d0a2aaee08c1add7ed Mon Sep 17 00:00:00 2001 From: srfwb <264158739+srfwb@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:33:48 +0200 Subject: [PATCH 02/12] feat(palette): add iconForFile, filterPaletteItems and highlightMatch helpers --- src/palette/filterPaletteItems.test.ts | 71 ++++++++++++++++++++++++++ src/palette/filterPaletteItems.ts | 39 ++++++++++++++ src/palette/highlightMatch.test.tsx | 36 +++++++++++++ src/palette/highlightMatch.tsx | 18 +++++++ src/palette/iconForFile.test.ts | 29 +++++++++++ src/palette/iconForFile.ts | 20 ++++++++ 6 files changed, 213 insertions(+) create mode 100644 src/palette/filterPaletteItems.test.ts create mode 100644 src/palette/filterPaletteItems.ts create mode 100644 src/palette/highlightMatch.test.tsx create mode 100644 src/palette/highlightMatch.tsx create mode 100644 src/palette/iconForFile.test.ts create mode 100644 src/palette/iconForFile.ts diff --git a/src/palette/filterPaletteItems.test.ts b/src/palette/filterPaletteItems.test.ts new file mode 100644 index 0000000..2879e5a --- /dev/null +++ b/src/palette/filterPaletteItems.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "vitest"; + +import { filterPaletteItems } from "./filterPaletteItems"; +import type { PaletteItem } from "./types"; + +function makeItem(partial: Partial & Pick): PaletteItem { + return { + group: "files", + icon: { glyph: "·", tone: "blank" }, + onSelect: () => {}, + ...partial, + }; +} + +describe("filterPaletteItems", () => { + test("returns the full input when the query is empty or whitespace", () => { + const items = [makeItem({ id: "a", title: "Alpha" }), makeItem({ id: "b", title: "Bravo" })]; + expect(filterPaletteItems(items, "")).toEqual(items); + expect(filterPaletteItems(items, " ")).toEqual(items); + }); + + test("drops items with no match", () => { + const items = [makeItem({ id: "a", title: "Alpha" }), makeItem({ id: "b", title: "Bravo" })]; + expect(filterPaletteItems(items, "zz")).toEqual([]); + }); + + test("is case insensitive", () => { + const items = [makeItem({ id: "a", title: "INDEX.html" })]; + expect(filterPaletteItems(items, "ind")).toHaveLength(1); + expect(filterPaletteItems(items, "INDEX")).toHaveLength(1); + }); + + test("title prefix ranks above title substring", () => { + const items = [ + makeItem({ id: "mid", title: "ready-steady-go" }), + makeItem({ id: "pre", title: "steady-state" }), + ]; + const result = filterPaletteItems(items, "steady"); + expect(result.map((i) => i.id)).toEqual(["pre", "mid"]); + }); + + test("title substring ranks above subtitle substring", () => { + const items = [ + makeItem({ id: "sub", title: "projet", subtitle: "contient index" }), + makeItem({ id: "title", title: "index.html" }), + ]; + const result = filterPaletteItems(items, "index"); + expect(result.map((i) => i.id)).toEqual(["title", "sub"]); + }); + + test("preserves input order for ties (stable sort)", () => { + const items = [ + makeItem({ id: "first", title: "alpha" }), + makeItem({ id: "second", title: "alpha-two" }), + makeItem({ id: "third", title: "alpha-three" }), + ]; + const result = filterPaletteItems(items, "alpha"); + expect(result.map((i) => i.id)).toEqual(["first", "second", "third"]); + }); + + test("respects an explicit searchTokens override", () => { + const items = [ + makeItem({ + id: "with-tokens", + title: "Nothing visible", + searchTokens: "hidden-needle", + }), + ]; + expect(filterPaletteItems(items, "needle")).toHaveLength(1); + }); +}); diff --git a/src/palette/filterPaletteItems.ts b/src/palette/filterPaletteItems.ts new file mode 100644 index 0000000..3a85061 --- /dev/null +++ b/src/palette/filterPaletteItems.ts @@ -0,0 +1,39 @@ +import type { PaletteItem } from "./types"; + +// Rank buckets — lower is better (we use Array.prototype.sort stable order). +const RANK_TITLE_PREFIX = 0; +const RANK_TITLE_SUBSTR = 1; +const RANK_SUB_SUBSTR = 2; +const RANK_MISS = 3; + +function haystack(item: PaletteItem): string { + return (item.searchTokens ?? `${item.title} ${item.subtitle ?? ""}`).toLowerCase(); +} + +function rankOf(item: PaletteItem, needle: string): number { + const title = item.title.toLowerCase(); + if (title.startsWith(needle)) return RANK_TITLE_PREFIX; + if (title.includes(needle)) return RANK_TITLE_SUBSTR; + const sub = (item.subtitle ?? "").toLowerCase(); + if (sub.includes(needle)) return RANK_SUB_SUBSTR; + if (haystack(item).includes(needle)) return RANK_SUB_SUBSTR; + return RANK_MISS; +} + +// Case-insensitive substring filter with a light three-tier ranking so that +// title matches surface above subtitle matches. Pure function — safe to call +// from a render without memoisation for reasonable list sizes. +export function filterPaletteItems(items: PaletteItem[], query: string): PaletteItem[] { + const needle = query.trim().toLowerCase(); + if (needle === "") return items; + + const ranked: Array<{ item: PaletteItem; rank: number; index: number }> = []; + items.forEach((item, index) => { + const rank = rankOf(item, needle); + if (rank === RANK_MISS) return; + ranked.push({ item, rank, index }); + }); + + ranked.sort((a, b) => (a.rank === b.rank ? a.index - b.index : a.rank - b.rank)); + return ranked.map((r) => r.item); +} diff --git a/src/palette/highlightMatch.test.tsx b/src/palette/highlightMatch.test.tsx new file mode 100644 index 0000000..48311f4 --- /dev/null +++ b/src/palette/highlightMatch.test.tsx @@ -0,0 +1,36 @@ +// @vitest-environment jsdom +import { render } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; + +import { highlightMatch } from "./highlightMatch"; + +describe("highlightMatch", () => { + test("returns the raw text when the query is empty", () => { + expect(highlightMatch("index.html", "")).toEqual(["index.html"]); + expect(highlightMatch("index.html", " ")).toEqual(["index.html"]); + }); + + test("returns the raw text when the query is not found", () => { + expect(highlightMatch("index.html", "zzz")).toEqual(["index.html"]); + }); + + test("wraps the matched substring in a mark tag preserving original case", () => { + const { container } = render({highlightMatch("INDEX.html", "ind")}); + const mark = container.querySelector("mark"); + expect(mark).toBeTruthy(); + expect(mark?.textContent).toBe("IND"); + }); + + test("keeps prefix and suffix around the mark", () => { + const { container } = render({highlightMatch("styles.css", "les")}); + expect(container.textContent).toBe("styles.css"); + expect(container.querySelector("mark")?.textContent).toBe("les"); + }); + + test("matches only the first occurrence", () => { + const { container } = render({highlightMatch("aaa-bbb-aaa", "aaa")}); + const marks = container.querySelectorAll("mark"); + expect(marks).toHaveLength(1); + expect(marks[0]?.textContent).toBe("aaa"); + }); +}); diff --git a/src/palette/highlightMatch.tsx b/src/palette/highlightMatch.tsx new file mode 100644 index 0000000..19e92b9 --- /dev/null +++ b/src/palette/highlightMatch.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from "react"; + +// Return the `text` split into prefix / match / suffix around the +// first case-insensitive occurrence of `query`. Original casing is preserved +// (we slice the input, we don't lowercase it). When there's no match or the +// query is empty the result is a single-element array containing the raw text +// so the caller can render it uniformly. +export function highlightMatch(text: string, query: string): ReactNode[] { + const needle = query.trim().toLowerCase(); + if (needle === "") return [text]; + const haystack = text.toLowerCase(); + const index = haystack.indexOf(needle); + if (index === -1) return [text]; + const prefix = text.slice(0, index); + const match = text.slice(index, index + needle.length); + const suffix = text.slice(index + needle.length); + return [prefix, {match}, suffix]; +} diff --git a/src/palette/iconForFile.test.ts b/src/palette/iconForFile.test.ts new file mode 100644 index 0000000..c7d53e7 --- /dev/null +++ b/src/palette/iconForFile.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "vitest"; + +import { iconForFile } from "./iconForFile"; + +describe("iconForFile", () => { + test("maps html extensions to the html tone", () => { + expect(iconForFile("/index.html")).toEqual({ glyph: "◇", tone: "html" }); + expect(iconForFile("/about.htm")).toEqual({ glyph: "◇", tone: "html" }); + }); + + test("maps css extensions to the css tone", () => { + expect(iconForFile("/style.css")).toEqual({ glyph: "#", tone: "css" }); + }); + + test("maps js / mjs extensions to the js tone", () => { + expect(iconForFile("/main.js")).toEqual({ glyph: "JS", tone: "js" }); + expect(iconForFile("/module.mjs")).toEqual({ glyph: "JS", tone: "js" }); + }); + + test("is case insensitive on the extension", () => { + expect(iconForFile("/UPPER.HTML")).toEqual({ glyph: "◇", tone: "html" }); + }); + + test("falls back to the blank tone for unknown extensions", () => { + expect(iconForFile("/notes.md")).toEqual({ glyph: "·", tone: "blank" }); + expect(iconForFile("/data.json")).toEqual({ glyph: "·", tone: "blank" }); + expect(iconForFile("/no-extension")).toEqual({ glyph: "·", tone: "blank" }); + }); +}); diff --git a/src/palette/iconForFile.ts b/src/palette/iconForFile.ts new file mode 100644 index 0000000..c6b7407 --- /dev/null +++ b/src/palette/iconForFile.ts @@ -0,0 +1,20 @@ +import type { PaletteIconTone } from "./types"; + +// Pick a glyph + tone for a VFS path based on its extension. Kept minimal on +// purpose — the palette only highlights the handful of languages WeCode +// actively supports; anything else falls back to a neutral dot. +export function iconForFile(path: string): { glyph: string; tone: PaletteIconTone } { + const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase(); + switch (ext) { + case "html": + case "htm": + return { glyph: "◇", tone: "html" }; + case "css": + return { glyph: "#", tone: "css" }; + case "js": + case "mjs": + return { glyph: "JS", tone: "js" }; + default: + return { glyph: "·", tone: "blank" }; + } +} From e743b04bc8e23a67ab9068ef7b9293be08ad3099 Mon Sep 17 00:00:00 2001 From: srfwb <264158739+srfwb@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:33:50 +0200 Subject: [PATCH 03/12] feat(palette): seed formatShortcut, paletteStore and their coverage --- src/palette/formatShortcut.test.ts | 67 ++++++++++++++++++++++++++++++ src/palette/formatShortcut.ts | 22 ++++++++++ src/palette/paletteStore.ts | 22 ++++++++++ 3 files changed, 111 insertions(+) create mode 100644 src/palette/formatShortcut.test.ts create mode 100644 src/palette/formatShortcut.ts create mode 100644 src/palette/paletteStore.ts diff --git a/src/palette/formatShortcut.test.ts b/src/palette/formatShortcut.test.ts new file mode 100644 index 0000000..ee77b51 --- /dev/null +++ b/src/palette/formatShortcut.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { isMacPlatform, modifierLabel, shortcutLabel } from "./formatShortcut"; + +// `navigator.platform` is a runtime string (`"MacIntel"`, `"Win32"`, +// `"Linux x86_64"`). We stub it per test and restore the original in +// `afterEach` so the suite is order-independent. +const globalNavigator = globalThis.navigator as Navigator | undefined; +const originalPlatform = globalNavigator?.platform; + +function stubPlatform(value: string): void { + if (!globalNavigator) { + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { platform: value }, + }); + return; + } + Object.defineProperty(globalNavigator, "platform", { + configurable: true, + value, + }); +} + +describe("formatShortcut helpers", () => { + beforeEach(() => { + stubPlatform("Win32"); + }); + + afterEach(() => { + stubPlatform(originalPlatform ?? ""); + }); + + test("isMacPlatform returns true on MacIntel", () => { + stubPlatform("MacIntel"); + expect(isMacPlatform()).toBe(true); + }); + + test("isMacPlatform returns true on macOS userAgent variants", () => { + stubPlatform("Mac68K"); + expect(isMacPlatform()).toBe(true); + }); + + test("isMacPlatform returns false on Win32", () => { + stubPlatform("Win32"); + expect(isMacPlatform()).toBe(false); + }); + + test("isMacPlatform returns false on Linux", () => { + stubPlatform("Linux x86_64"); + expect(isMacPlatform()).toBe(false); + }); + + test("modifierLabel returns ⌘ on Mac and Ctrl elsewhere", () => { + stubPlatform("MacIntel"); + expect(modifierLabel()).toBe("⌘"); + stubPlatform("Win32"); + expect(modifierLabel()).toBe("Ctrl"); + }); + + test("shortcutLabel uppercases the key and uses the right modifier", () => { + stubPlatform("MacIntel"); + expect(shortcutLabel("k")).toBe("⌘K"); + stubPlatform("Win32"); + expect(shortcutLabel("k")).toBe("Ctrl K"); + }); +}); diff --git a/src/palette/formatShortcut.ts b/src/palette/formatShortcut.ts new file mode 100644 index 0000000..2202732 --- /dev/null +++ b/src/palette/formatShortcut.ts @@ -0,0 +1,22 @@ +// On macOS the user expects ⌘K; everywhere else the convention is Ctrl K. +// We listen for `metaKey OR ctrlKey` in the global handler regardless — this +// helper just picks the *display* string for the badge. +export function isMacPlatform(): boolean { + if (typeof navigator === "undefined") return false; + // `navigator.userAgentData.platform` is the modern API but still + // patchy across browsers; fall back to the legacy `platform` string. + // Tauri reports "MacIntel" on macOS, "Win32" on Windows, "Linux x86_64". + const platform = + (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform ?? + navigator.platform ?? + ""; + return /mac/i.test(platform); +} + +export function modifierLabel(): string { + return isMacPlatform() ? "⌘" : "Ctrl"; +} + +export function shortcutLabel(key: string): string { + return isMacPlatform() ? `⌘${key.toUpperCase()}` : `Ctrl ${key.toUpperCase()}`; +} diff --git a/src/palette/paletteStore.ts b/src/palette/paletteStore.ts new file mode 100644 index 0000000..cf657f2 --- /dev/null +++ b/src/palette/paletteStore.ts @@ -0,0 +1,22 @@ +import { create } from "zustand"; + +interface PaletteState { + open: boolean; + query: string; + openPalette: () => void; + closePalette: () => void; + toggle: () => void; + setQuery: (q: string) => void; +} + +// Singleton open/close + query state. Kept tiny on purpose — the palette is +// otherwise stateless: every render derives its visible items from the +// current sources (projects store, VFS, commands registry). +export const usePaletteStore = create((set, get) => ({ + open: false, + query: "", + openPalette: () => set({ open: true }), + closePalette: () => set({ open: false, query: "" }), + toggle: () => set({ open: !get().open, query: get().open ? "" : get().query }), + setQuery: (query) => set({ query }), +})); From 6af6c7b2c18408aa7d1fa534c76ef2c8265bbfef Mon Sep 17 00:00:00 2001 From: srfwb <264158739+srfwb@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:33:53 +0200 Subject: [PATCH 04/12] feat(palette): register home-scope commands for v1 --- src/palette/commands.test.ts | 26 ++++++++++++++++++++++++++ src/palette/commands.ts | 24 ++++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 src/palette/commands.test.ts create mode 100644 src/palette/commands.ts diff --git a/src/palette/commands.test.ts b/src/palette/commands.test.ts new file mode 100644 index 0000000..4067895 --- /dev/null +++ b/src/palette/commands.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "vitest"; + +import { COMMANDS } from "./commands"; + +describe("COMMANDS registry", () => { + test("is non-empty", () => { + expect(COMMANDS.length).toBeGreaterThan(0); + }); + + test("has unique ids", () => { + const ids = COMMANDS.map((c) => c.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + test("every command has a callable run", () => { + for (const cmd of COMMANDS) { + expect(typeof cmd.run).toBe("function"); + } + }); + + test("every command carries a non-empty title", () => { + for (const cmd of COMMANDS) { + expect(cmd.title.trim().length).toBeGreaterThan(0); + } + }); +}); diff --git a/src/palette/commands.ts b/src/palette/commands.ts new file mode 100644 index 0000000..f880f87 --- /dev/null +++ b/src/palette/commands.ts @@ -0,0 +1,24 @@ +import { useProjectModalStore } from "../projects/ui/modalStore"; + +// Global actions surfaced in the palette's "Commandes" section. v1 ships the +// Home-relevant shortcuts only; `reloadPreview`, `goHome` and friends will +// land when the palette works from the IDE too. +export interface PaletteCommand { + id: string; + title: string; + subtitle?: string; + pill?: string; + run: () => void | Promise; +} + +export const COMMANDS: readonly PaletteCommand[] = [ + { + id: "open-create-project", + title: "Nouveau projet", + subtitle: "Partir d'un modèle", + pill: "commande", + run: () => { + useProjectModalStore.getState().openCreate({ templateId: "html-css" }); + }, + }, +]; From 3c62264711c0c51936525763c8426fd7c85c7008 Mon Sep 17 00:00:00 2001 From: srfwb <264158739+srfwb@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:33:57 +0200 Subject: [PATCH 05/12] feat(palette): aggregate projects, files, commands and lessons into groups --- src/palette/sources.ts | 122 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 src/palette/sources.ts diff --git a/src/palette/sources.ts b/src/palette/sources.ts new file mode 100644 index 0000000..fbd8c51 --- /dev/null +++ b/src/palette/sources.ts @@ -0,0 +1,122 @@ +import { toast } from "../ide/shell/toastStore"; +import { openProject } from "../projects/actions"; +import { useProjectStore } from "../projects/projectStore"; +import { useIdeStore } from "../state/ideStore"; +import { vfs } from "../vfs/VirtualFS"; +import { COMMANDS } from "./commands"; +import { iconForFile } from "./iconForFile"; +import { + PALETTE_GROUP_LABELS, + PALETTE_GROUP_ORDER, + type PaletteGroup, + type PaletteItem, +} from "./types"; + +const MAX_JUMP_ITEMS = 5; + +function basename(path: string): string { + const trimmed = path.startsWith("/") ? path.slice(1) : path; + const slash = trimmed.lastIndexOf("/"); + return slash === -1 ? trimmed : trimmed.slice(slash + 1); +} + +// Top N projects by `lastOpenedAt`, excluding the active one — the Home "Jump +// back in" section that lets the user hop to a different project in one go. +export function buildJumpItems(): PaletteItem[] { + const { projects, activeProjectId } = useProjectStore.getState(); + return [...projects] + .filter((p) => p.id !== activeProjectId) + .sort((a, b) => b.lastOpenedAt - a.lastOpenedAt) + .slice(0, MAX_JUMP_ITEMS) + .map((project) => ({ + id: `jump:${project.id}`, + group: "jump", + title: project.name, + subtitle: project.path, + pill: "projet", + icon: { + glyph: project.kind === "blank" ? "·" : project.kind.toUpperCase().slice(0, 3), + tone: project.kind, + }, + onSelect: () => { + void openProject(project.id); + }, + })); +} + +// Files from the VFS — only populated when an active project is in memory. +// Selecting a row flips the view to IDE (if we were on Home) and opens the +// file via the existing `openFile` store action. +export function buildFileItems(): PaletteItem[] { + const { activeProjectId } = useProjectStore.getState(); + if (!activeProjectId) return []; + + const paths = vfs.listFiles(); + return paths.map((path) => { + const icon = iconForFile(path); + return { + id: `file:${path}`, + group: "files", + title: basename(path), + subtitle: path, + pill: "fichier", + icon, + onSelect: () => { + const { view, setView, openFile } = useIdeStore.getState(); + if (view !== "ide") setView("ide"); + openFile(path); + }, + }; + }); +} + +// Wraps the typed command registry into palette-shaped rows. +export function buildCommandItems(): PaletteItem[] { + return COMMANDS.map((cmd) => ({ + id: `cmd:${cmd.id}`, + group: "commands", + title: cmd.title, + ...(cmd.subtitle ? { subtitle: cmd.subtitle } : {}), + ...(cmd.pill ? { pill: cmd.pill } : {}), + icon: { glyph: "⌘", tone: "cmd" }, + onSelect: () => { + void cmd.run(); + }, + })); +} + +// Single placeholder row so the Leçons section shows up even before the +// curriculum lands. Selecting it toasts "Bientôt disponible". +export function buildLessonItems(): PaletteItem[] { + return [ + { + id: "lesson:placeholder", + group: "lessons", + title: "Parcours de leçons", + subtitle: "Bientôt disponible", + pill: "leçon", + icon: { glyph: "L", tone: "lesson" }, + onSelect: () => { + toast.info("Bientôt disponible"); + }, + }, + ]; +} + +// Orchestrator: assemble every group in the canonical order and drop those +// that came back empty (typically "Fichiers" when no project is active). +export function buildAllGroups(): PaletteGroup[] { + const bucket: Record = { + jump: buildJumpItems(), + files: buildFileItems(), + commands: buildCommandItems(), + lessons: buildLessonItems(), + }; + const groups: PaletteGroup[] = []; + for (const key of PALETTE_GROUP_ORDER) { + const items = bucket[key] ?? []; + if (items.length === 0) continue; + groups.push({ key, label: PALETTE_GROUP_LABELS[key], items }); + } + return groups; +} From 8f0a700e2acc9057b22b84969e55e588696ef28d Mon Sep 17 00:00:00 2001 From: srfwb <264158739+srfwb@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:34:01 +0200 Subject: [PATCH 06/12] feat(palette): render overlay with grouped results and keyboard navigation --- src/palette/CommandPalette.test.tsx | 123 ++++++++++++++++++ src/palette/CommandPalette.tsx | 195 ++++++++++++++++++++++++++++ 2 files changed, 318 insertions(+) create mode 100644 src/palette/CommandPalette.test.tsx create mode 100644 src/palette/CommandPalette.tsx 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 ( + <> +