mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 08:29:42 +02:00
- Session Manager (COD-121/192): align _loadSessionManagerList() with the merged #139 endpoint — map UnifiedSessionItem fields (lastActivityAt epoch-ms → lastModified, optional sizeBytes/firstPrompt/name) to the history-record shape _buildHistoryItem renders; surface non-2xx / error-envelope responses as a visible message instead of a silent "No sessions found"; route clicks by liveness (live row → selectSession, history row → resumeHistorySession by conversation UUID) via a new onActivate option so a live session is never duplicate-resumed - Ctrl+K double-dispatch: gate the palette chord in attachCustomKeyEventHandler (return false on keydown) so xterm never writes 0x0b kill-line into the PTY while the palette opens; gate is registry-aware so a rebound/disabled palette shortcut restores normal terminal Ctrl+K - Shortcut registry (COD-157) finished per maintainer decision: document keydown now dispatches through getShortcutRegistry() + matchesShortcutEvent() (legacy SHORTCUTS table removed), honoring per-shortcut disable and rebinds incl. the palette chord; overrides persist via saveAppSettingsToStorage() (correct device key + cache coherence, was orphaned 'codeman:settings'); Shortcuts tab renders on open via switchSettingsTab hook; capture uses a persistent listener that ignores bare modifier keydowns (combos now capturable) and requires a Ctrl/Cmd/Alt chord; settings rows use delegated listeners instead of inline onclick (JS-string injection sink) and overrides can no longer clobber id/label/action; added the missing row + overlay CSS - matchesShortcutEvent: reject undeclared extra modifiers (Ctrl+Shift+K no longer hijacked from Firefox devtools) while keeping Ctrl/Cmd interchangeable; match physical code OR produced key for layout parity - Registry/dispatch gaps: added restore-terminal-size entry, documented Ctrl+Shift+R again in the help modal (test flipped to assert presence), Ctrl+?/Alt+? now really open the registry-driven shortcut overlay, and Escape closes it - Palette new-session pick routes through selectQuickStartCase() so the searchable combobox, dir display, and lastUsedCase stay in sync - Removed fork cherry-pick debris: dead _onSessionListMaybeChanged(), orphaned .session-row-menu CSS, nonexistent closeMobileHeaderUtilities calls - Tests: functional vm-harness coverage for the unified-list field mapping + error state + liveness routing, palette chord shift/disable/ rebind handling, override persistence round-trip, capture flow, tab render hook, and source guards for the PTY gate + registry dispatch Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
217 lines
9.0 KiB
TypeScript
217 lines
9.0 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import vm from 'node:vm';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
const appSource = readFileSync('src/web/public/app.js', 'utf8');
|
|
const settingsSource = readFileSync('src/web/public/settings-ui.js', 'utf8');
|
|
const htmlSource = readFileSync('src/web/public/index.html', 'utf8');
|
|
|
|
describe('shortcut registry and overlay', () => {
|
|
it('defines shortcut metadata that can be overridden from global settings', () => {
|
|
expect(appSource).toContain('const DEFAULT_SHORTCUTS = [');
|
|
expect(appSource).toContain('shortcutOverrides');
|
|
expect(appSource).toContain('getShortcutRegistry()');
|
|
expect(appSource).toContain('matchesShortcutEvent(e, shortcut)');
|
|
});
|
|
|
|
it('renders a shortcut overlay modal from the registry', () => {
|
|
expect(htmlSource).toContain('id="shortcutOverlayModal"');
|
|
expect(htmlSource).toContain('id="shortcutOverlayList"');
|
|
expect(appSource).toContain('showShortcutOverlay()');
|
|
expect(appSource).toContain('renderShortcutOverlay()');
|
|
expect(appSource).toContain('closeShortcutOverlay()');
|
|
});
|
|
|
|
it('adds Ctrl/Option question-mark bindings for the overlay', () => {
|
|
expect(appSource).toContain("id: 'show-shortcuts'");
|
|
expect(appSource).toContain("modifiers: ['ctrl']");
|
|
expect(appSource).toContain("modifiers: ['alt']");
|
|
expect(appSource).toContain("key: '?'");
|
|
expect(appSource).toContain("code: 'Slash'");
|
|
expect(appSource).not.toContain("key: '/'");
|
|
});
|
|
|
|
it('exposes shortcut overrides in a dedicated App Settings shortcuts tab', () => {
|
|
expect(htmlSource).toContain('data-tab="settings-shortcuts"');
|
|
expect(htmlSource).toContain('id="settings-shortcuts"');
|
|
expect(htmlSource).toContain('id="appSettingsShortcutsList"');
|
|
expect(htmlSource).not.toContain('id="appSettingsShortcutOverrides"');
|
|
expect(htmlSource).not.toContain('Shortcut Overrides</span>');
|
|
expect(settingsSource).toContain('renderShortcutSettingsList');
|
|
expect(settingsSource).toContain('readShortcutOverridesFromSettings');
|
|
expect(settingsSource).toContain('startShortcutCapture');
|
|
expect(settingsSource).toContain('onShortcutCaptureKeydown');
|
|
expect(settingsSource).toContain('settings.shortcutOverrides');
|
|
});
|
|
|
|
it('renders shortcut rows with capture, typed input, reset, and disable controls', () => {
|
|
expect(settingsSource).toContain('shortcut-setting-row');
|
|
expect(settingsSource).toContain('shortcut-capture-btn');
|
|
expect(settingsSource).toContain('shortcut-binding-input');
|
|
expect(settingsSource).toContain('shortcut-reset-btn');
|
|
expect(settingsSource).toContain('shortcut-enabled-checkbox');
|
|
});
|
|
|
|
it('styles the shortcut settings rows and overlay (no unstyled tab)', () => {
|
|
const css = readFileSync('src/web/public/styles.css', 'utf8');
|
|
expect(css).toContain('.shortcut-setting-row {');
|
|
expect(css).toContain('.shortcut-capture-btn,');
|
|
expect(css).toContain('.shortcut-overlay-row {');
|
|
});
|
|
});
|
|
|
|
// ─── Functional coverage (vm-sandbox harness, mirrors run-mode-ui.test.ts) ────
|
|
// The grep assertions above pin the wiring; these exercise the actual
|
|
// persistence round-trip and capture flow that were broken in review.
|
|
|
|
function makeLocalStorage() {
|
|
const store = new Map<string, string>();
|
|
return {
|
|
getItem: (k: string) => (store.has(k) ? store.get(k)! : null),
|
|
setItem: (k: string, v: string) => void store.set(k, String(v)),
|
|
removeItem: (k: string) => void store.delete(k),
|
|
key: (i: number) => [...store.keys()][i] ?? null,
|
|
get length() {
|
|
return store.size;
|
|
},
|
|
};
|
|
}
|
|
|
|
function loadSettingsHarness() {
|
|
const CodemanApp = function CodemanApp(this: any) {};
|
|
const localStorage = makeLocalStorage();
|
|
const elements: Record<string, any> = {};
|
|
const holder: { queryResult: any } = { queryResult: null };
|
|
const context = vm.createContext({
|
|
CodemanApp,
|
|
MobileDetection: { getDeviceType: () => 'desktop', isMobile: () => false, isTouchDevice: () => false },
|
|
localStorage,
|
|
document: {
|
|
getElementById: (id: string) => elements[id] ?? null,
|
|
querySelector: () => holder.queryResult,
|
|
},
|
|
console,
|
|
escapeHtml: (s: string) => String(s),
|
|
});
|
|
|
|
const settingsUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/settings-ui.js'), 'utf8');
|
|
vm.runInContext(settingsUi, context, { filename: 'settings-ui.js' });
|
|
|
|
const app = new (CodemanApp as any)();
|
|
return { app, localStorage, elements, holder };
|
|
}
|
|
|
|
describe('shortcut settings persistence and capture', () => {
|
|
it('persists overrides under the app-settings storage key and round-trips through the cache', () => {
|
|
const { app, localStorage } = loadSettingsHarness();
|
|
|
|
app.toggleShortcutEnabled('close-session', false);
|
|
|
|
// Written to the SAME key loadAppSettingsFromStorage() reads (NOT the
|
|
// legacy 'codeman:settings' key), and the in-memory cache stays coherent.
|
|
const raw = localStorage.getItem('codeman-app-settings');
|
|
expect(raw).toBeTruthy();
|
|
expect(JSON.parse(raw!).shortcutOverrides['close-session']).toMatchObject({ disabled: true });
|
|
expect(localStorage.getItem('codeman:settings')).toBeNull();
|
|
expect(app.readShortcutOverridesFromSettings()['close-session']).toMatchObject({ disabled: true });
|
|
|
|
app.resetShortcutOverride('close-session');
|
|
const after = JSON.parse(localStorage.getItem('codeman-app-settings')!);
|
|
expect(after.shortcutOverrides['close-session']).toBeUndefined();
|
|
expect(app.readShortcutOverridesFromSettings()['close-session']).toBeUndefined();
|
|
});
|
|
|
|
it('captures multi-modifier combos: bare modifier keydowns do not end the capture', () => {
|
|
const { app, localStorage, holder } = loadSettingsHarness();
|
|
const listeners: Array<(e: any) => void> = [];
|
|
const input = {
|
|
value: '',
|
|
focus: vi.fn(),
|
|
addEventListener: vi.fn((_ev: string, fn: (e: any) => void) => listeners.push(fn)),
|
|
removeEventListener: vi.fn(),
|
|
};
|
|
holder.queryResult = input;
|
|
|
|
app.startShortcutCapture('clear-terminal');
|
|
expect(input.value).toBe('Press keys…');
|
|
const handler = listeners[0];
|
|
|
|
// First keydown of Ctrl+Shift+P is 'Control' — must not finalize.
|
|
handler({ key: 'Control', ctrlKey: true, preventDefault: vi.fn(), stopPropagation: vi.fn() });
|
|
expect(input.removeEventListener).not.toHaveBeenCalled();
|
|
|
|
handler({
|
|
key: 'P',
|
|
code: 'KeyP',
|
|
ctrlKey: true,
|
|
shiftKey: true,
|
|
preventDefault: vi.fn(),
|
|
stopPropagation: vi.fn(),
|
|
});
|
|
expect(input.removeEventListener).toHaveBeenCalledTimes(1);
|
|
const stored = JSON.parse(localStorage.getItem('codeman-app-settings')!);
|
|
expect(stored.shortcutOverrides['clear-terminal'].bindings[0]).toMatchObject({
|
|
modifiers: ['ctrl', 'shift'],
|
|
key: 'P',
|
|
code: 'KeyP',
|
|
});
|
|
});
|
|
|
|
it('rejects captures without a Ctrl/Cmd/Alt modifier (a bare key would fire while typing)', () => {
|
|
const { app, localStorage, holder } = loadSettingsHarness();
|
|
const listeners: Array<(e: any) => void> = [];
|
|
holder.queryResult = {
|
|
value: '',
|
|
focus: vi.fn(),
|
|
addEventListener: vi.fn((_ev: string, fn: (e: any) => void) => listeners.push(fn)),
|
|
removeEventListener: vi.fn(),
|
|
};
|
|
app.showToast = vi.fn();
|
|
|
|
app.startShortcutCapture('clear-terminal');
|
|
listeners[0]({ key: 'x', code: 'KeyX', preventDefault: vi.fn(), stopPropagation: vi.fn() });
|
|
|
|
expect(localStorage.getItem('codeman-app-settings')).toBeNull();
|
|
expect(app.showToast).toHaveBeenCalledWith('Shortcut must include Ctrl, Cmd, or Alt', 'error');
|
|
});
|
|
|
|
it('renders the shortcuts list when the Shortcuts settings tab is opened', () => {
|
|
const { app, elements } = loadSettingsHarness();
|
|
elements.appSettingsModal = { querySelectorAll: () => [] };
|
|
app.renderShortcutSettingsList = vi.fn();
|
|
|
|
app.switchSettingsTab('settings-shortcuts');
|
|
expect(app.renderShortcutSettingsList).toHaveBeenCalledTimes(1);
|
|
|
|
app.switchSettingsTab('settings-display');
|
|
expect(app.renderShortcutSettingsList).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('renders configurable rows with delegated controls (no inline onclick) and fixed rows read-only', () => {
|
|
const { app, elements } = loadSettingsHarness();
|
|
const list: any = { innerHTML: '', dataset: {}, addEventListener: vi.fn() };
|
|
elements.appSettingsShortcutsList = list;
|
|
app.getShortcutRegistry = () => [
|
|
{
|
|
id: 'clear-terminal',
|
|
group: 'Terminal',
|
|
label: 'Clear Terminal',
|
|
bindings: [{ modifiers: ['ctrl'], key: 'l' }],
|
|
action: 'clearTerminal',
|
|
},
|
|
{ id: 'close-panels', group: 'Panels', label: 'Close Panels', displayBindings: ['Escape'] },
|
|
];
|
|
|
|
app.renderShortcutSettingsList();
|
|
|
|
expect(list.innerHTML).not.toContain('onclick=');
|
|
expect((list.innerHTML.match(/shortcut-capture-btn/g) || []).length).toBe(1);
|
|
expect(list.innerHTML).toContain('shortcut-setting-row--fixed');
|
|
// Delegated listeners wired exactly once.
|
|
expect(list.addEventListener).toHaveBeenCalledTimes(2);
|
|
app.renderShortcutSettingsList();
|
|
expect(list.addEventListener).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|