mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* feat(web): add Cmd/Ctrl+P quick file switcher A keyboard-driven file palette overlaid on the workspace. Press Cmd/Ctrl+P anywhere in the project view; type to fuzzy-filter the file list, ↑/↓ to navigate, Enter to open in a tab, Esc to dismiss. With an empty query the palette surfaces recents (per-project, localStorage) followed by the rest of the file list sorted by mtime. Adds: - apps/web/src/components/QuickSwitcher.tsx: palette UI and matcher - apps/web/src/quickSwitcherRecents.ts: per-project recents store - index.css: scoped .qs-* styles using existing design tokens - i18n: 6 new keys translated across all 16 locale files Wires into FileWorkspace's existing openFile() so recents and tab state behave identically to opening from DesignFilesPanel. Capture-phase keydown beats the browser's print dialog. No backend changes; uses the files prop already passed to FileWorkspace. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(web): address QuickSwitcher review feedback Three fixes from the PR review: - z-index: bump .qs-overlay from 200 to 1500 so the palette renders in the modal tier (alongside prompt-template-modal-overlay) instead of behind context menus and popovers (which sit at 200). - Arrow-key guard: skip setCursor when matches is empty. Without this, pressing ↓ on a no-results query set the cursor to -1, making the highlight selector miss every row on the next render. - Tests: add 19 unit tests covering scoreMatch ranking tiers, render output (empty state / row count / kbd hints / placeholder), and the full recents lifecycle (cap at 6, dedupe-on-push, corrupt-JSON recovery, per-project scoping, quota-exceeded no-op). Vitest stays on the node env via a small in-memory localStorage stub. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(web): QuickSwitcher review — wrap, IME, platform gate Three follow-ups from @mrcfps's review on #556: - ArrowUp/ArrowDown now wrap at list bounds (last → first, first → last) via modulo arithmetic in a new pure helper `nextCursor(current, total, direction)`. Previously they clamped, which contradicted the wrap behavior the PR test plan promised. Pulled into a pure function so boundary cases are unit-testable without simulating keyboard events. - Palette's onKeyDown now early-returns on `e.nativeEvent.isComposing`, so users typing CJK file names through an IME keep ↑/↓/Enter for candidate navigation instead of having them steered by the palette. The global Cmd/Ctrl+P opener already had the equivalent guard. - Global keydown is now platform-gated: macOS responds only to metaKey, win/linux only to ctrlKey. Previously both fired everywhere, which meant Ctrl+P on macOS was stealing native readline "previous line" in text fields (and the chat composer). Tests: +6 unit tests for `nextCursor` covering forward/backward wrap, mid-list moves, empty list (no division-by-zero), and single-item no-op. Suite now 258 passing (up from 252). Verified live: ↓ from last row → first row; ↑ from first row → last row, in a mocked-project Playwright harness. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
143 lines
5.1 KiB
TypeScript
143 lines
5.1 KiB
TypeScript
import { renderToStaticMarkup } from 'react-dom/server';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { nextCursor, QuickSwitcher, scoreMatch } from '../../src/components/QuickSwitcher';
|
|
import type { ProjectFile } from '../../src/types';
|
|
|
|
// QuickSwitcher reads recents from localStorage during render. The default
|
|
// vitest env is node, so stub a minimal Storage to keep the component
|
|
// happy and the assertions deterministic.
|
|
function createStorageStub(): Storage {
|
|
const store = new Map<string, string>();
|
|
return {
|
|
getItem: (k) => (store.has(k) ? store.get(k)! : null),
|
|
setItem: (k, v) => { store.set(k, v); },
|
|
removeItem: (k) => { store.delete(k); },
|
|
clear: () => { store.clear(); },
|
|
key: (i) => Array.from(store.keys())[i] ?? null,
|
|
get length() { return store.size; },
|
|
} satisfies Storage;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.stubGlobal('localStorage', createStorageStub());
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
function file(overrides: Partial<ProjectFile>): ProjectFile {
|
|
return {
|
|
name: 'index.html',
|
|
path: 'index.html',
|
|
type: 'file',
|
|
size: 1024,
|
|
mtime: 1700000000,
|
|
kind: 'html',
|
|
mime: 'text/html',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('scoreMatch — fuzzy ranking tiers', () => {
|
|
it('exact basename match scores highest', () => {
|
|
expect(scoreMatch(file({ name: 'app.tsx' }), 'app.tsx')).toBe(1000);
|
|
});
|
|
|
|
it('prefix-on-basename outranks substring-on-basename', () => {
|
|
const prefix = scoreMatch(file({ name: 'header.tsx' }), 'head');
|
|
const substring = scoreMatch(file({ name: 'page-header.tsx' }), 'head');
|
|
expect(prefix).toBeGreaterThan(substring);
|
|
});
|
|
|
|
it('substring-on-basename outranks substring-on-path-only', () => {
|
|
const inBase = scoreMatch(file({ name: 'utils/helper.ts' }), 'help');
|
|
const onlyInPath = scoreMatch(file({ name: 'helpers/main.ts' }), 'help');
|
|
// 'help' is in the basename of utils/helper.ts ('helper.ts')
|
|
// 'help' is only in the dir of helpers/main.ts ('helpers')
|
|
expect(inBase).toBeGreaterThan(onlyInPath);
|
|
});
|
|
|
|
it('returns 0 when the query matches neither basename nor path', () => {
|
|
expect(scoreMatch(file({ name: 'app.tsx' }), 'xyz')).toBe(0);
|
|
});
|
|
|
|
it('matching is case-insensitive (queries normalized to lowercase by caller)', () => {
|
|
// The component lowercases the query before calling scoreMatch, so
|
|
// scoreMatch itself can rely on the contract that q is already lower.
|
|
expect(scoreMatch(file({ name: 'Hero.tsx' }), 'hero')).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('nextCursor — arrow-key wrap behavior', () => {
|
|
it('moves forward through the list without wrapping in the middle', () => {
|
|
expect(nextCursor(0, 5, 1)).toBe(1);
|
|
expect(nextCursor(2, 5, 1)).toBe(3);
|
|
});
|
|
|
|
it('moves backward through the list without wrapping in the middle', () => {
|
|
expect(nextCursor(3, 5, -1)).toBe(2);
|
|
expect(nextCursor(1, 5, -1)).toBe(0);
|
|
});
|
|
|
|
it('wraps from the last row to the first when pressing ↓', () => {
|
|
// Row 4 (last of 5) → 0 (first). Documented behavior in the PR test plan.
|
|
expect(nextCursor(4, 5, 1)).toBe(0);
|
|
});
|
|
|
|
it('wraps from the first row to the last when pressing ↑', () => {
|
|
expect(nextCursor(0, 5, -1)).toBe(4);
|
|
});
|
|
|
|
it('returns 0 when the list is empty (no division-by-zero, no NaN)', () => {
|
|
expect(nextCursor(0, 0, 1)).toBe(0);
|
|
expect(nextCursor(0, 0, -1)).toBe(0);
|
|
});
|
|
|
|
it('stays put on a single-item list (wrap is a no-op)', () => {
|
|
expect(nextCursor(0, 1, 1)).toBe(0);
|
|
expect(nextCursor(0, 1, -1)).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('QuickSwitcher render', () => {
|
|
it('renders the empty state when the project has no files', () => {
|
|
const markup = renderToStaticMarkup(
|
|
<QuickSwitcher projectId="p1" files={[]} onOpenFile={vi.fn()} onClose={vi.fn()} />,
|
|
);
|
|
// Empty-state copy comes from i18n; the rendered class is stable.
|
|
expect(markup).toContain('class="qs-empty"');
|
|
expect(markup).not.toContain('class="qs-row');
|
|
});
|
|
|
|
it('renders a row per file when no query is set', () => {
|
|
const files = [
|
|
file({ name: 'a.html', mtime: 3 }),
|
|
file({ name: 'b.html', mtime: 2 }),
|
|
file({ name: 'c.html', mtime: 1 }),
|
|
];
|
|
const markup = renderToStaticMarkup(
|
|
<QuickSwitcher projectId="p1" files={files} onOpenFile={vi.fn()} onClose={vi.fn()} />,
|
|
);
|
|
const rowCount = (markup.match(/class="qs-row /g) ?? []).length;
|
|
expect(rowCount).toBe(3);
|
|
});
|
|
|
|
it('exposes the keyboard hints in the footer', () => {
|
|
const markup = renderToStaticMarkup(
|
|
<QuickSwitcher projectId="p1" files={[file({})]} onOpenFile={vi.fn()} onClose={vi.fn()} />,
|
|
);
|
|
// Three <kbd> hints (↑↓ / ↵ / esc).
|
|
const kbdCount = (markup.match(/<kbd>/g) ?? []).length;
|
|
expect(kbdCount).toBeGreaterThanOrEqual(3);
|
|
});
|
|
|
|
it('renders the input placeholder so users discover the palette is searchable', () => {
|
|
const markup = renderToStaticMarkup(
|
|
<QuickSwitcher projectId="p1" files={[]} onOpenFile={vi.fn()} onClose={vi.fn()} />,
|
|
);
|
|
expect(markup).toContain('class="qs-input"');
|
|
expect(markup).toContain('placeholder=');
|
|
});
|
|
});
|