mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
The modal had grown to 8 tabs that wrapped onto two rows on desktop and became a horizontal scroller on phones, with a "Display" mega-tab holding 11 sections and ~35 controls. Local Echo sat 60% down it, and the model settings were split across two tabs whose three controls fought each other (the 1M Opus toggle's own hint said it was "ignored when a Claude Model is selected above"). Replaced with a left rail that is a TABLE OF CONTENTS over one scrolling document: every section stays mounted, the rail follows the scroll, and find-in-page works across the whole thing. Nine sections: Terminal & Input (Local Echo is the first row of the first section) Appearance, Header & Panels, Models, Agents & CLIs, Notifications, Voice, Shortcuts, System Models are now one page. The picker is a card grid of BASE models with a single "1M context window" switch; context becomes a property of the chosen model and composes back into `claudeModel` as `base + [1m]`, which retires the precedence trap. Thinking effort is a segmented control on the same page, and the old Models tab (task routing) becomes a collapsed Advanced block under it. The 12 header-button toggles and the 8 panel toggles become chip grids, which is most of the old Display tab reclaimed. Rows now say whether a setting is per-device or synced, stated once per group. Phones drop the rail for a sticky jump pill that names the current section and opens a jump list, move Save into the header (the bottom action bar cost 60px), and render groups as one inset rounded list with hairline dividers instead of a stack of bordered cards. Load and save are untouched: every control keeps its id, so openAppSettings()/saveAppSettings() work as before. Model cards and the effort segment are views over hidden <select>s that stay the source of truth. test/app-settings-structure.test.ts pins that contract, plus the rail hooks admin-ui.js injects the multi-user Users section into. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
233 lines
9.9 KiB
TypeScript
233 lines
9.9 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 section', () => {
|
|
expect(htmlSource).toContain('data-section="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 {');
|
|
});
|
|
|
|
it('saveAppSettings preserves shortcutOverrides (rebuilt-from-DOM saves must not wipe them)', () => {
|
|
// Same trap as showTokenCount/showCost: saveAppSettings() rebuilds the settings
|
|
// object fresh from the DOM, so keys edited elsewhere (the Shortcuts tab) must be
|
|
// explicitly carried over from the previously stored blob.
|
|
expect(settingsSource).toContain(
|
|
'if (_prev.shortcutOverrides !== undefined) settings.shortcutOverrides = _prev.shortcutOverrides;'
|
|
);
|
|
});
|
|
|
|
it('keeps the full help modal reachable now that Ctrl+? opens the registry overlay', () => {
|
|
// The legacy #helpModal (full shortcut reference) lost its only opener when
|
|
// Ctrl+? was rerouted to the overlay; the overlay footer must link to it.
|
|
expect(htmlSource).toContain('shortcut-overlay-footer');
|
|
expect(htmlSource).toContain('app.closeShortcutOverlay(); app.showHelp()');
|
|
});
|
|
});
|
|
|
|
// ─── 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 section is reached', () => {
|
|
const { app, elements } = loadSettingsHarness();
|
|
elements.appSettingsModal = { querySelectorAll: () => [] };
|
|
app.renderShortcutSettingsList = vi.fn();
|
|
|
|
app.switchSettingsTab('settings-shortcuts');
|
|
expect(app.renderShortcutSettingsList).toHaveBeenCalledTimes(1);
|
|
|
|
app.switchSettingsTab('settings-terminal');
|
|
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);
|
|
});
|
|
});
|