mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
feat(tiles): tile chords in the shortcut registry, and the grid never beside a split
Shortcut registry (DEFAULT_SHORTCUTS, group Tiles, all rebindable): - Toggle Tile Grid, Ctrl+Shift+G: opens the grid this tab last left (one step back after a selection outside it), else an open split as two tiles, else the active session as one tile; pressed again, back to the single view of the focused session. xterm emits nothing for a shifted Ctrl letter; the browser's find-previous is overridden only where the grid can open. - Focus Tile Left/Right/Up/Down, Alt+Shift+Arrows: a human selection of the tile in that direction. - Remove Focused Tile, unbound: the session keeps running. tileShortcutFor() decides whether a chord applies (the toggle wherever a grid could open, the rest only while one is open, so outside the grid Alt+Shift+Arrows reach the terminal untouched) and is registry-aware. The capture handler dispatches it, and the main terminal's and every tile's xterm key handler return false for it, for every event type and before the Shift+Enter gate, so a chord that applies never reaches a PTY. Coexistence with the split pane: opening the grid over an open split closes it (no wasted resize for the pane about to park) and seeds the grid with both of its sessions, Pane A focused. While the grid is open openSplitPicker and openSplitPane refuse and the Split button reads as unavailable (aria-disabled); closing the grid never reopens a split. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* @fileoverview The tile grid's chords: registry entries, when they apply, and
|
||||
* that a chord which applies never reaches a PTY.
|
||||
*
|
||||
* Defaults (all rebindable in App Settings, Shortcuts): Ctrl+Shift+G toggles
|
||||
* the grid, Alt+Shift+Arrows move focus between tiles, Remove Focused Tile is
|
||||
* unbound. The toggle applies wherever a grid could open; the focus and remove
|
||||
* chords only while the grid is open, so outside it Alt+Shift+Arrows reach the
|
||||
* terminal untouched. The capture-phase handler (app.js) dispatches a chord
|
||||
* that applies; its preventDefault() does not stop xterm, so every xterm key
|
||||
* handler (the main terminal's and TerminalTile's) returns false for it too,
|
||||
* for every event type and before the Shift+Enter gate (which would otherwise
|
||||
* send S-Enter for Alt+Shift+Enter style chords).
|
||||
*
|
||||
* Real code: the grid harness (test/mocks/tile-grid-vm.ts) for the registry,
|
||||
* the capture handler and the actions; a second `vm` context with the real
|
||||
* TerminalTile for its key handler; the main terminal's handler, which needs a
|
||||
* real xterm, is pinned at the source. Port: N/A.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
documentAddEventListener,
|
||||
makeGridApp,
|
||||
resetGridHarness,
|
||||
windowStub as gridWindow,
|
||||
type GridApp,
|
||||
} from './mocks/tile-grid-vm.js';
|
||||
|
||||
const IDS = ['s-a', 's-b', 's-c'];
|
||||
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
|
||||
|
||||
const chord = (overrides: Record<string, unknown>) => ({
|
||||
type: 'keydown',
|
||||
key: '',
|
||||
code: '',
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
target: { closest: () => null },
|
||||
...overrides,
|
||||
});
|
||||
const TOGGLE = { key: 'G', code: 'KeyG', ctrlKey: true, shiftKey: true };
|
||||
const RIGHT = { key: 'ArrowRight', code: 'ArrowRight', altKey: true, shiftKey: true };
|
||||
|
||||
beforeEach(() => {
|
||||
resetGridHarness();
|
||||
});
|
||||
|
||||
describe('registry', () => {
|
||||
it('ships the tile chords, rebindable, with Remove Focused Tile unbound', () => {
|
||||
const app = makeGridApp(IDS);
|
||||
const byId = Object.fromEntries(app.getShortcutRegistry().map((s: { id: string }) => [s.id, s])) as Record<
|
||||
string,
|
||||
{ bindings: Array<Record<string, unknown>>; group: string }
|
||||
>;
|
||||
expect(byId['toggle-tile-grid'].bindings).toEqual([{ modifiers: ['ctrl', 'shift'], key: 'G', code: 'KeyG' }]);
|
||||
for (const dir of ['Left', 'Right', 'Up', 'Down']) {
|
||||
expect(byId[`focus-tile-${dir.toLowerCase()}`].bindings).toEqual([
|
||||
{ modifiers: ['alt', 'shift'], key: `Arrow${dir}` },
|
||||
]);
|
||||
}
|
||||
expect(byId['remove-tile'].bindings).toEqual([]);
|
||||
expect(byId['toggle-tile-grid'].group).toBe('Tiles');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when a chord applies', () => {
|
||||
it('the toggle applies wherever a grid could open, and while one is open', () => {
|
||||
const app = 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', () => {
|
||||
const app = makeGridApp(IDS);
|
||||
gridWindow.innerWidth = 1000;
|
||||
expect(app.tileShortcutFor(chord(TOGGLE))).toBeNull();
|
||||
gridWindow.innerWidth = 2400;
|
||||
app.isSoloWindow = true;
|
||||
expect(app.tileShortcutFor(chord(TOGGLE))).toBeNull();
|
||||
});
|
||||
|
||||
it('the focus chords apply only while the grid is open', () => {
|
||||
const app = makeGridApp(IDS);
|
||||
expect(app.tileShortcutFor(chord(RIGHT))).toBeNull();
|
||||
app.openTileGrid(IDS);
|
||||
expect(app.tileShortcutFor(chord(RIGHT))).toBe('focus-tile-right');
|
||||
});
|
||||
|
||||
it('plain typing and unrelated chords never match', () => {
|
||||
const app = makeGridApp(IDS);
|
||||
app.openTileGrid(IDS);
|
||||
expect(app.tileShortcutFor(chord({ key: 'g', code: 'KeyG' }))).toBeNull();
|
||||
expect(app.tileShortcutFor(chord({ key: 'ArrowRight', altKey: true }))).toBeNull();
|
||||
expect(app.tileShortcutFor(chord({ key: 'G', code: 'KeyG', ctrlKey: true }))).toBeNull();
|
||||
});
|
||||
|
||||
it('honours a disable and a rebind from App Settings', () => {
|
||||
const app = makeGridApp(IDS);
|
||||
app.loadAppSettingsFromStorage = () => ({
|
||||
shortcutOverrides: {
|
||||
'toggle-tile-grid': { disabled: true },
|
||||
'focus-tile-right': { bindings: [{ modifiers: ['ctrl', 'alt'], key: 'l', code: 'KeyL' }] },
|
||||
},
|
||||
});
|
||||
expect(app.tileShortcutFor(chord(TOGGLE))).toBeNull();
|
||||
app.openTileGrid(IDS);
|
||||
expect(app.tileShortcutFor(chord(RIGHT))).toBeNull();
|
||||
expect(app.tileShortcutFor(chord({ key: 'l', code: 'KeyL', ctrlKey: true, altKey: true }))).toBe(
|
||||
'focus-tile-right'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the capture-phase handler', () => {
|
||||
function handlerFor(app: GridApp) {
|
||||
app.$ = () => null;
|
||||
app.setupColorPicker = vi.fn();
|
||||
const before = (documentAddEventListener.mock.calls as unknown[]).length;
|
||||
app.setupEventListeners();
|
||||
const added = (documentAddEventListener.mock.calls as Array<[string, (e: unknown) => void, boolean]>).slice(before);
|
||||
const keydown = added.find(([type, , capture]) => type === 'keydown' && capture === true);
|
||||
if (!keydown) throw new Error('no capture-phase keydown listener');
|
||||
return keydown[1];
|
||||
}
|
||||
|
||||
it('Ctrl+Shift+G opens the grid on the active session, then closes it, overriding the browser', () => {
|
||||
const app = makeGridApp(IDS);
|
||||
app.selectSession = vi.fn();
|
||||
const onKeydown = handlerFor(app);
|
||||
const open = chord(TOGGLE);
|
||||
onKeydown(open);
|
||||
expect(open.preventDefault).toHaveBeenCalled();
|
||||
expect(app._tileGrid.ids).toEqual(['s-a']);
|
||||
|
||||
onKeydown(chord(TOGGLE));
|
||||
expect(app._tilesOwnTerminal()).toBe(false);
|
||||
expect(app.selectSession).toHaveBeenCalledWith('s-a', { forceReload: true, auto: true });
|
||||
});
|
||||
|
||||
it('Alt+Shift+Right moves focus to the tile on the right, as a human selection', () => {
|
||||
const app = makeGridApp(IDS);
|
||||
app.openTileGrid(IDS);
|
||||
app.markIdleAlertSeen.mockClear();
|
||||
// The real selectSession routes a tiled id to the tile branch.
|
||||
delete app.selectSession;
|
||||
const onKeydown = handlerFor(app);
|
||||
const e = chord(RIGHT);
|
||||
onKeydown(e);
|
||||
expect(e.preventDefault).toHaveBeenCalled();
|
||||
expect(app.activeSessionId).toBe('s-b');
|
||||
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-b');
|
||||
});
|
||||
|
||||
it('outside the grid Alt+Shift+Right is left alone for the terminal', () => {
|
||||
const app = makeGridApp(IDS);
|
||||
const onKeydown = handlerFor(app);
|
||||
const e = chord(RIGHT);
|
||||
onKeydown(e);
|
||||
expect(e.preventDefault).not.toHaveBeenCalled();
|
||||
expect(app._tilesOwnTerminal()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the actions', () => {
|
||||
it('the toggle brings back the grid this tab last left, focus included', () => {
|
||||
const app = makeGridApp(IDS);
|
||||
app.selectSession = vi.fn();
|
||||
app.openTileGrid(IDS, { focusedId: 's-c' });
|
||||
app.closeTileGrid({ reselect: false });
|
||||
app.toggleTileGrid();
|
||||
expect(app._tileGrid.ids).toEqual(IDS);
|
||||
expect(app.activeSessionId).toBe('s-c');
|
||||
});
|
||||
|
||||
it('a remembered tile whose session is gone is not brought back', () => {
|
||||
const app = makeGridApp(IDS);
|
||||
app.openTileGrid(IDS);
|
||||
app.closeTileGrid({ reselect: false });
|
||||
app.sessions.delete('s-b');
|
||||
app.toggleTileGrid();
|
||||
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
|
||||
});
|
||||
|
||||
it('with every remembered session gone, it opens on the active session instead', () => {
|
||||
const app = makeGridApp(IDS);
|
||||
app.openTileGrid(['s-b', 's-c']);
|
||||
app.closeTileGrid({ reselect: false });
|
||||
app.sessions.delete('s-b');
|
||||
app.sessions.delete('s-c');
|
||||
app.activeSessionId = 's-a';
|
||||
app.toggleTileGrid();
|
||||
expect(app._tileGrid.ids).toEqual(['s-a']);
|
||||
});
|
||||
|
||||
it('Remove Focused Tile removes it (the session keeps running) and a neighbour takes focus', () => {
|
||||
const app = makeGridApp(IDS);
|
||||
app.openTileGrid(IDS, { focusedId: 's-b' });
|
||||
app._apiDelete = vi.fn();
|
||||
app.removeFocusedTile();
|
||||
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']);
|
||||
expect(app.activeSessionId).toBe('s-c');
|
||||
expect(app.sessions.has('s-b')).toBe(true);
|
||||
expect(app._apiDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('xterm key handlers swallow a chord that applies', () => {
|
||||
it("the main terminal's handler checks the chord before the Shift+Enter gate, for every event type", () => {
|
||||
// That handler lives in initTerminal's closure over a real xterm, so the
|
||||
// rule is pinned at the source, as terminal-copy-clean.test.ts does.
|
||||
const src = read('terminal-ui.js');
|
||||
const handler = src.slice(src.indexOf('this.terminal.attachCustomKeyEventHandler((ev) => {'));
|
||||
const gate = handler.indexOf('if (this.tileShortcutFor?.(ev)) return false;');
|
||||
const enter = handler.indexOf("if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey)) {");
|
||||
expect(gate).toBeGreaterThan(0);
|
||||
expect(gate).toBeLessThan(enter);
|
||||
});
|
||||
|
||||
describe("TerminalTile's handler (real tile, real tileShortcutFor)", () => {
|
||||
class FakeTerminal {
|
||||
static last: FakeTerminal;
|
||||
keyHandler: ((ev: Record<string, unknown>) => boolean) | null = null;
|
||||
options: Record<string, unknown>;
|
||||
cols = 80;
|
||||
rows = 24;
|
||||
buffer = { active: { type: 'normal', viewportY: 0, length: 24 } };
|
||||
textarea = { addEventListener() {}, removeEventListener() {} };
|
||||
constructor(options: Record<string, unknown>) {
|
||||
this.options = options;
|
||||
FakeTerminal.last = this;
|
||||
}
|
||||
loadAddon() {}
|
||||
open() {}
|
||||
onData() {}
|
||||
attachCustomKeyEventHandler(fn: (ev: Record<string, unknown>) => boolean) {
|
||||
this.keyHandler = fn;
|
||||
}
|
||||
registerLinkProvider() {}
|
||||
write() {}
|
||||
clear() {}
|
||||
dispose() {}
|
||||
}
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { terminalBuffer: '' } }) }));
|
||||
const windowStub: Record<string, unknown> = {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
CodemanBase: { base: '' },
|
||||
innerWidth: 2400,
|
||||
};
|
||||
const context = vm.createContext({
|
||||
console: { ...console, log: vi.fn(), debug: vi.fn() },
|
||||
performance,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||
WebSocket: class {
|
||||
static OPEN = 1;
|
||||
send() {}
|
||||
close() {}
|
||||
},
|
||||
Terminal: FakeTerminal,
|
||||
FitAddon: {
|
||||
FitAddon: class {
|
||||
fit() {}
|
||||
proposeDimensions() {
|
||||
return { cols: 80, rows: 24 };
|
||||
}
|
||||
},
|
||||
},
|
||||
fetch: fetchMock,
|
||||
location: { protocol: 'http:', host: 'codeman.test' },
|
||||
document: { addEventListener: vi.fn(), documentElement: { dataset: {} } },
|
||||
localStorage: { getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
window: windowStub,
|
||||
MobileDetection: { isTouchDevice: () => false, isHandheldDevice: () => false, getDeviceType: () => 'desktop' },
|
||||
});
|
||||
vm.runInContext(
|
||||
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-tile.js')}\n` +
|
||||
`${read('terminal-split.js')}\n${read('tile-grid.js')}\nglobalThis.__CodemanApp = CodemanApp;`,
|
||||
context
|
||||
);
|
||||
const App = (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp;
|
||||
const TerminalTile = windowStub.TerminalTile as new (
|
||||
id: string,
|
||||
mount: unknown,
|
||||
opts: object
|
||||
) => { connect(): Promise<void>; destroy(): void };
|
||||
|
||||
async function tileHandler(gridOpen: boolean) {
|
||||
const app = Object.create(App.prototype) as GridApp;
|
||||
app.loadAppSettingsFromStorage = () => ({});
|
||||
app.isSoloWindow = false;
|
||||
app._tileGrid = { open: gridOpen };
|
||||
windowStub.app = app;
|
||||
const tile = new TerminalTile('s1', { addEventListener() {}, removeEventListener() {} }, { mode: 'claude' });
|
||||
await tile.connect();
|
||||
const handler = FakeTerminal.last.keyHandler!;
|
||||
tile.destroy();
|
||||
return handler;
|
||||
}
|
||||
|
||||
it.each(['keydown', 'keypress', 'keyup'])(
|
||||
'returns false for Alt+Shift+Right (%s) while the grid is open',
|
||||
async (type) => {
|
||||
const handler = await tileHandler(true);
|
||||
expect(handler({ ...chord(RIGHT), type })).toBe(false);
|
||||
}
|
||||
);
|
||||
|
||||
it('lets Alt+Shift+Right through to the PTY when no grid is open', async () => {
|
||||
const handler = await tileHandler(false);
|
||||
expect(handler(chord(RIGHT))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for the toggle chord', async () => {
|
||||
const handler = await tileHandler(true);
|
||||
expect(handler(chord(TOGGLE))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user