diff --git a/src/web/public/index.html b/src/web/public/index.html index 0cbbebec..4000553c 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1921,6 +1921,7 @@ + diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 8a5354d7..70a6ffd1 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -430,6 +430,7 @@ Object.assign(CodemanApp.prototype, { settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false; document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false; document.getElementById('appSettingsShowSplitButton').checked = settings.showSplitButton ?? defaults.showSplitButton ?? false; + document.getElementById('appSettingsShowTileGridButton').checked = settings.showTileGridButton ?? defaults.showTileGridButton ?? false; document.getElementById('appSettingsShowPlanUsageLimits').checked = this.planUsageChipEnabled(settings); document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false; // Phone overview home screen: only meaningful under 600px, so the row is @@ -2492,6 +2493,7 @@ Object.assign(CodemanApp.prototype, { ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked, showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked, showSplitButton: document.getElementById('appSettingsShowSplitButton').checked, + showTileGridButton: document.getElementById('appSettingsShowTileGridButton').checked, showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked, showRedrawButton: document.getElementById('appSettingsShowRedrawButton').checked, mobileOverviewEnabled: document.getElementById('appSettingsMobileOverview').checked, @@ -2730,6 +2732,9 @@ Object.assign(CodemanApp.prototype, { // SettingsUpdateSchema (.strict()) — sending it 400s the whole PUT // (moving it into displayKeys alone is not the strip; this is). showSplitButton: _ssp, + // Same as Split: a per-device header button (and the Tiles chord), absent + // from SettingsUpdateSchema (.strict()), so sending it 400s the whole PUT. + showTileGridButton: _stg, webglRendererEnabled: _wgl, terminalWheelLocalScrollback: _twls, // Copy-on-select. Per-device (clipboard access differs by device and by @@ -3420,6 +3425,7 @@ Object.assign(CodemanApp.prototype, { ultracodeFloatingWindows: false, showMultiMonitorButton: false, showSplitButton: false, + showTileGridButton: false, // Desktop defaults this ON (see planUsageChipEnabled); handhelds keep it // OFF so the phone header stays minimal and the mobile-header-buttons // policy guard keeps passing. @@ -4057,6 +4063,7 @@ Object.assign(CodemanApp.prototype, { 'mobileOverviewEnabled', 'sessionLineageLines', 'showSplitButton', + 'showTileGridButton', ]); // The plan-usage chip is a PER-DEVICE display setting (desktop default ON, // handheld default OFF): desktop can show it while mobile stays hidden. Drop diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index eb5a0e2a..678cf8ab 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -552,11 +552,14 @@ Object.assign(CodemanApp.prototype, { }, /** - * The tile chord `e` asks for, if it applies right now, else null: the toggle - * wherever a grid could open (or is open), the focus and remove chords only - * while it is open, so outside the grid they reach the terminal untouched. - * Registry-aware (rebinds and disables in App Settings, Shortcuts). The - * capture handler (app.js) dispatches it; every xterm key handler returns + * The tile chord `e` asks for, if it applies right now, else null. The + * toggle applies while the grid is open, or where one could open AND the + * per-device `showTileGridButton` setting is on: with it off (the default) + * the chord is inert and reaches the terminal like any unbound key. (The + * applied default pending the owner's answer; one line to change.) The focus, + * zoom and remove chords apply only while the grid is open, however it was + * opened. Registry-aware (rebinds and disables in App Settings, Shortcuts). + * The capture handler (app.js) dispatches it; every xterm key handler returns * false for it, so a chord that applies never reaches a PTY. * * @returns {string|null} the registry id @@ -568,8 +571,9 @@ Object.assign(CodemanApp.prototype, { for (const shortcut of this.getShortcutRegistry()) { const spec = TILE_SHORTCUTS[shortcut.id]; if (!spec || shortcut.disabled || !this.matchesShortcutEvent(e, shortcut)) continue; - if (spec.needsOpen ? open : open || this.canOpenTileGrid()) return shortcut.id; - return null; + if (spec.needsOpen) return open ? shortcut.id : null; + const enabled = this.loadAppSettingsFromStorage?.()?.showTileGridButton === true; + return open || (enabled && this.canOpenTileGrid()) ? shortcut.id : null; } return null; }, diff --git a/test/tile-grid-per-device-setting.test.ts b/test/tile-grid-per-device-setting.test.ts new file mode 100644 index 00000000..6f2dc12e --- /dev/null +++ b/test/tile-grid-per-device-setting.test.ts @@ -0,0 +1,69 @@ +// test/tile-grid-per-device-setting.test.ts +// Port: none (pure static analysis, runs in CI, no browser/server). +// +// `showTileGridButton` (the header Tiles button, and the Ctrl+Shift+G chord) is +// a PER-DEVICE setting, default OFF, with the same three-way rule as +// showSplitButton (test/split-pane-per-device-setting.test.ts, read it for the +// history): in settings-ui.js's displayKeys merge policy, stripped out of the +// object saveAppSettings() PUTs (SettingsUpdateSchema is .strict(), so sending +// it would 400 the whole save), and never declared in the schema. Plus the +// parts that make the setting reachable: the App Settings chip, its load and +// save lines, the mobile default, and the `--hidden` marker's display:none +// rule (also covered generically by split-pane-hidden-button-css.test.ts). +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; + +const HERE = fileURLToPath(new URL('.', import.meta.url)); +const PUBLIC = join(HERE, '../src/web/public'); +const read = (file: string) => readFileSync(join(PUBLIC, file), 'utf8'); + +describe('showTileGridButton stays per-device: display key, stripped from the PUT, absent from the schema', () => { + const settingsUi = read('settings-ui.js'); + const schemas = readFileSync(join(HERE, '../src/web/schemas.ts'), 'utf8'); + + it('is in the client-side displayKeys merge policy', () => { + const start = settingsUi.indexOf('const displayKeys = new Set(['); + const displayKeys = settingsUi.slice(start, settingsUi.indexOf('])', start)); + expect(displayKeys).toContain("'showTileGridButton'"); + }); + + it('is stripped out of the object saveAppSettings() PUTs to the server', () => { + expect(settingsUi).toContain('showTileGridButton: _stg,'); + }); + + it('is never declared in the .strict() SettingsUpdateSchema', () => { + expect(schemas).not.toContain('showTileGridButton'); + }); + + it('defaults OFF on handhelds (and reads as OFF when absent everywhere)', () => { + expect(settingsUi).toMatch(/showSplitButton: false,\s*showTileGridButton: false,/); + expect(settingsUi).toContain( + "document.getElementById('appSettingsShowTileGridButton').checked = settings.showTileGridButton ?? defaults.showTileGridButton ?? false;" + ); + }); + + it('is saved from its App Settings chip and applied to the header', () => { + expect(settingsUi).toContain( + "showTileGridButton: document.getElementById('appSettingsShowTileGridButton').checked," + ); + expect(settingsUi).toContain('this._applyTileGridButtonVisibility?.(showTileGridButton);'); + }); +}); + +describe('markup and styles', () => { + const html = read('index.html'); + const css = read('styles.css'); + + it('has a header chip in App Settings, beside Split', () => { + expect(html).toMatch( + /data-preview-order="11\.6">[\s\S]*?Tiles<\/span>/ + ); + }); + + it('ships the header button hidden by its marker class, which has a display:none rule', () => { + expect(html).toContain('class="btn-icon-header btn-tile-grid btn-tile-grid--hidden"'); + expect(css).toMatch(/\.btn-tile-grid--hidden\s*\{\s*display: none !important;/); + }); +}); diff --git a/test/tile-grid-shortcuts.test.ts b/test/tile-grid-shortcuts.test.ts index 591b9991..e677fcef 100644 --- a/test/tile-grid-shortcuts.test.ts +++ b/test/tile-grid-shortcuts.test.ts @@ -46,6 +46,11 @@ const chord = (overrides: Record) => ({ ...overrides, }); const TOGGLE = { key: 'G', code: 'KeyG', ctrlKey: true, shiftKey: true }; +/** The per-device Tiles setting on (it enables the Tiles button and the toggle chord). */ +const withTilesSetting = (app: GridApp) => { + app.loadAppSettingsFromStorage = () => ({ showTileGridButton: true }); + return app; +}; const RIGHT = { key: 'ArrowRight', code: 'ArrowRight', altKey: true, shiftKey: true }; beforeEach(() => { @@ -71,15 +76,27 @@ describe('registry', () => { }); describe('when a chord applies', () => { - it('the toggle applies wherever a grid could open, and while one is open', () => { - const app = makeGridApp(IDS); + it('with the Tiles setting on, the toggle applies wherever a grid could open, and while one is open', () => { + const app = withTilesSetting(makeGridApp(IDS)); expect(app.tileShortcutFor(chord(TOGGLE))).toBe('toggle-tile-grid'); app.openTileGrid(IDS); expect(app.tileShortcutFor(chord(TOGGLE))).toBe('toggle-tile-grid'); }); - it('the toggle does not apply in a narrow window or a solo window', () => { + it('with the Tiles setting off (the default) the toggle is inert, like an unbound key', () => { const app = makeGridApp(IDS); + expect(app.tileShortcutFor(chord(TOGGLE))).toBeNull(); + }); + + it('with the setting off, a grid opened another way (Ctrl+click, a drop) still has its chords, toggle included', () => { + const app = makeGridApp(IDS); + app.openTileGrid(IDS); + expect(app.tileShortcutFor(chord(RIGHT))).toBe('focus-tile-right'); + expect(app.tileShortcutFor(chord(TOGGLE))).toBe('toggle-tile-grid'); + }); + + it('the toggle does not apply in a narrow window or a solo window', () => { + const app = withTilesSetting(makeGridApp(IDS)); gridWindow.innerWidth = 1000; expect(app.tileShortcutFor(chord(TOGGLE))).toBeNull(); gridWindow.innerWidth = 2400; @@ -131,8 +148,17 @@ describe('the capture-phase handler', () => { return keydown[1]; } - it('Ctrl+Shift+G opens the grid on the active session, then closes it, overriding the browser', () => { + it('setting off: Ctrl+Shift+G is left alone (no preventDefault), the grid stays closed', () => { const app = makeGridApp(IDS); + const onKeydown = handlerFor(app); + const e = chord(TOGGLE); + onKeydown(e); + expect(e.preventDefault).not.toHaveBeenCalled(); + expect(app._tilesOwnTerminal()).toBe(false); + }); + + it('Ctrl+Shift+G opens the grid on the active session, then closes it, overriding the browser', () => { + const app = withTilesSetting(makeGridApp(IDS)); app.selectSession = vi.fn(); const onKeydown = handlerFor(app); const open = chord(TOGGLE);