diff --git a/src/quick-switcher/quick-switcher-overlay.js b/src/quick-switcher/quick-switcher-overlay.js new file mode 100644 index 0000000..d6fb53a --- /dev/null +++ b/src/quick-switcher/quick-switcher-overlay.js @@ -0,0 +1,316 @@ +/** + * Quick-switcher overlay UI. + * + * Modal centered over the viewport. Lists recent files, open tabs, and + * (when toggled) workspace files ranked by the shared fuzzy matcher. + * + * Pure logic — ranking, scoring — lives in fuzzy-matcher.js. This module + * owns DOM and keyboard only. + * + * @module quick-switcher-overlay + */ + +const { rankResults: defaultRankResults } = require('./fuzzy-matcher'); + +/** + * @param {HTMLElement} container Mount point (typically document.body or a + * dedicated overlay host appended by the caller) + * @param {object} deps + * @param {() => string[]} [deps.getOpenTabPaths] + * @param {(dir:string, options?:object) => Promise>} [deps.listWorkspaceFiles] + * @param {(filePath:string) => void} [deps.onOpenFile] + * @param {() => string|null} [deps.getWorkspaceDir] + * @param {(query:string, items:Array, boosts:object) => Array} [deps.rankResults] + * @param {number} [deps.debounceMs=80] + * @returns {{ + * show(opts?:{recent?:string[]}): void, + * hide(): void, + * destroy(): void, + * isOpen(): boolean, + * setRecent(recent:string[]): void, + * getSelectedPath(): string|null, + * }} + */ +function createQuickSwitcherOverlay(container, deps = {}) { + const { + getOpenTabPaths = () => [], + listWorkspaceFiles = async () => [], + onOpenFile = () => {}, + getWorkspaceDir = () => null, + rankResults = defaultRankResults, + debounceMs = 80, + } = deps; + + let modalEl = null; + let inputEl = null; + let listEl = null; + let emptyEl = null; + let workspaceToggleEl = null; + let open = false; + let currentQuery = ''; + let recentFiles = []; + let workspaceFiles = []; + let workspaceMode = false; + let rankedItems = []; + let selectedIndex = 0; + let debounceTimer = null; + let keyListener = null; + let backdropListener = null; + + function buildDom() { + const wrap = document.createElement('div'); + wrap.className = 'quick-switcher-wrap'; + wrap.innerHTML = ` +
+ + `; + return wrap; + } + + function attachListeners() { + if (!modalEl) return; + + inputEl.addEventListener('input', onInput); + + keyListener = (e) => { + if (!open) return; + if (e.key === 'Escape') { + e.preventDefault(); + hide(); + return; + } + // Only arrow/enter handled at document level (Esc, too); input handles + // text natively. + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + updateSelection(e.key === 'ArrowDown' ? 1 : -1); + } else if (e.key === 'Enter') { + e.preventDefault(); + openSelected(); + } + }; + // Capture so Esc works even if focus drifts (it usually shouldn't). + document.addEventListener('keydown', keyListener, true); + + backdropListener = () => hide(); + modalEl.querySelector('.quick-switcher-backdrop').addEventListener('click', backdropListener); + + workspaceToggleEl.addEventListener('click', onToggleWorkspace); + } + + function detachListeners() { + if (inputEl) inputEl.removeEventListener('input', onInput); + if (keyListener) document.removeEventListener('keydown', keyListener, true); + if (backdropListener && modalEl) { + const bd = modalEl.querySelector('.quick-switcher-backdrop'); + if (bd) bd.removeEventListener('click', backdropListener); + } + if (workspaceToggleEl) workspaceToggleEl.removeEventListener('click', onToggleWorkspace); + keyListener = null; + backdropListener = null; + } + + function onInput() { + currentQuery = inputEl.value; + scheduleRefresh(); + } + + function onToggleWorkspace() { + workspaceMode = !workspaceMode; + workspaceToggleEl.setAttribute('aria-pressed', String(workspaceMode)); + workspaceToggleEl.classList.toggle('active', workspaceMode); + scheduleRefresh(true); + } + + function scheduleRefresh(immediate = false) { + if (debounceTimer) clearTimeout(debounceTimer); + const run = () => { + debounceTimer = null; + refresh(); + }; + if (immediate || debounceMs <= 0) run(); + else debounceTimer = setTimeout(run, debounceMs); + } + + async function refresh() { + const dir = getWorkspaceDir(); + if (workspaceMode && workspaceFiles.length === 0 && dir) { + try { + workspaceFiles = await listWorkspaceFiles(dir); + } catch { + workspaceFiles = []; + } + } + + const openTabs = getOpenTabPaths(); + const recentSet = new Set(recentFiles); + const openSet = new Set(openTabs); + + const candidates = []; + if (workspaceMode) { + for (const f of workspaceFiles) candidates.push(f); + } + for (const path of recentFiles) { + if (!candidates.some((c) => c.path === path)) { + const name = path.split(/[\\/]/).pop() || path; + candidates.push({ path, name }); + } + } + for (const path of openTabs) { + if (!candidates.some((c) => c.path === path)) { + const name = path.split(/[\\/]/).pop() || path; + candidates.push({ path, name }); + } + } + + rankedItems = rankResults(currentQuery, candidates, { recent: recentSet, openTabs: openSet }); + + // Keep selection in bounds + if (selectedIndex >= rankedItems.length) { + selectedIndex = Math.max(0, rankedItems.length - 1); + } + renderList(); + } + + function renderList() { + if (!modalEl) return; + + listEl.innerHTML = ''; + emptyEl.hidden = rankedItems.length > 0; + + for (let i = 0; i < rankedItems.length; i++) { + const { item } = rankedItems[i]; + const li = document.createElement('li'); + li.className = 'quick-switcher-item' + (i === selectedIndex ? ' selected' : ''); + li.setAttribute('role', 'option'); + li.dataset.index = String(i); + li.dataset.path = item.path; + + const baseName = item.name || item.path.split(/[\\/]/).pop() || item.path; + const parent = item.path.slice(0, item.path.length - baseName.length); + li.innerHTML = ` + + + `; + li.querySelector('.quick-switcher-name').textContent = baseName; + li.querySelector('.quick-switcher-path').textContent = parent; + + li.addEventListener('click', () => { + selectedIndex = i; + openSelected(); + }); + li.addEventListener('mouseenter', () => { + selectedIndex = i; + updateHighlight(); + }); + + listEl.appendChild(li); + } + + const count = modalEl.querySelector('.quick-switcher-count'); + if (count) { + count.textContent = rankedItems.length + ? `${rankedItems.length} ${rankedItems.length === 1 ? 'result' : 'results'}` + : ''; + } + } + + function updateHighlight() { + if (!listEl) return; + const items = listEl.querySelectorAll('.quick-switcher-item'); + items.forEach((el, i) => { + el.classList.toggle('selected', i === selectedIndex); + }); + } + + function updateSelection(delta) { + if (rankedItems.length === 0) return; + selectedIndex = Math.max(0, Math.min(rankedItems.length - 1, selectedIndex + delta)); + updateHighlight(); + } + + function openSelected() { + const sel = rankedItems[selectedIndex]; + if (!sel) return; + onOpenFile(sel.item.path); + hide(); + } + + function show({ recent = [] } = {}) { + recentFiles = recent; + currentQuery = ''; + selectedIndex = 0; + workspaceMode = false; + workspaceFiles = []; + + if (!modalEl) { + modalEl = buildDom(); + container.appendChild(modalEl); + inputEl = modalEl.querySelector('.quick-switcher-input'); + listEl = modalEl.querySelector('.quick-switcher-list'); + emptyEl = modalEl.querySelector('.quick-switcher-empty'); + workspaceToggleEl = modalEl.querySelector('.quick-switcher-workspace-toggle'); + attachListeners(); + } + + inputEl.value = ''; + workspaceToggleEl.setAttribute('aria-pressed', 'false'); + workspaceToggleEl.classList.remove('active'); + + modalEl.classList.add('open'); + open = true; + inputEl.focus(); + scheduleRefresh(true); + } + + function hide() { + open = false; + if (modalEl) modalEl.classList.remove('open'); + } + + function destroy() { + detachListeners(); + if (modalEl && modalEl.parentNode) modalEl.parentNode.removeChild(modalEl); + modalEl = null; + inputEl = null; + listEl = null; + emptyEl = null; + workspaceToggleEl = null; + } + + function isOpenFn() { + return open; + } + + function setRecentFn(recent) { + recentFiles = Array.isArray(recent) ? recent : []; + if (open) scheduleRefresh(true); + } + + function getSelectedPathFn() { + const sel = rankedItems[selectedIndex]; + return sel ? sel.item.path : null; + } + + return { + show, + hide, + destroy, + isOpen: isOpenFn, + setRecent: setRecentFn, + getSelectedPath: getSelectedPathFn, + }; +} + +module.exports = { createQuickSwitcherOverlay }; diff --git a/tests/quick-switcher-overlay.test.js b/tests/quick-switcher-overlay.test.js new file mode 100644 index 0000000..4916388 --- /dev/null +++ b/tests/quick-switcher-overlay.test.js @@ -0,0 +1,316 @@ +/** + * @jest-environment jsdom + * + * Quick-switcher overlay UI. + * + * Mounts a modal centered over the viewport. Pure controller logic + * (fuzzy match, ranking) lives in fuzzy-matcher.js — this test only + * covers DOM contract, keyboard nav, and the wiring between user + * input and the deps callbacks. + */ + +const { createQuickSwitcherOverlay } = require('../src/quick-switcher/quick-switcher-overlay'); +const { rankResults } = require('../src/quick-switcher/fuzzy-matcher'); + +async function flush(ms = 0) { + for (let i = 0; i < 5; i++) await Promise.resolve(); + if (ms > 0) await new Promise((r) => setTimeout(r, ms)); +} + +function mount(deps = {}) { + const container = document.createElement('div'); + document.body.appendChild(container); + const overlay = createQuickSwitcherOverlay(container, { + getOpenTabPaths: deps.getOpenTabPaths || (() => []), + listWorkspaceFiles: + deps.listWorkspaceFiles || + jest.fn().mockResolvedValue([ + { path: '/ws/readme.md', name: 'readme.md' }, + { path: '/ws/notes.md', name: 'notes.md' }, + ]), + onOpenFile: deps.onOpenFile || jest.fn(), + getWorkspaceDir: deps.getWorkspaceDir || (() => '/ws'), + rankResults: deps.rankResults || rankResults, + debounceMs: 0, // tests don't wait for debounce + ...deps, + }); + return { container, overlay }; +} + +function getInput(container) { + return container.querySelector('.quick-switcher-input'); +} + +function getItems(container) { + return Array.from(container.querySelectorAll('.quick-switcher-item')); +} + +function selectedIndex(container) { + const items = getItems(container); + return items.findIndex((el) => el.classList.contains('selected')); +} + +function dispatchKey(target, key) { + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }); + target.dispatchEvent(event); + return event; +} + +describe('createQuickSwitcherOverlay — show/hide', () => { + test('show() mounts DOM structure into container', () => { + const { container, overlay } = mount(); + overlay.show({ recent: [] }); + expect(container.querySelector('.quick-switcher-modal')).not.toBeNull(); + expect(container.querySelector('.quick-switcher-input')).not.toBeNull(); + expect(container.querySelector('.quick-switcher-list')).not.toBeNull(); + }); + + test('show() focuses the input', () => { + const { container, overlay } = mount(); + overlay.show({ recent: [] }); + const input = getInput(container); + expect(document.activeElement).toBe(input); + }); + + test('hide() removes the open class but keeps the DOM mounted', () => { + const { container, overlay } = mount(); + overlay.show({ recent: [] }); + overlay.hide(); + const modal = container.querySelector('.quick-switcher-modal'); + expect(modal).not.toBeNull(); + expect(modal.classList.contains('open')).toBe(false); + }); + + test('destroy() removes the DOM entirely', () => { + const { container, overlay } = mount(); + overlay.show({ recent: [] }); + overlay.destroy(); + expect(container.querySelector('.quick-switcher-modal')).toBeNull(); + }); + + test('isOpen() reflects state', () => { + const { overlay } = mount(); + expect(overlay.isOpen()).toBe(false); + overlay.show({ recent: [] }); + expect(overlay.isOpen()).toBe(true); + overlay.hide(); + expect(overlay.isOpen()).toBe(false); + }); +}); + +describe('createQuickSwitcherOverlay — initial results', () => { + test('empty query shows recent files first', async () => { + const { container, overlay } = mount(); + overlay.show({ recent: ['/ws/recent-a.md', '/ws/recent-b.md'] }); + await flush(); + const items = getItems(container); + expect(items.length).toBe(2); + expect(items[0].textContent).toContain('recent-a'); + }); + + test('empty query with no recent shows open tabs', async () => { + const { container, overlay } = mount({ + getOpenTabPaths: () => ['/ws/open.md'], + }); + overlay.show({ recent: [] }); + await flush(); + const items = getItems(container); + expect(items.length).toBe(1); + expect(items[0].textContent).toContain('open.md'); + }); + + test('empty query with no recent and no tabs shows an empty state', async () => { + const { container, overlay } = mount(); + overlay.show({ recent: [] }); + await flush(); + expect(container.querySelector('.quick-switcher-empty')).not.toBeNull(); + expect(getItems(container)).toHaveLength(0); + }); +}); + +describe('createQuickSwitcherOverlay — typing & filtering', () => { + test('typing a query filters the list via fuzzy matcher', async () => { + const { container, overlay } = mount(); + overlay.show({ recent: ['/ws/readme.md', '/ws/notes.md', '/ws/idea.md'] }); + await flush(); + + const input = getInput(container); + input.value = 'idea'; + input.dispatchEvent(new Event('input', { bubbles: true })); + await flush(); + + const items = getItems(container); + expect(items.length).toBe(1); + expect(items[0].textContent).toContain('idea.md'); + }); + + test('typing with no match shows the "no matches" state', async () => { + const { container, overlay } = mount(); + overlay.show({ recent: ['/ws/readme.md'] }); + await flush(); + + const input = getInput(container); + input.value = 'xyzqwerty'; + input.dispatchEvent(new Event('input', { bubbles: true })); + await flush(); + + expect(container.querySelector('.quick-switcher-empty')).not.toBeNull(); + expect(getItems(container)).toHaveLength(0); + }); + + test('selection defaults to the top result', async () => { + const { container, overlay } = mount(); + overlay.show({ recent: ['/ws/readme.md', '/ws/idea.md'] }); + await flush(); + expect(selectedIndex(container)).toBe(0); + }); +}); + +describe('createQuickSwitcherOverlay — keyboard navigation', () => { + test('ArrowDown moves selection down', async () => { + const { container, overlay } = mount(); + overlay.show({ recent: ['/ws/a.md', '/ws/b.md', '/ws/c.md'] }); + await flush(); + + const input = getInput(container); + dispatchKey(input, 'ArrowDown'); + await flush(); + expect(selectedIndex(container)).toBe(1); + + dispatchKey(input, 'ArrowDown'); + await flush(); + expect(selectedIndex(container)).toBe(2); + }); + + test('ArrowUp moves selection up', async () => { + const { container, overlay } = mount(); + overlay.show({ recent: ['/ws/a.md', '/ws/b.md', '/ws/c.md'] }); + await flush(); + + const input = getInput(container); + // Start at 0, go down twice, back up once + dispatchKey(input, 'ArrowDown'); + dispatchKey(input, 'ArrowDown'); + dispatchKey(input, 'ArrowUp'); + await flush(); + expect(selectedIndex(container)).toBe(1); + }); + + test('ArrowDown clamps at the last item', async () => { + const { container, overlay } = mount(); + overlay.show({ recent: ['/ws/a.md', '/ws/b.md'] }); + await flush(); + const input = getInput(container); + dispatchKey(input, 'ArrowDown'); + dispatchKey(input, 'ArrowDown'); + dispatchKey(input, 'ArrowDown'); + await flush(); + expect(selectedIndex(container)).toBe(1); + }); + + test('ArrowUp clamps at 0', async () => { + const { container, overlay } = mount(); + overlay.show({ recent: ['/ws/a.md'] }); + await flush(); + const input = getInput(container); + dispatchKey(input, 'ArrowUp'); + await flush(); + expect(selectedIndex(container)).toBe(0); + }); + + test('Enter calls onOpenFile with the selected path', async () => { + const onOpenFile = jest.fn(); + const { container, overlay } = mount({ onOpenFile }); + overlay.show({ recent: ['/ws/a.md', '/ws/b.md'] }); + await flush(); + const input = getInput(container); + dispatchKey(input, 'ArrowDown'); // select /ws/b.md + dispatchKey(input, 'Enter'); + await flush(); + expect(onOpenFile).toHaveBeenCalledTimes(1); + expect(onOpenFile).toHaveBeenCalledWith('/ws/b.md'); + }); + + test('Esc hides the overlay without calling onOpenFile', async () => { + const onOpenFile = jest.fn(); + const { overlay } = mount({ onOpenFile }); + overlay.show({ recent: ['/ws/a.md'] }); + await flush(); + dispatchKey(document, 'Escape'); + await flush(); + expect(overlay.isOpen()).toBe(false); + expect(onOpenFile).not.toHaveBeenCalled(); + }); +}); + +describe('createQuickSwitcherOverlay — workspace toggle', () => { + test('workspace toggle calls listWorkspaceFiles with workspace dir', async () => { + const listWorkspaceFiles = jest + .fn() + .mockResolvedValue([{ path: '/ws/ws-only.md', name: 'ws-only.md' }]); + const { container, overlay } = mount({ + listWorkspaceFiles, + getWorkspaceDir: () => '/ws', + }); + overlay.show({ recent: [] }); + await flush(); + + const toggle = container.querySelector('.quick-switcher-workspace-toggle'); + expect(toggle).not.toBeNull(); + toggle.click(); + await flush(); + + expect(listWorkspaceFiles).toHaveBeenCalled(); + const args = listWorkspaceFiles.mock.calls[0][0]; + expect(args).toBe('/ws'); + }); + + test('workspace toggle off by default (recent + tabs only)', async () => { + const listWorkspaceFiles = jest.fn().mockResolvedValue([]); + const { overlay } = mount({ + listWorkspaceFiles, + getWorkspaceDir: () => '/ws', + }); + overlay.show({ recent: ['/recent.md'] }); + await flush(); + expect(listWorkspaceFiles).not.toHaveBeenCalled(); + }); +}); + +describe('createQuickSwitcherOverlay — escape hatches', () => { + test('backdrop click hides the overlay', async () => { + const { container, overlay } = mount(); + overlay.show({ recent: [] }); + await flush(); + const backdrop = container.querySelector('.quick-switcher-backdrop'); + backdrop.click(); + await flush(); + expect(overlay.isOpen()).toBe(false); + }); + + test('multiple show/hide cycles reuse the same DOM', () => { + const { container, overlay } = mount(); + overlay.show({ recent: [] }); + const modalA = container.querySelector('.quick-switcher-modal'); + overlay.hide(); + overlay.show({ recent: [] }); + const modalB = container.querySelector('.quick-switcher-modal'); + expect(modalA).toBe(modalB); + }); +}); + +describe('createQuickSwitcherOverlay — getSelectedPath', () => { + test('returns the path of the current selection', async () => { + const { overlay } = mount(); + overlay.show({ recent: ['/ws/a.md', '/ws/b.md'] }); + await flush(); + expect(overlay.getSelectedPath()).toBe('/ws/a.md'); + }); + + test('returns null when no items', async () => { + const { overlay } = mount(); + overlay.show({ recent: [] }); + await flush(); + expect(overlay.getSelectedPath()).toBeNull(); + }); +});