From 20c2561a459d180b4b5bdf2d850b37cd4d163466 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 7 Oct 2026 23:49:11 +0200 Subject: [PATCH] feat(tiles): Move Tile Left/Right/Up/Down (Ctrl+Shift+Arrows) Four rebindable registry chords in the Tiles group move the focused tile: it trades places with the neighbour the Alt+Shift+Arrow focus chords pick (tileInDirection), through the same _reorderTiles path as the header drag, and keeps the focus. Nothing at an edge. They go through tileShortcutFor()/runTileShortcut() like the other tile chords, so every xterm key handler swallows them while they apply: only while the grid is open, a zoomed grid included (a no-op there, so the keys never reach the CLI), and never in a text field other than xterm's own textarea, where Ctrl+Shift+Arrows select by word. Ctrl+Shift+Arrows because every other two-modifier arrow chord is taken: Ctrl+Alt switches workspaces (GNOME, Xfce, some Windows graphics drivers), Ctrl+Alt+Shift moves a window to another workspace (GNOME, Cinnamon, Xfce), Super belongs to the desktop, Alt is the browser's back and forward, Alt+Shift focuses tiles. No browser, GNOME, KDE, macOS or Claude Code default uses Ctrl+Shift+Arrows; it costs a terminal editor's word selection inside a tile while the grid is open. The Help modal lists the chords and the header drag; the shortcut overlay lists the registry. zh-CN entries for every new string. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/app.js | 39 ++++++ src/web/public/i18n.js | 8 ++ src/web/public/index.html | 2 + src/web/public/tile-grid.js | 39 +++++- test/tile-grid-i18n.test.ts | 6 +- test/tile-grid-move.test.ts | 217 +++++++++++++++++++++++++++++++ test/tile-grid-shortcuts.test.ts | 23 +++- 7 files changed, 328 insertions(+), 6 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index ecf64b61..ad8b4f3a 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -579,6 +579,45 @@ const DEFAULT_SHORTCUTS = [ bindings: [{ modifiers: ['alt', 'shift'], key: 'ArrowDown' }], action: 'focusTileDown', }, + // Move the focused tile: it trades places with the neighbour the focus + // chords would pick. Ctrl+Shift+Arrows because every other two-modifier + // arrow chord is taken: Ctrl+Alt+Arrows switch workspaces (GNOME, Xfce, and + // rotate the screen on some Windows graphics drivers), Ctrl+Alt+Shift+Arrows + // move a window to another workspace (GNOME, Cinnamon, Xfce), Super chords + // belong to the desktop, Alt+Arrows are the browser's back and forward, and + // Alt+Shift+Arrows focus tiles. No browser, GNOME, KDE or macOS default and + // no Claude Code default uses Ctrl+Shift+Arrows (it parallels Ctrl+Shift+{ } + // for moving tabs); what it costs is word selection, so the chords skip a + // text field (tileShortcutFor) and give up only a terminal editor's + // word selection (nano, micro, emacs) inside a tile while the grid is open. + { + id: 'move-tile-left', + group: 'Tiles', + label: 'Move Tile Left', + bindings: [{ modifiers: ['ctrl', 'shift'], key: 'ArrowLeft' }], + action: 'moveTileLeft', + }, + { + id: 'move-tile-right', + group: 'Tiles', + label: 'Move Tile Right', + bindings: [{ modifiers: ['ctrl', 'shift'], key: 'ArrowRight' }], + action: 'moveTileRight', + }, + { + id: 'move-tile-up', + group: 'Tiles', + label: 'Move Tile Up', + bindings: [{ modifiers: ['ctrl', 'shift'], key: 'ArrowUp' }], + action: 'moveTileUp', + }, + { + id: 'move-tile-down', + group: 'Tiles', + label: 'Move Tile Down', + bindings: [{ modifiers: ['ctrl', 'shift'], key: 'ArrowDown' }], + action: 'moveTileDown', + }, { id: 'zoom-tile', group: 'Tiles', diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 3b794c9e..3e9e03fa 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -114,6 +114,14 @@ 'Focus Tile Up': '聚焦上方窗格', 'Focus Tile Down': '聚焦下方窗格', 'Focus Tile Left / Right / Up / Down': '聚焦左侧 / 右侧 / 上方 / 下方窗格', + 'Move Tile Left': '向左移动窗格', + 'Move Tile Right': '向右移动窗格', + 'Move Tile Up': '向上移动窗格', + 'Move Tile Down': '向下移动窗格', + 'Move Tile Left / Right / Up / Down': '向左 / 右 / 上 / 下移动窗格', + Drag: '拖动', + "a tile's header": '窗格的标题栏', + 'Move the Tile (onto Another: Swap)': '移动窗格(拖到另一个窗格上:互换位置)', 'Zoom Focused Tile': '放大聚焦的窗格', 'Remove Focused Tile': '移除聚焦的窗格', 'Add the Session to the Tile Grid': '将该会话加入平铺网格', diff --git a/src/web/public/index.html b/src/web/public/index.html index 53484f3e..3fbe4530 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -838,6 +838,8 @@
Ctrl+Shift+G
Toggle Tile Grid
Alt/Option+Shift+Arrows
Focus Tile Left / Right / Up / Down
+
Ctrl+Shift+Arrows
Move Tile Left / Right / Up / Down
+
Drag a tile's header
Move the Tile (onto Another: Swap)
Alt/Option+Shift+Enter
Zoom Focused Tile
Ctrl/Cmd+Click a tab
Add the Session to the Tile Grid
Right-click the Tiles button
Choose Which Sessions to Tile
diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index 6772cb94..88ba58cf 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -40,17 +40,33 @@ function tileGridTracks(fr) { return fr.map((f) => `minmax(0, ${Math.round(f * 1000) / 1000}fr)`).join(` ${TILE_DIVIDER_PX}px `); } // Registry ids of the tile chords (DEFAULT_SHORTCUTS, app.js), and whether each -// needs the grid open. The toggle applies wherever a grid could open. +// needs the grid open. The toggle applies wherever a grid could open. The move +// chords leave a text field its keys (`move`: see tileShortcutFor). const TILE_SHORTCUTS = { 'toggle-tile-grid': { needsOpen: false }, 'focus-tile-left': { needsOpen: true, direction: 'left' }, 'focus-tile-right': { needsOpen: true, direction: 'right' }, 'focus-tile-up': { needsOpen: true, direction: 'up' }, 'focus-tile-down': { needsOpen: true, direction: 'down' }, + 'move-tile-left': { needsOpen: true, move: 'left' }, + 'move-tile-right': { needsOpen: true, move: 'right' }, + 'move-tile-up': { needsOpen: true, move: 'up' }, + 'move-tile-down': { needsOpen: true, move: 'down' }, 'remove-tile': { needsOpen: true }, 'zoom-tile': { needsOpen: true }, }; +/** + * A text field other than a terminal's own input (xterm's helper textarea): + * the rename input, the file editor, a settings field. Ctrl+Shift+Arrows + * selects by word there, so the move chords leave it alone. + */ +function isTextFieldTarget(target) { + if (!target || target.classList?.contains?.('xterm-helper-textarea')) return false; + const tag = String(target.tagName || '').toUpperCase(); + return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable === true; +} + /** The grid's state. `has(id)` answers only while it is open. */ class TileGridModel { constructor() { @@ -528,9 +544,11 @@ Object.assign(CodemanApp.prototype, { * 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 (owner - * decision 6 in docs/tile-grid-plan.md). The focus, + * decision 6 in docs/tile-grid-plan.md). The focus, move, * zoom and remove chords apply only while the grid is open, however it was - * opened. Registry-aware (rebinds and disables in App Settings, Shortcuts). + * opened (a move chord also while a tile is zoomed, as a no-op, so its keys + * never reach the CLI; and never in a text field, whose keys they are). + * 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. * @@ -543,6 +561,7 @@ 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.move && isTextFieldTarget(e.target)) continue; if (spec.needsOpen) return open ? shortcut.id : null; const enabled = this.loadAppSettingsFromStorage?.()?.showTileGridButton === true; return open || (enabled && this.canOpenTileGrid()) ? shortcut.id : null; @@ -558,6 +577,7 @@ Object.assign(CodemanApp.prototype, { else if (id === 'remove-tile') this.removeFocusedTile(); else if (id === 'zoom-tile') this.zoomTile(this._tileGrid?.focusedId); else if (spec.direction) this.focusTileInDirection(spec.direction); + else if (spec.move) this.moveTileInDirection(spec.move); }, /** @@ -838,6 +858,19 @@ Object.assign(CodemanApp.prototype, { return this._reorderTiles(ids); }, + /** + * Move Tile Left/Right/Up/Down: the focused tile trades places with its + * neighbour in that direction, the neighbour the Alt+Shift+Arrow focus + * chords pick (tileInDirection). Focus stays on the moved tile. Nothing while + * a tile is zoomed, or at an edge. + */ + moveTileInDirection(direction) { + const grid = this._tileGrid; + if (!grid?.open || grid.zoomedId || !grid.focusedId) return; + const neighbor = window.CodemanTileGrid.tileInDirection(grid.ids, grid.focusedId, direction, grid.cols); + if (neighbor) this._swapTiles(grid.focusedId, neighbor); + }, + /** * A tab dropped on a tile, or a tile dragged there by its header: a session * not yet tiled REPLACES that tile (same place; the replaced session keeps diff --git a/test/tile-grid-i18n.test.ts b/test/tile-grid-i18n.test.ts index 27b830ce..4ee26d44 100644 --- a/test/tile-grid-i18n.test.ts +++ b/test/tile-grid-i18n.test.ts @@ -369,7 +369,7 @@ describe('every tile grid string the code puts on screen translates to zh-CN', ( it('the shortcut registry entries: the Tiles group, its labels, "not bound", the group menu item', () => { const app = read('app.js'); const labels = [...app.matchAll(/group: 'Tiles',\s*label: '([^']+)'/g)].map((m) => m[1]); - expect(labels).toHaveLength(7); + expect(labels).toHaveLength(11); expect(app).toContain("label: 'Open group as tiles'"); expect(app).toContain('not bound'); const bad = ['Tiles', 'not bound', 'Open group as tiles', ...labels].filter((s) => { @@ -417,6 +417,10 @@ describe('the static markup through the real translator (JSDOM, zh-CN)', () => { expect(leftover(tiles!.textContent!)).toEqual([]); expect(tiles!.textContent).toContain('切换平铺网格'); expect(tiles!.textContent).toContain('右键单击'); + // Moving tiles: the chords and the header drag. + expect(tiles!.textContent).toContain('向左 / 右 / 上 / 下移动窗格'); + expect(tiles!.textContent).toContain('拖动'); + expect(tiles!.textContent).toContain('窗格的标题栏'); }); it('user text stays as typed: a session name and a group name that are also UI words', () => { diff --git a/test/tile-grid-move.test.ts b/test/tile-grid-move.test.ts index 141322ea..09b8f9ab 100644 --- a/test/tile-grid-move.test.ts +++ b/test/tile-grid-move.test.ts @@ -17,6 +17,12 @@ * input starts no drag, and a double-click on the name still renames. * - Moving is off while a tile is zoomed (the header drag, a tab drag of a * tiled session, the chords) and with a single tile. + * - Move Tile Left/Right/Up/Down (Ctrl+Shift+Arrows): the focused tile trades + * places with the neighbour the Alt+Shift+Arrow focus chords pick, checked + * against hand-written tables for every cell and direction of 2x1, 2x2, 3x2 + * and the partial 2x2 and 3x2; focus stays on the moved tile. The chords + * apply (and are swallowed) only while the grid is open, zoomed included as a + * no-op, and never in a text field, where Ctrl+Shift+Arrows select by word. * * Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A. */ @@ -24,6 +30,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FakeEl, FakeTile, + documentAddEventListener, fetchSpy, localStore, makeGridApp, @@ -405,3 +412,213 @@ describe('sizes belong to the cells: only a tile whose size changed fits, once', expect(fitCounts(SIX)).toEqual({ 's-a': 1, 's-b': 1, 's-c': 0, 's-d': 0, 's-e': 0, 's-f': 0 }); }); }); + +describe('Move Tile Left/Right/Up/Down (Ctrl+Shift+Arrows)', () => { + const ARROW = { left: 'ArrowLeft', right: 'ArrowRight', up: 'ArrowUp', down: 'ArrowDown' } as const; + type Dir = keyof typeof ARROW; + const chord = (dir: Dir, overrides: Record = {}) => ({ + type: 'keydown', + key: ARROW[dir], + code: ARROW[dir], + ctrlKey: true, + shiftKey: true, + altKey: false, + metaKey: false, + preventDefault: vi.fn(), + target: { closest: () => null, classList: { contains: (c: string) => c === 'xterm-helper-textarea' } }, + ...overrides, + }); + + 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]; + } + + // The neighbour in each direction, by cell, written out by hand (null: an + // edge). A partial last row: down from a cell above an empty one goes to the + // last tile, the rule the focus chords follow. + const TABLES: Record< + string, + { ids: string[]; cols: number; width?: number; next: Array> } + > = { + '2x1': { + cols: 2, + ids: ['s-a', 's-b'], + next: [ + { left: null, right: 1, up: null, down: null }, + { left: 0, right: null, up: null, down: null }, + ], + }, + '2x2': { + cols: 2, + ids: FOUR, + next: [ + { left: null, right: 1, up: null, down: 2 }, + { left: 0, right: null, up: null, down: 3 }, + { left: null, right: 3, up: 0, down: null }, + { left: 2, right: null, up: 1, down: null }, + ], + }, + '3x2': { + cols: 3, + ids: SIX, + next: [ + { left: null, right: 1, up: null, down: 3 }, + { left: 0, right: 2, up: null, down: 4 }, + { left: 1, right: null, up: null, down: 5 }, + { left: null, right: 4, up: 0, down: null }, + { left: 3, right: 5, up: 1, down: null }, + { left: 4, right: null, up: 2, down: null }, + ], + }, + '2x2 with 3 tiles': { + cols: 2, + ids: ['s-a', 's-b', 's-c'], + width: 1700, + next: [ + { left: null, right: 1, up: null, down: 2 }, + { left: 0, right: null, up: null, down: 2 }, + { left: null, right: null, up: 0, down: null }, + ], + }, + '3x2 with 5 tiles': { + cols: 3, + ids: SIX.slice(0, 5), + width: 1700, + next: [ + { left: null, right: 1, up: null, down: 3 }, + { left: 0, right: 2, up: null, down: 4 }, + { left: 1, right: null, up: null, down: 4 }, + { left: null, right: 4, up: 0, down: null }, + { left: 3, right: null, up: 1, down: null }, + ], + }, + }; + + afterEach(() => { + delete (section as unknown as Record).getBoundingClientRect; + }); + + for (const [layout, table] of Object.entries(TABLES)) { + it(`${layout}: every cell, every direction, swaps with the right neighbour (or nothing at an edge)`, () => { + const bad: string[] = []; + table.next.forEach((next, i) => { + for (const dir of Object.keys(ARROW) as Dir[]) { + resetGridHarness(); + if (table.width) { + const w = table.width; + section.getBoundingClientRect = () => ({ width: w, height: 1000, top: 0, left: 0, right: w, bottom: 1000 }); + } + const app = openGrid(table.ids, table.ids[i]); + expect(app._tileGrid.cols).toBe(table.cols); + const costsNothing = snapshotCost(); + app.markIdleAlertSeen.mockClear(); + const onKeydown = handlerFor(app); + const e = chord(dir); + onKeydown(e); + const expected = table.ids.slice(); + const j = next[dir]; + if (j !== null) [expected[i], expected[j]] = [expected[j], expected[i]]; + const moved = table.ids[i]; + const ok = + e.preventDefault.mock.calls.length === 1 && + JSON.stringify(app._tileGrid.ids) === JSON.stringify(expected) && + app.activeSessionId === moved && + app._tileGrid.focusedId === moved && + tileEl(moved).classList.contains('focused') && + JSON.stringify(stored().ids) === JSON.stringify(expected) && + stored().focused === moved; + if (!ok) bad.push(`cell ${i} ${dir}: got ${app._tileGrid.ids.join(',')} focus ${app.activeSessionId}`); + costsNothing(); + // Equal cells: nothing changed size, so nothing fits. + expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true); + } + }); + expect(bad).toEqual([]); + }); + } + + it('focus stays on the moved tile through several moves, and the order is stored', () => { + const app = openGrid(SIX, 's-a'); + const onKeydown = handlerFor(app); + for (const dir of ['right', 'right', 'down', 'left'] as Dir[]) onKeydown(chord(dir)); + // a: 0 -> 1 -> 2 -> 5 -> 4 + expect(app._tileGrid.ids.indexOf('s-a')).toBe(4); + expect(app.activeSessionId).toBe('s-a'); + expect(stored().ids).toEqual(app._tileGrid.ids); + expect(stored().focused).toBe('s-a'); + }); + + it('only a tile whose cell size changed fits, once', () => { + const app = openGrid(SIX, 's-a'); + app._tileGrid.colFr = [2, 1, 1]; + app._applyTileLayout(); + clearFits(); + handlerFor(app)(chord('right')); + expect(fitCounts(SIX)).toEqual({ 's-a': 1, 's-b': 1, 's-c': 0, 's-d': 0, 's-e': 0, 's-f': 0 }); + }); + + describe('swallowed only while they apply', () => { + it('grid closed: not a tile chord, and the capture handler leaves it alone (it reaches the terminal)', () => { + const app = makeGridApp(FOUR); + // Even with the Tiles setting on (where the toggle chord would apply). + app.loadAppSettingsFromStorage = () => ({ showTileGridButton: true }); + expect(app.tileShortcutFor(chord('right'))).toBeNull(); + const e = chord('right'); + handlerFor(app)(e); + expect(e.preventDefault).not.toHaveBeenCalled(); + }); + + it('grid open, from a terminal: applies and is swallowed', () => { + const app = openGrid(FOUR); + expect(app.tileShortcutFor(chord('right'))).toBe('move-tile-right'); + expect(app.tileShortcutFor(chord('down'))).toBe('move-tile-down'); + }); + + it('in a text field (the rename input, an editor) it is left to the field: word selection', () => { + const app = openGrid(FOUR); + const onKeydown = handlerFor(app); + for (const tagName of ['INPUT', 'TEXTAREA']) { + const e = chord('right', { target: { tagName, closest: () => null, classList: { contains: () => false } } }); + expect(app.tileShortcutFor(e)).toBeNull(); + onKeydown(e); + expect(e.preventDefault).not.toHaveBeenCalled(); + } + const editable = chord('left', { target: { isContentEditable: true, closest: () => null } }); + expect(app.tileShortcutFor(editable)).toBeNull(); + expect(app._tileGrid.ids).toEqual(FOUR); + }); + + it('while a tile is zoomed it still applies (swallowed, never typed into the CLI) and moves nothing', () => { + const app = openGrid(FOUR); + app.zoomTile('s-a'); + const e = chord('right'); + expect(app.tileShortcutFor(e)).toBe('move-tile-right'); + handlerFor(app)(e); + expect(e.preventDefault).toHaveBeenCalled(); + expect(app._tileGrid.ids).toEqual(FOUR); + expect(app._tileGrid.zoomedId).toBe('s-a'); + }); + + it('honours a disable and a rebind from App Settings', () => { + const app = openGrid(FOUR); + app.loadAppSettingsFromStorage = () => ({ + shortcutOverrides: { + 'move-tile-right': { disabled: true }, + 'move-tile-left': { bindings: [{ modifiers: ['ctrl', 'alt'], key: 'h', code: 'KeyH' }] }, + }, + }); + expect(app.tileShortcutFor(chord('right'))).toBeNull(); + expect(app.tileShortcutFor(chord('left'))).toBeNull(); + expect(app.tileShortcutFor(chord('left', { key: 'h', code: 'KeyH', shiftKey: false, altKey: true }))).toBe( + 'move-tile-left' + ); + }); + }); +}); diff --git a/test/tile-grid-shortcuts.test.ts b/test/tile-grid-shortcuts.test.ts index 339a1f51..5cbab3f3 100644 --- a/test/tile-grid-shortcuts.test.ts +++ b/test/tile-grid-shortcuts.test.ts @@ -3,8 +3,8 @@ * 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 + * the grid, Alt+Shift+Arrows move focus between tiles, Ctrl+Shift+Arrows move + * the focused tile (tile-grid-move.test.ts), 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 @@ -69,6 +69,11 @@ describe('registry', () => { expect(byId[`focus-tile-${dir.toLowerCase()}`].bindings).toEqual([ { modifiers: ['alt', 'shift'], key: `Arrow${dir}` }, ]); + // Moving the focused tile (tile-grid-move.test.ts). + expect(byId[`move-tile-${dir.toLowerCase()}`].bindings).toEqual([ + { modifiers: ['ctrl', 'shift'], key: `Arrow${dir}` }, + ]); + expect(byId[`move-tile-${dir.toLowerCase()}`].group).toBe('Tiles'); } expect(byId['remove-tile'].bindings).toEqual([]); expect(byId['toggle-tile-grid'].group).toBe('Tiles'); @@ -351,6 +356,20 @@ describe('xterm key handlers swallow a chord that applies', () => { expect(handler(chord(RIGHT))).toBe(true); }); + const MOVE_RIGHT = { key: 'ArrowRight', code: 'ArrowRight', ctrlKey: true, shiftKey: true }; + it.each(['keydown', 'keypress', 'keyup'])( + 'returns false for Ctrl+Shift+Right, Move Tile Right (%s), while the grid is open', + async (type) => { + const handler = await tileHandler(true); + expect(handler({ ...chord(MOVE_RIGHT), type })).toBe(false); + } + ); + + it('lets Ctrl+Shift+Right through to the PTY when no grid is open', async () => { + const handler = await tileHandler(false); + expect(handler(chord(MOVE_RIGHT))).toBe(true); + }); + it('Alt+Shift+Enter (zoom) never becomes a Shift+Enter newline in the tile session', async () => { const handler = await tileHandler(true); fetchMock.mockClear();