open-design/apps/web/tests/quickSwitcherRecents.test.ts
Kadu Maverick 2036ce0a8e
feat(web): add Cmd/Ctrl+P quick file switcher (#556)
* 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>
2026-05-06 10:31:50 +08:00

106 lines
3.7 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
pushRecent,
readRecents,
RECENTS_LIMIT,
} from '../src/quickSwitcherRecents';
// Tiny in-memory localStorage stub. Vitest runs in a node env (per
// vitest.config.ts), so we provide just enough of the Storage interface
// for the recents module to exercise its code paths.
function createStorageStub() {
const store = new Map<string, string>();
return {
getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
setItem: (key: string, value: string) => { store.set(key, value); },
removeItem: (key: string) => { store.delete(key); },
clear: () => { store.clear(); },
key: (i: number) => Array.from(store.keys())[i] ?? null,
get length() { return store.size; },
} satisfies Storage;
}
describe('quickSwitcherRecents', () => {
let storage: Storage;
beforeEach(() => {
storage = createStorageStub();
vi.stubGlobal('localStorage', storage);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('readRecents', () => {
it('returns an empty array when no entry exists for the project', () => {
expect(readRecents('p1')).toEqual([]);
});
it('returns the stored list as-is when valid', () => {
storage.setItem('od:qs-recents:p1', JSON.stringify(['a.html', 'b.html']));
expect(readRecents('p1')).toEqual(['a.html', 'b.html']);
});
it('returns an empty array for corrupt JSON instead of throwing', () => {
storage.setItem('od:qs-recents:p1', '{not json');
expect(readRecents('p1')).toEqual([]);
});
it('filters out non-string entries (defends against schema drift)', () => {
storage.setItem('od:qs-recents:p1', JSON.stringify(['a.html', 42, null, 'b.html']));
expect(readRecents('p1')).toEqual(['a.html', 'b.html']);
});
it('returns an empty array when the stored value is not an array', () => {
storage.setItem('od:qs-recents:p1', JSON.stringify({ a: 1 }));
expect(readRecents('p1')).toEqual([]);
});
it('scopes recents per project (different keys, no cross-bleed)', () => {
pushRecent('p1', 'a.html');
pushRecent('p2', 'b.html');
expect(readRecents('p1')).toEqual(['a.html']);
expect(readRecents('p2')).toEqual(['b.html']);
});
});
describe('pushRecent', () => {
it('puts the most recent file at the head of the list', () => {
pushRecent('p1', 'a.html');
pushRecent('p1', 'b.html');
expect(readRecents('p1')).toEqual(['b.html', 'a.html']);
});
it('deduplicates: re-pushing an existing entry moves it to the head', () => {
pushRecent('p1', 'a.html');
pushRecent('p1', 'b.html');
pushRecent('p1', 'a.html');
expect(readRecents('p1')).toEqual(['a.html', 'b.html']);
});
it(`caps the list at ${RECENTS_LIMIT} entries`, () => {
for (let i = 0; i < RECENTS_LIMIT + 4; i++) {
pushRecent('p1', `file-${i}.html`);
}
const recents = readRecents('p1');
expect(recents).toHaveLength(RECENTS_LIMIT);
// Most recent first; older entries fall off the tail.
expect(recents[0]).toBe(`file-${RECENTS_LIMIT + 3}.html`);
});
it('is a no-op when localStorage throws (quota exceeded / private mode)', () => {
const setItem = vi.spyOn(storage, 'setItem').mockImplementation(() => {
throw new Error('QuotaExceeded');
});
// Should not throw even though setItem does.
expect(() => pushRecent('p1', 'a.html')).not.toThrow();
setItem.mockRestore();
// After restoring, the previous push left no record because the
// throw aborted the write — recents stays empty.
expect(readRecents('p1')).toEqual([]);
});
});
});