Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -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" ? <HomeShell key="home" /> : <IdeShell key="ide" />}
<CommandPalette />
<Toasts />
</>
);
Expand Down
39 changes: 36 additions & 3 deletions src/home/sections/HomeSearch.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 <html> 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 (
<div className="home-search" role="search">
<div
ref={barRef}
className="home-search"
role="search"
onClick={handleClick}
style={paletteOpen ? { visibility: "hidden" } : undefined}
>
<IconSearch />
<input
type="text"
placeholder="Rechercher un projet, une leçon ou ouvrir un fichier…"
aria-label="Rechercher"
disabled
readOnly
tabIndex={-1}
/>
<span className="home-search-kbd">Ctrl K</span>
<span className="home-search-kbd">{shortcutLabel("K")}</span>
</div>
);
}
123 changes: 123 additions & 0 deletions src/palette/CommandPalette.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<CommandPalette />);
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(<CommandPalette />);
expect(container.firstChild).toBeNull();
});

test("renders the dialog with the input focused when opened on home", () => {
usePaletteStore.setState({ open: true });
render(<CommandPalette />);
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(<CommandPalette />);
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 `<mark>` 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<typeof useProjectModalStore.getState>);
usePaletteStore.setState({ open: true, query: "nouv" });
render(<CommandPalette />);
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(<CommandPalette />);
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(<CommandPalette />);
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");
});
});
195 changes: 195 additions & 0 deletions src/palette/CommandPalette.tsx
Original file line number Diff line number Diff line change
@@ -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 <PaletteDialog />;
}

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<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<>
<div className="palette-backdrop" onClick={closePalette} aria-hidden="true" />
<div
ref={dialogRef}
className="palette-root"
role="dialog"
aria-modal="true"
aria-label="Palette de commandes"
>
<div className={`palette-bar${hasQuery ? " palette-bar--has-query" : ""}`}>
<svg className="palette-ico" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
<input
ref={inputRef}
className="palette-input"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Rechercher un projet, une leçon ou ouvrir un fichier…"
autoComplete="off"
aria-label="Rechercher"
/>
{hasQuery && (
<button
type="button"
className="palette-clear"
onClick={() => setQuery("")}
aria-label="Effacer la recherche"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 6l12 12M6 18 18 6" />
</svg>
</button>
)}
<span className="palette-esc">esc</span>
</div>

<div className="palette-results">
{flatItems.length === 0 ? (
<div className="palette-empty">Aucun résultat pour « {query} ».</div>
) : (
<div className="palette-body">
{filteredGroups.map((group) => (
<div key={group.key} className="palette-group">
<div className="palette-group-head">
<span>{group.label}</span>
<span className="palette-group-line" />
</div>
{group.items.map((item) => {
const flatIndex = flatItems.indexOf(item);
const selected = flatIndex === activeIndex;
return (
<div
key={item.id}
className={`palette-item${selected ? " palette-item--sel" : ""}`}
onMouseEnter={() => setSelectedIndex(flatIndex)}
onClick={() => {
setSelectedIndex(flatIndex);
runSelected();
}}
>
<span className={`palette-ic palette-ic--${item.icon.tone}`}>
{item.icon.glyph}
</span>
<span className="palette-lbl">
<span className="palette-title">{highlightMatch(item.title, query)}</span>
{item.subtitle && <span className="palette-sub">{item.subtitle}</span>}
</span>
<span className="palette-meta">
{item.pill && <span className="palette-pill">{item.pill}</span>}
<span className="palette-kbd-ret">↵ ouvrir</span>
</span>
</div>
);
})}
</div>
))}
</div>
)}

<div className="palette-foot">
<span className="palette-fk">
<kbd>↑</kbd>
<kbd>↓</kbd> naviguer
</span>
<span className="palette-fk">
<kbd>↵</kbd> ouvrir
</span>
<span className="palette-fk">
<kbd>esc</kbd> fermer
</span>
</div>
</div>
</div>
</>
);
}
Loading