feat(tiles): right-click Tiles is a 2 / 4 / 6 count menu

Owner decision 10 ("give me then the option to choose only HOW many
tiles, 2,4,6 default is 6 so the menu is easier"; asked where it lives:
"Click opens 6"). The click still opens the grid at once; right-click,
Shift+F10 or the Menu key on the button opens a small menu of three
counts, each drawn as the grid's own layout (2x1, 2x2, 3x2), the
remembered one checked. It replaces the session picker, which is gone
(method, markup hook, CSS, zh-CN entries).

- A pick is remembered per device in codeman:tile-count (default 6;
  codeman:tile-grid stays ids only) and opens that many tiles. The click
  and Ctrl+Shift+G then open with it, at most what the window fits.
- Which sessions: the rule the click already had (the grid this tab
  last had, else an open split's two, else tab order, the active one
  included and focused), trimmed from the end (the focused one kept) or
  filled from tab order to the count. A remembered grid comes back with
  its tiles first, in their cells, holes filled first, then tab order
  (owner answer, superseding decision 8's "exactly the stored set"). A
  page-load restore still brings back exactly what was stored.
- With the grid open a pick re-forms it: a count change is a shape
  change under the cell model's rule (reformTileCells), the focused tile
  always kept, every joining tile mounted and laid out before any of
  them connects, so each fits once and sends one PTY resize.
- Ctrl/Cmd+click on a tab with the grid closed opens the count in total,
  that session among them and focused (owner answer: N, not N+1).
- A count the window cannot fit is greyed out with the reason; a
  remembered one stays checked, the keyboard starts on the largest that
  fits. Arrows, Home/End, Enter or Space; Escape closes the menu alone
  (the global handler gives it the key first, like the tab-group menu)
  and puts the keyboard back on the Tiles button; Tab and a click
  elsewhere close it.
- zh-CN for every new string (N 个窗格, 窗格数量, the titles, the Help
  modal row); the i18n test harvests the menu now, with a session named
  "6 tiles" as the user-text trap.

Tests: tile-grid-picker.test.ts becomes tile-grid-count-menu.test.ts
(the button checks kept, every picker check carried over to the menu,
plus keyboard, remembered count, re-form, Ctrl/Cmd+click and the batch
connect); the open-set, cap, restore, shortcuts, split-coexistence and
i18n expectations follow the count; the Help modal test escapes its
label (the new one has parentheses). The vm harness tracks
document.activeElement and makes SVG elements.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-08 04:10:55 +02:00
parent 64298101b0
commit 7debebb7b3
16 changed files with 973 additions and 483 deletions
+6 -1
View File
@@ -1417,12 +1417,17 @@ class CodemanApp {
this._tabLayoutDragKeydown(e); this._tabLayoutDragKeydown(e);
return; return;
} }
// So does the Tiles count menu: it closes alone and gives the keyboard
// back to the Tiles button (tile-grid.js).
if (this._tileCountMenu) {
this.closeTileCountMenu({ refocus: true });
return;
}
this.closeAllPanels(); this.closeAllPanels();
this.closeHelp(); this.closeHelp();
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory(); if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
this.closeSessionManager(); this.closeSessionManager();
this.closeCommandPalette?.(); this.closeCommandPalette?.();
this.closeTilePicker?.();
this.closeShortcutOverlay?.(); this.closeShortcutOverlay?.();
// Overlay layouts only: below 1024px the sidebar is a modal off-canvas // Overlay layouts only: below 1024px the sidebar is a modal off-canvas
// drawer over the terminal, so Escape must close it. The docked desktop // drawer over the terminal, so Escape must close it. The docked desktop
+4 -4
View File
@@ -1793,8 +1793,8 @@ function dragTrackFractions(fr, index, deltaPx, totalPx, minPx) {
} }
/** /**
* The sessions the Tiles picker offers (and the Tiles button opens, case c of * The sessions the Tiles button can open (case c of tileGridOpenSet, and the
* tileGridOpenSet), in tab order: live ones only, never a session popped out to * ones a count fills a grid with), in tab order: live ones only, never a session popped out to
* its own window (that window owns its PTY size). A session with no PTY * its own window (that window owns its PTY size). A session with no PTY
* attached IS offered: its tile shows the Attach overlay. * attached IS offered: its tile shows the Attach overlay.
* *
@@ -1820,8 +1820,8 @@ function buildTilePickerSessions(sessions, sessionOrder, detachedIds) {
* a. the grid this tab last had (`stored`, already sanitized: live, not * a. the grid this tab last had (`stored`, already sanitized: live, not
* detached, at most the cap), if any of its sessions survive; * detached, at most the cap), if any of its sessions survive;
* b. else an open split's two sessions, Pane A focused; * b. else an open split's two sessions, Pane A focused;
* c. else the open sessions in tab order, the picker's list (no detached * c. else the open sessions in tab order (buildTilePickerSessions: no
* ones), up to `limit`, the active session always among them and focused * detached ones), up to `limit`, the active session always among them and focused
* (when it sits past the limit, the first `limit - 1` others come with it). * (when it sits past the limit, the first `limit - 1` others come with it).
* Null when there is nothing to open. * Null when there is nothing to open.
* *
+6 -8
View File
@@ -103,10 +103,10 @@
Tiles: '平铺', Tiles: '平铺',
Split: '分屏', Split: '分屏',
'Tiled sessions': '平铺的会话', 'Tiled sessions': '平铺的会话',
'Tiles: show several sessions side by side (right-click to choose which)': 'Tiles: show several sessions side by side (right-click for how many)':
'平铺:并排显示多个会话(右键单击可选择会话)', '平铺:并排显示多个会话(右键单击可选择窗格数量)',
'Tiles: back to a single session (right-click to choose which sessions)': 'Tiles: back to a single session (right-click for how many tiles)': '平铺:返回单个会话(右键单击可选择窗格数量)',
'平铺:返回单个会话(右键单击可选择会话)', 'How many tiles': '窗格数量',
'Split: unavailable while tiles are open': '分屏:平铺打开时不可用', 'Split: unavailable while tiles are open': '分屏:平铺打开时不可用',
'Toggle Tile Grid': '切换平铺网格', 'Toggle Tile Grid': '切换平铺网格',
'Focus Tile Left': '聚焦左侧窗格', 'Focus Tile Left': '聚焦左侧窗格',
@@ -125,7 +125,7 @@
'Zoom Focused Tile': '放大聚焦的窗格', 'Zoom Focused Tile': '放大聚焦的窗格',
'Remove Focused Tile': '移除聚焦的窗格', 'Remove Focused Tile': '移除聚焦的窗格',
'Add the Session to the Tile Grid': '将该会话加入平铺网格', 'Add the Session to the Tile Grid': '将该会话加入平铺网格',
'Choose Which Sessions to Tile': '选择要平铺的会话', 'Choose How Many Tiles (2, 4 or 6)': '选择窗格数量(2、4 或 6)',
'a tab': '标签页', 'a tab': '标签页',
'the Tiles button': '平铺按钮', 'the Tiles button': '平铺按钮',
Click: '单击', Click: '单击',
@@ -133,8 +133,6 @@
Arrows: '方向键', Arrows: '方向键',
'not bound': '未绑定', 'not bound': '未绑定',
'Open group as tiles': '以平铺方式打开分组', 'Open group as tiles': '以平铺方式打开分组',
'Show sessions as tiles': '以平铺方式显示会话',
'Open tiles': '打开平铺',
'No sessions to show as tiles': '没有可平铺显示的会话', 'No sessions to show as tiles': '没有可平铺显示的会话',
'This group has no session to show as tiles': '此分组没有可平铺显示的会话', 'This group has no session to show as tiles': '此分组没有可平铺显示的会话',
'Zoom this tile': '放大此窗格', 'Zoom this tile': '放大此窗格',
@@ -1058,7 +1056,7 @@
(_m, group) => `删除分组“${group}”?其中的标签将移到未分组。`, (_m, group) => `删除分组“${group}”?其中的标签将移到未分组。`,
], ],
// Tile grid: counts, exit codes and durations pass through. // Tile grid: counts, exit codes and durations pass through.
[/^Up to (\d+) tiles$/, (_m, n) => `最多 ${n} 个窗格`], [/^(\d+) tiles$/, (_m, n) => `${n} 个窗格`],
[/^This window fits (\d+) tiles?$/, (_m, n) => `此窗口可容纳 ${n} 个窗格`], [/^This window fits (\d+) tiles?$/, (_m, n) => `此窗口可容纳 ${n} 个窗格`],
[/^The grid holds at most (\d+) tiles$/, (_m, n) => `平铺网格最多容纳 ${n} 个窗格`], [/^The grid holds at most (\d+) tiles$/, (_m, n) => `平铺网格最多容纳 ${n} 个窗格`],
[ [
+2 -2
View File
@@ -192,7 +192,7 @@
<button class="btn-icon-header btn-file-viewer" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></button> <button class="btn-icon-header btn-file-viewer" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></button>
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button> <button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
<button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker(event)" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button> <button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker(event)" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button>
<button class="btn-icon-header btn-tile-grid btn-tile-grid--hidden" onclick="app.toggleTileGrid()" oncontextmenu="app.openTilePicker(event)" title="Tiles: show several sessions side by side (right-click to choose which)" aria-label="Tiles: show several sessions side by side (right-click to choose which)" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/></svg></button> <button class="btn-icon-header btn-tile-grid btn-tile-grid--hidden" onclick="app.toggleTileGrid()" oncontextmenu="app.openTileCountMenu(event)" title="Tiles: show several sessions side by side (right-click for how many)" aria-label="Tiles: show several sessions side by side (right-click for how many)" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/></svg></button>
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button> <button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude and Codex plan usage limits">—</div> <div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude and Codex plan usage limits">—</div>
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;"> <button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
@@ -842,7 +842,7 @@
<div><kbd>Drag</kbd> a tile's header</div><div>Move the Tile (onto Another: Swap)</div> <div><kbd>Drag</kbd> a tile's header</div><div>Move the Tile (onto Another: Swap)</div>
<div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Enter</kbd></div><div>Zoom Focused Tile</div> <div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Enter</kbd></div><div>Zoom Focused Tile</div>
<div><kbd>Ctrl/Cmd</kbd>+<kbd>Click</kbd> a tab</div><div>Add the Session to the Tile Grid</div> <div><kbd>Ctrl/Cmd</kbd>+<kbd>Click</kbd> a tab</div><div>Add the Session to the Tile Grid</div>
<div><kbd>Right-click</kbd> the Tiles button</div><div>Choose Which Sessions to Tile</div> <div><kbd>Right-click</kbd> the Tiles button</div><div>Choose How Many Tiles (2, 4 or 6)</div>
</div> </div>
</section> </section>
<section class="shortcut-section"> <section class="shortcut-section">
+70 -48
View File
@@ -19766,10 +19766,8 @@ body.tile-grid-resizing--row * {
text-align: center; text-align: center;
} }
/* The grid's accent buttons: the Attach overlay's, and the picker's Open /* The Attach overlay's accent button. */
(which narrows the padding in its own block below). */ .tile-attach-btn {
.tile-attach-btn,
.tile-picker-open {
padding: 4px 14px; padding: 4px 14px;
font: inherit; font: inherit;
color: var(--accent-ink, #fff); color: var(--accent-ink, #fff);
@@ -19779,8 +19777,7 @@ body.tile-grid-resizing--row * {
cursor: pointer; cursor: pointer;
} }
.tile-attach-btn:disabled, .tile-attach-btn:disabled {
.tile-picker-open:disabled {
opacity: 0.5; opacity: 0.5;
cursor: default; cursor: default;
} }
@@ -19802,70 +19799,95 @@ body.tile-grid-resizing--row * {
pointer-events: none; pointer-events: none;
} }
/* Tiles picker (tile-grid.js openTilePicker): a checkbox per open session, /* The Tiles button's right-click menu (tile-grid.js openTileCountMenu): how
positioned `fixed` under the Tiles button; same layer as the split picker. */ many tiles, 2, 4 or 6, each with its shape; positioned `fixed` under the
.tile-picker-menu { button, on the split picker's layer and in its floating style. */
.tile-count-menu {
position: fixed; position: fixed;
z-index: 1000; z-index: 1000;
min-width: 240px; min-width: 168px;
max-width: 340px;
max-height: 60vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px;
padding: 6px; padding: 6px;
background: var(--bg-secondary, #1a1a1a); background: var(--floating-bg, var(--bg-secondary, #1a1a1a));
border: 1px solid var(--border-color, #333); backdrop-filter: blur(20px);
border-radius: 6px; -webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); border: 1px solid var(--control-border, var(--border-color, #333));
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45), 0 2px 8px rgba(0, 0, 0, 0.25);
font-size: 13px; font-size: 13px;
} }
.tile-picker-list { .tile-count-heading {
overflow-y: auto; padding: 2px 8px 4px;
min-height: 0; font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted, #888);
} }
.tile-picker-item { .tile-count-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
padding: 4px 6px; width: 100%;
border-radius: 4px; padding: 6px 8px;
border: none;
border-radius: 6px;
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer; cursor: pointer;
} }
.tile-picker-item:hover { .tile-count-item:hover:not(:disabled),
background: color-mix(in srgb, var(--text-muted, #888) 14%, transparent); .tile-count-item:focus {
outline: none;
background: color-mix(in srgb, var(--text-muted, #888) 16%, transparent);
} }
.tile-picker-item span { .tile-count-item:disabled {
overflow: hidden; opacity: 0.4;
cursor: default;
}
/* The shape: the grid's own layout for that count, as outlined cells; the
checked count's cells are filled with the accent. */
.tile-count-glyph {
flex: 0 0 auto;
width: 30px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-width: 1.2;
opacity: 0.75;
}
.tile-count-item[aria-checked='true'] .tile-count-glyph {
fill: color-mix(in srgb, var(--accent, #4a9eff) 35%, transparent);
stroke: var(--accent, #4a9eff);
opacity: 1;
}
.tile-count-label {
flex: 1 1 auto;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis;
} }
.tile-picker-footer { .tile-count-check {
display: flex; flex: 0 0 1em;
align-items: center; color: var(--accent, #4a9eff);
justify-content: space-between; text-align: center;
gap: 8px;
padding: 6px 4px 2px;
border-top: 1px solid var(--border-color, #333);
margin-top: 4px;
} }
.tile-picker-hint, .tile-count-hint {
.tile-picker-empty { padding: 4px 8px 2px;
color: var(--text-muted); border-top: 1px solid var(--control-border, var(--border-color, #333));
font-size: 12px; margin-top: 2px;
} color: var(--text-muted, #888);
font-size: 11px;
.tile-picker-empty {
padding: 6px 8px;
}
.tile-picker-open {
padding: 4px 12px;
} }
/* Split-picker menu: a small popover listing sessions to split with, appended /* Split-picker menu: a small popover listing sessions to split with, appended
+294 -123
View File
@@ -28,6 +28,10 @@ const TILE_GRID_FONT_KEY = 'codeman-tile-font-size';
// in reading order with `null` for an empty one. `open: false` keeps it // in reading order with `null` for an empty one. `open: false` keeps it
// remembered for one-click return; restored on reload inside handleInit. // remembered for one-click return; restored on reload inside handleInit.
const TILE_GRID_STORAGE_KEY = 'codeman:tile-grid'; const TILE_GRID_STORAGE_KEY = 'codeman:tile-grid';
// How many tiles a click on Tiles opens: the count last picked in its
// right-click menu (2, 4 or 6; owner decision 10), per device. Its own key, so
// the stored grid above stays ids only.
const TILE_GRID_COUNT_KEY = 'codeman:tile-count';
// Trailing debounce for refitting tiles after the grid area changes size, so a // Trailing debounce for refitting tiles after the grid area changes size, so a
// window drag sends each tile's PTY one resize, not one per frame. // window drag sends each tile's PTY one resize, not one per frame.
const TILE_GRID_REFIT_MS = 150; const TILE_GRID_REFIT_MS = 150;
@@ -69,6 +73,34 @@ function isTextFieldTarget(target) {
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable === true; return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable === true;
} }
/**
* The count menu's picture of `n` tiles: the grid's own layout for that count
* (2x1, 2x2, 3x2) as small rounded cells.
*/
function tileCountGlyph(n) {
const { cols, rows } = window.CodemanTileGrid.computeTileLayout({ count: n });
const NS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(NS, 'svg');
svg.setAttribute('class', 'tile-count-glyph');
svg.setAttribute('viewBox', '0 0 30 20');
svg.setAttribute('aria-hidden', 'true');
const gap = 3;
const w = (30 - gap * (cols - 1)) / cols;
const h = (20 - gap * (rows - 1)) / rows;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const rect = document.createElementNS(NS, 'rect');
rect.setAttribute('x', String(c * (w + gap)));
rect.setAttribute('y', String(r * (h + gap)));
rect.setAttribute('width', String(w));
rect.setAttribute('height', String(h));
rect.setAttribute('rx', '1.5');
svg.appendChild(rect);
}
}
return svg;
}
/** The grid's state. `has(id)` answers only while it is open. */ /** The grid's state. `has(id)` answers only while it is open. */
class TileGridModel { class TileGridModel {
constructor() { constructor() {
@@ -362,8 +394,8 @@ Object.assign(CodemanApp.prototype, {
btn.classList.toggle('tiles-open', open); btn.classList.toggle('tiles-open', open);
btn.setAttribute('aria-pressed', open ? 'true' : 'false'); btn.setAttribute('aria-pressed', open ? 'true' : 'false');
const title = open const title = open
? 'Tiles: back to a single session (right-click to choose which sessions)' ? 'Tiles: back to a single session (right-click for how many tiles)'
: 'Tiles: show several sessions side by side (right-click to choose which)'; : 'Tiles: show several sessions side by side (right-click for how many)';
btn.title = title; btn.title = title;
btn.setAttribute('aria-label', title); btn.setAttribute('aria-label', title);
}, },
@@ -371,8 +403,8 @@ Object.assign(CodemanApp.prototype, {
/** /**
* How many tiles the grid takes here and now: what the terminal area fits * How many tiles the grid takes here and now: what the terminal area fits
* (the grid section, or the single view it would replace), at most the cap * (the grid section, or the single view it would replace), at most the cap
* (TILE_GRID_MAX), never less than one. The texts say which of the two * (TILE_GRID_MAX), never less than one. `full` says which of the two binds,
* binds, so a large monitor never reads "this window fits 6". * so a large monitor never reads "this window fits 6".
*/ */
_tileGridLimit() { _tileGridLimit() {
const T = window.CodemanTileGrid; const T = window.CodemanTileGrid;
@@ -387,131 +419,236 @@ Object.assign(CodemanApp.prototype, {
const byCap = fits >= max; const byCap = fits >= max;
return { return {
capacity, capacity,
hint: byCap ? `Up to ${max} tiles` : `This window fits ${capacity} tile${capacity === 1 ? '' : 's'}`,
full: byCap full: byCap
? `The grid holds at most ${max} tiles` ? `The grid holds at most ${max} tiles`
: `The grid already holds what this window fits (${capacity})`, : `The grid already holds what this window fits (${capacity})`,
}; };
}, },
/** The tile count a click on Tiles opens (the menu's last pick, per device), default 6. */
_tileGridCount() {
let raw = null;
try {
raw = localStorage.getItem(TILE_GRID_COUNT_KEY);
} catch {
/* Storage unavailable: the default. */
}
return window.CodemanTileGrid.sanitizeTileCount(raw);
},
_rememberTileGridCount(count) {
try {
localStorage.setItem(TILE_GRID_COUNT_KEY, String(window.CodemanTileGrid.sanitizeTileCount(count)));
} catch {
/* Per-device convenience only. */
}
},
/** /**
* Right-click on the Tiles button (its click opens the grid at once, see * Right-click on the Tiles button (or Shift+F10 / the Menu key on it, which
* toggleTileGrid): a picker with a checkbox per open session, in tab order, * fire the same contextmenu event; its click opens the grid at once, see
* preselected with the tiles shown now, else the grid this tab last left * toggleTileGrid): how many tiles, 2, 4 or 6 (owner decision 10), each with
* (else the active session and an open split's two), and an Open button. * its shape, the remembered count checked. A count the window cannot fit is
* Boxes past what the grid takes are disabled. With the grid open, Open * greyed out, and the menu says why; the keyboard then starts on the largest
* replaces its set. * that fits (the count a click opens here). Picking one opens the grid with
* that many tiles, or re-forms the open grid to it, and remembers it for the
* click. Arrows move, Enter or Space picks, Escape closes and gives the
* keyboard back to the Tiles button, Tab closes.
*/ */
openTilePicker(event) { openTileCountMenu(event) {
// The right-click: the browser's own context menu stays away. // The right-click: the browser's own context menu stays away.
event?.preventDefault?.(); event?.preventDefault?.();
if (this._tilePicker) { if (this._tileCountMenu) {
this.closeTilePicker(); this.closeTileCountMenu();
return; return;
} }
if (!this.canOpenTileGrid()) return; if (!this.canOpenTileGrid()) return;
const T = window.CodemanTileGrid; const T = window.CodemanTileGrid;
const limit = this._tileGridLimit(); const capacity = this._tileGridLimit().capacity;
const capacity = limit.capacity; const fitsText = `This window fits ${capacity} tile${capacity === 1 ? '' : 's'}`;
const candidates = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions); const current = this._tileGridCount();
const gridOpen = this._tilesOwnTerminal();
const remembered = (gridOpen ? this._tileGrid.ids : this._readStoredTileGrid()?.ids || []).filter((id) =>
candidates.some((c) => c.id === id)
);
const seed = remembered.length
? remembered
: [this.activeSessionId, this._splitPane ? this._splitSessionId : null].filter(Boolean);
const checked = new Set(seed.slice(0, capacity));
const menu = document.createElement('div'); const menu = document.createElement('div');
menu.id = 'tilePickerMenu'; menu.id = 'tileCountMenu';
menu.className = 'tile-picker-menu'; menu.className = 'tile-count-menu';
menu.setAttribute('role', 'dialog'); menu.setAttribute('role', 'menu');
menu.setAttribute('aria-label', 'Show sessions as tiles'); menu.setAttribute('aria-label', 'How many tiles');
const list = document.createElement('div'); const heading = document.createElement('div');
list.className = 'tile-picker-list'; heading.className = 'tile-count-heading';
const boxes = []; heading.setAttribute('aria-hidden', 'true');
for (const c of candidates) { heading.textContent = 'Tiles';
const row = document.createElement('label'); menu.appendChild(heading);
row.className = 'tile-picker-item'; const items = [];
const box = document.createElement('input'); for (const n of T.TILE_GRID_COUNTS) {
box.type = 'checkbox'; const item = document.createElement('button');
box.value = c.id; item.type = 'button';
box.checked = checked.has(c.id); item.className = 'tile-count-item';
const name = document.createElement('span'); item.setAttribute('role', 'menuitemradio');
// A session literally named like a UI string must not be translated. item.setAttribute('aria-checked', n === current ? 'true' : 'false');
name.setAttribute('data-i18n-skip', ''); item.tabIndex = -1;
name.textContent = c.label; item.dataset.count = String(n);
row.append(box, name); if (n > capacity) {
list.appendChild(row); item.disabled = true;
boxes.push(box); item.setAttribute('aria-disabled', 'true');
item.title = fitsText;
} }
const footer = document.createElement('div'); const label = document.createElement('span');
footer.className = 'tile-picker-footer'; label.className = 'tile-count-label';
const hint = document.createElement('span'); label.textContent = `${n} tiles`;
hint.className = 'tile-picker-hint'; const check = document.createElement('span');
hint.textContent = limit.hint; check.className = 'tile-count-check';
const open = document.createElement('button'); check.setAttribute('aria-hidden', 'true');
open.type = 'button'; check.textContent = n === current ? '\u2713' : '';
open.className = 'tile-picker-open'; item.append(tileCountGlyph(n), label, check);
open.textContent = 'Open tiles'; item.addEventListener('click', (e) => {
footer.append(hint, open); e.stopPropagation?.();
if (candidates.length === 0) { if (!item.disabled) this._pickTileCount(n);
const empty = document.createElement('div');
empty.className = 'tile-picker-empty';
empty.textContent = 'No sessions to show as tiles';
menu.appendChild(empty);
} else {
menu.append(list, footer);
}
const sync = () => {
const count = boxes.filter((b) => b.checked).length;
for (const b of boxes) {
b.disabled = !b.checked && count >= capacity;
b.title = b.disabled ? limit.full : '';
}
open.disabled = count === 0;
};
for (const b of boxes) b.addEventListener('change', sync);
sync();
open.addEventListener('click', () => {
const ids = boxes.filter((b) => b.checked).map((b) => b.value);
this.closeTilePicker();
if (ids.length > 0) this._replaceTileGrid(ids);
}); });
item.addEventListener('pointerenter', () => {
if (!item.disabled) item.focus?.();
});
menu.appendChild(item);
items.push(item);
}
if (capacity < T.TILE_GRID_MAX) {
const hint = document.createElement('div');
hint.className = 'tile-count-hint';
hint.textContent = fitsText;
menu.appendChild(hint);
}
document.body.appendChild(menu); document.body.appendChild(menu);
const btn = document.querySelector('.btn-tile-grid'); const btn = document.querySelector('.btn-tile-grid');
if (btn?.getBoundingClientRect) { if (btn?.getBoundingClientRect) {
// Under the Tiles button (.tile-picker-menu is position: fixed). // Under the Tiles button (.tile-count-menu is position: fixed).
const rect = btn.getBoundingClientRect(); const rect = btn.getBoundingClientRect();
menu.style.top = `${rect.bottom + 4}px`; menu.style.top = `${rect.bottom + 6}px`;
menu.style.right = `${window.innerWidth - rect.right}px`; menu.style.right = `${Math.max(8, window.innerWidth - rect.right)}px`;
} }
// A click elsewhere or Escape closes it. A click on the Tiles button needs no const enabled = () => items.filter((i) => !i.disabled);
// exception: its own handler (toggleTileGrid) closes the picker first. const move = (delta) => {
const list = enabled();
if (list.length === 0) return;
const at = list.indexOf(document.activeElement);
const next = at === -1 ? (delta > 0 ? 0 : list.length - 1) : (at + delta + list.length) % list.length;
list[next].focus?.();
};
// A click elsewhere closes it. A click on the Tiles button needs no
// exception: its own handler (toggleTileGrid) closes the menu first.
const onOutside = (e) => { const onOutside = (e) => {
if (menu.contains?.(e.target)) return; if (menu.contains?.(e.target)) return;
this.closeTilePicker(); this.closeTileCountMenu();
}; };
// The menu's keys, in the capture phase while it is open. Escape normally
// reaches the app's global handler first, which closes the menu alone and
// gives the keyboard back (app.js); this one covers the rest.
const onKey = (e) => { const onKey = (e) => {
if (e.key === 'Escape') this.closeTilePicker(); if (e.key === 'Escape') {
e.preventDefault?.();
this.closeTileCountMenu({ refocus: true });
} else if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
e.preventDefault?.();
move(1);
} else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
e.preventDefault?.();
move(-1);
} else if (e.key === 'Home' || e.key === 'End') {
e.preventDefault?.();
const list = enabled();
list[e.key === 'Home' ? 0 : list.length - 1]?.focus?.();
} else if (e.key === 'Enter' || e.key === ' ') {
const item = items.find((i) => i === document.activeElement);
if (!item) return;
// Not the button's own activation too.
e.preventDefault?.();
if (!item.disabled) this._pickTileCount(Number(item.dataset.count));
} else if (e.key === 'Tab') {
this.closeTileCountMenu();
}
}; };
this._tilePicker = { menu, onOutside, onKey }; this._tileCountMenu = { menu, onOutside, onKey };
document.addEventListener('click', onOutside); document.addEventListener('click', onOutside);
document.addEventListener('keydown', onKey); document.addEventListener('keydown', onKey, true);
(boxes.find((b) => !b.disabled) || open).focus?.(); // The checked count, or when the window cannot fit it, the largest that fits.
const start = items.find((i) => !i.disabled && i.getAttribute('aria-checked') === 'true') || enabled().at(-1);
(start || menu).focus?.();
}, },
/** Idempotent: the global Escape handler calls it whether or not the picker is open. */ /**
closeTilePicker() { * Idempotent: the global Escape handler may call it whether or not the menu
const picker = this._tilePicker; * is open. The keyboard goes back to the Tiles button when it was in the
if (!picker) return; * menu (`refocus` unset), or when asked (Escape); a click elsewhere has
this._tilePicker = null; * already moved it, and a pick passes `refocus: false`.
document.removeEventListener('click', picker.onOutside); */
document.removeEventListener('keydown', picker.onKey); closeTileCountMenu({ refocus } = {}) {
picker.menu.remove(); const menu = this._tileCountMenu;
if (!menu) return;
if (refocus === undefined) refocus = !!menu.menu.contains?.(document.activeElement);
this._tileCountMenu = null;
document.removeEventListener('click', menu.onOutside);
document.removeEventListener('keydown', menu.onKey, true);
menu.menu.remove();
if (refocus) document.querySelector('.btn-tile-grid')?.focus?.();
},
/**
* A count picked in the menu: remembered for the click, then the grid opens
* with that many tiles (what the click opens), or the open grid is re-formed
* to it (_reformTileGrid). Never more than the window fits; fewer open
* sessions than the count give fewer tiles.
*/
_pickTileCount(count) {
this.closeTileCountMenu({ refocus: false });
this._rememberTileGridCount(count);
const grid = this._tileGrid;
if (!grid?.open) {
this.toggleTileGrid();
return;
}
const T = window.CodemanTileGrid;
const n = Math.min(T.sanitizeTileCount(count), this._tileGridLimit().capacity);
const all = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions).map((c) => c.id);
this._reformTileGrid(T.tileGridSetForCount(grid.ids, all, n, grid.focusedId));
},
/**
* The open grid re-formed to the tiles `target` (a count picked in the
* menu): a count change is a shape change, so the cell model's rule applies
* (reformTileCells: the tiles that stay keep their row and column when all
* fit, else they pack in reading order), and the tiles that join fill the
* empty cells in reading order, holes first. The focused tile is always
* among those that stay (tileGridSetForCount). Every tile that joins is
* mounted and the grid laid out ONCE before any of them connects (as in
* openTileGrid): a tile connected into an intermediate layout would fit that
* cell first and resize its PTY twice. A zoom the user chose ends: the grid
* they asked for is meant to be seen.
*/
_reformTileGrid(target) {
const grid = this._tileGrid;
if (!grid?.open) return;
const T = window.CodemanTileGrid;
const leaving = grid.ids.filter((id) => !target.includes(id));
const joining = target.filter((id) => !grid.tiles.has(id));
if (leaving.length === 0 && joining.length === 0) return;
// A divider or header drag in progress measured the old grid.
this._tileDividerDragTeardown?.();
this._endTileMoveDrag();
for (const id of leaving) {
const entry = grid.tiles.get(id);
this._destroyTerminalTile(entry.tile);
entry.el.remove();
grid.tiles.delete(id);
}
if (grid.zoomedId && !grid.autoZoom) grid.zoomedId = null;
const staying = grid.ids.filter((id) => grid.tiles.has(id));
const mounted = joining.filter((id) => this._mountTile(id));
const { cols, rows } = this._tileShapeFor(grid.tiles.size);
grid.cells = T.reformTileCells(grid.cells, grid.cols, staying, mounted, cols, rows);
grid.cols = cols;
grid.rows = rows;
this._applyTileLayout();
for (const id of mounted) this._connectTile(id);
this._scheduleTileGridRefit();
this.renderSessionTabs?.();
}, },
/** /**
@@ -598,14 +735,18 @@ Object.assign(CodemanApp.prototype, {
/** /**
* The Tiles button's click and Ctrl+Shift+G, one function so the two never * The Tiles button's click and Ctrl+Shift+G, one function so the two never
* drift (owner decision 8): opens the grid at once, no picker in the way, or * drift (owner decision 8): opens the grid at once, no menu in the way, or
* closes it to the single view of the focused session. What opens is * closes it to the single view of the focused session. What opens is the
* remembered count of tiles (the right-click menu's last pick, default 6,
* at most what the window fits; owner decision 10), chosen by
* `tileGridOpenSet` (constants.js): the grid this tab last had, else an open * `tileGridOpenSet` (constants.js): the grid this tab last had, else an open
* split's two sessions, else the open sessions in tab order up to what the * split's two sessions, else the open sessions in tab order, the active one
* grid takes here, the active one focused. The picker is a right-click away. * focused; then trimmed or filled to the count (the focused one kept). A
* remembered grid comes back with its tiles in their cells, the ones the
* count adds filling its empty cells first (_openStoredTileGrid).
*/ */
toggleTileGrid() { toggleTileGrid() {
this.closeTilePicker(); this.closeTileCountMenu();
if (this._tilesOwnTerminal()) { if (this._tilesOwnTerminal()) {
this.closeTileGrid({ keepStored: true, reselect: true }); this.closeTileGrid({ keepStored: true, reselect: true });
return; return;
@@ -617,21 +758,30 @@ Object.assign(CodemanApp.prototype, {
this.showToast?.('No sessions to show as tiles', 'info'); this.showToast?.('No sessions to show as tiles', 'info');
return; return;
} }
if (set.source === 'stored') this._openStoredTileGrid(stored); if (set.source === 'stored') this._openStoredTileGrid(stored, set.ids);
else this.openTileGrid(set.ids, { focusedId: set.focusedId }); else this.openTileGrid(set.ids, { focusedId: set.focusedId });
}, },
/** What the toggle would open now (see toggleTileGrid); `stored` saves a second read. */ /**
_tileGridOpenSet(stored = this._readStoredTileGrid()) { * What the toggle would open now (see toggleTileGrid): `count` tiles (the
return window.CodemanTileGrid.tileGridOpenSet({ * remembered count), or as many as the window fits; `stored` saves a
* second read.
*/
_tileGridOpenSet(stored = this._readStoredTileGrid(), count = this._tileGridCount()) {
const T = window.CodemanTileGrid;
const n = Math.max(1, Math.min(count, this._tileGridLimit().capacity));
const set = T.tileGridOpenSet({
stored, stored,
split: this._splitPane ? [this.activeSessionId, this._splitSessionId] : null, split: this._splitPane ? [this.activeSessionId, this._splitSessionId] : null,
sessions: this.sessions, sessions: this.sessions,
sessionOrder: this.sessionOrder, sessionOrder: this.sessionOrder,
detachedIds: this.detachedSessions, detachedIds: this.detachedSessions,
activeId: this.activeSessionId, activeId: this.activeSessionId,
limit: this._tileGridLimit().capacity, limit: n,
}); });
if (!set) return null;
const all = T.buildTilePickerSessions(this.sessions, this.sessionOrder, this.detachedSessions).map((c) => c.id);
return { ...set, ids: T.tileGridSetForCount(set.ids, all, n, set.focusedId) };
}, },
/** Alt+Shift+Arrows: a human selection of the tile in that direction. */ /** Alt+Shift+Arrows: a human selection of the tile in that direction. */
@@ -1006,7 +1156,9 @@ Object.assign(CodemanApp.prototype, {
/** /**
* Ctrl/Cmd+click on a tab: that session joins the grid and takes focus (a * Ctrl/Cmd+click on a tab: that session joins the grid and takes focus (a
* human selection: the user clicked its tab). With the grid closed it opens * human selection: the user clicked its tab). With the grid closed it opens
* on what the Tiles toggle would bring back, plus this session. Returns false * what the Tiles toggle would, with this session among the tiles and
* focused: the remembered count in total (owner answer to decision 10's
* questions), never one more. Returns false
* when the grid cannot open here (narrow or solo window), so the click is an * when the grid cannot open here (narrow or solo window), so the click is an
* ordinary one. * ordinary one.
*/ */
@@ -1028,8 +1180,9 @@ Object.assign(CodemanApp.prototype, {
this.selectSession(sessionId); this.selectSession(sessionId);
return true; return true;
} }
const n = Math.max(1, Math.min(this._tileGridCount(), capacity));
const base = this._tileGridOpenSet()?.ids || []; const base = this._tileGridOpenSet()?.ids || [];
const ids = [...base.filter((id) => id !== sessionId).slice(0, capacity - 1), sessionId]; const ids = [...base.filter((id) => id !== sessionId).slice(0, n - 1), sessionId];
this.openTileGrid(ids, { focusedId: sessionId, auto: false }); this.openTileGrid(ids, { focusedId: sessionId, auto: false });
return true; return true;
}, },
@@ -1057,8 +1210,8 @@ Object.assign(CodemanApp.prototype, {
}, },
/** /**
* Opens the grid on `ids` in place of whatever it shows (the picker's Open, * Opens the grid on `ids` in place of whatever it shows ("Open group as
* "Open group as tiles"): the session in focus keeps focus when `ids` holds * tiles"): the session in focus keeps focus when `ids` holds
* it, otherwise the first one takes it. Chosen BEFORE an open grid closes, * it, otherwise the first one takes it. Chosen BEFORE an open grid closes,
* because the close drops activeSessionId: as in selectSession's tile * because the close drops activeSessionId: as in selectSession's tile
* branch, the parked terminal still holds what it showed before the grid, * branch, the parked terminal still holds what it showed before the grid,
@@ -1311,8 +1464,8 @@ Object.assign(CodemanApp.prototype, {
actions.append( actions.append(
button('tile-menu', 'Session actions', '\u22EF', (e) => this.openTabRailActionMenu?.(e, sessionId)), button('tile-menu', 'Session actions', '\u22EF', (e) => this.openTabRailActionMenu?.(e, sessionId)),
zoomBtn, zoomBtn,
// (No + here, owner decision 9: tiles are added from the Tiles button's // (No + here, owner decision 9: tiles are added from the Tiles button
// picker, Ctrl/Cmd+click, a dragged tab, a tab group or Run.) // and its count menu, Ctrl/Cmd+click, a dragged tab, a tab group or Run.)
// Removes the tile ONLY: the session keeps running. Killing it stays // Removes the tile ONLY: the session keeps running. Killing it stays
// behind the menu's Close session and its confirm. // behind the menu's Close session and its confirm.
button('tile-remove', 'Remove tile (the session keeps running)', '\u00D7', () => this.removeTile(sessionId)) button('tile-remove', 'Remove tile (the session keeps running)', '\u00D7', () => this.removeTile(sessionId))
@@ -1919,18 +2072,36 @@ Object.assign(CodemanApp.prototype, {
/** /**
* Opens a stored grid: its tiles and focus, then the fractions it had (only * Opens a stored grid: its tiles and focus, then the fractions it had (only
* if they still match the layout) and a zoom the user chose. `auto`: the app * if they still match the layout) and a zoom the user chose. `auto`: the app
* is putting it back, so no idle alert is spent. * is putting it back, so no idle alert is spent. `ids` is the set to open,
* the stored one by default (a reload); the Tiles button passes it trimmed
* or filled to the remembered count, a shape change the cell model's rule
* handles (reformTileCells).
*/ */
_openStoredTileGrid(stored) { _openStoredTileGrid(stored, ids = stored.ids) {
const focus = stored.zoomed || stored.focused; const focus = stored.zoomed || stored.focused;
// Exactly the stored set: an open split closes without joining it (decision 8, case a). // The stored set (an open split closes without joining it, decision 8,
if (!this.openTileGrid(stored.ids, { focusedId: focus, auto: true, mergeSplit: false })) return false; // case a), trimmed or filled to the count by the caller.
if (!this.openTileGrid(ids, { focusedId: focus, auto: true, mergeSplit: false })) return false;
const grid = this._tileGrid; const grid = this._tileGrid;
// openTileGrid packed the tiles from the first cell. The stored holes come const T = window.CodemanTileGrid;
// back where they were when the shape is the same as when they were stored // openTileGrid packed the tiles from the first cell. The stored tiles go
// (as many cells as this layout has); a session gone since leaves its cell // back to their cells (a session gone since leaves its cell empty): as
// empty then. Otherwise the tiles stay packed. // they were when this is the stored set in the shape it was stored with,
const cells = (stored.cells || []).map((id) => (id && grid.tiles.has(id) ? id : null)); // otherwise by the cell model's rule, the tiles the count adds filling the
// empty cells in reading order, holes first.
const storedCells = (stored.cells || []).map((id) => (id && grid.tiles.has(id) ? id : null));
const kept = storedCells.filter(Boolean);
const sameSet = ids.length === stored.ids.length && ids.every((id) => stored.ids.includes(id));
const cells = sameSet
? storedCells
: T.reformTileCells(
storedCells,
T.tileCellCols(storedCells.length),
kept,
grid.ids.filter((id) => !kept.includes(id)),
grid.cols,
grid.rows
);
if (cells.length === grid.cols * grid.rows && cells.filter(Boolean).length === grid.tiles.size) { if (cells.length === grid.cols * grid.rows && cells.filter(Boolean).length === grid.tiles.size) {
grid.cells = cells; grid.cells = cells;
} }
+2 -2
View File
@@ -31,7 +31,7 @@ function escapeRegExp(value: string): string {
function expectShortcut(html: string, keys: string[], label: string): void { function expectShortcut(html: string, keys: string[], label: string): void {
const keyPattern = keys.map((key) => `<kbd>${escapeRegExp(key)}</kbd>`).join('\\s*\\+\\s*'); const keyPattern = keys.map((key) => `<kbd>${escapeRegExp(key)}</kbd>`).join('\\s*\\+\\s*');
expect(html).toMatch(new RegExp(`${keyPattern}.*?${label}`, 'i')); expect(html).toMatch(new RegExp(`${keyPattern}.*?${escapeRegExp(label)}`, 'i'));
} }
describe('help modal shortcuts', () => { describe('help modal shortcuts', () => {
@@ -58,7 +58,7 @@ describe('help modal shortcuts', () => {
expectShortcut(helpModal, ['Alt/Option', 'Shift', 'Arrows'], 'Focus Tile Left / Right / Up / Down'); expectShortcut(helpModal, ['Alt/Option', 'Shift', 'Arrows'], 'Focus Tile Left / Right / Up / Down');
expectShortcut(helpModal, ['Alt/Option', 'Shift', 'Enter'], 'Zoom Focused Tile'); expectShortcut(helpModal, ['Alt/Option', 'Shift', 'Enter'], 'Zoom Focused Tile');
expectShortcut(helpModal, ['Ctrl/Cmd', 'Click'], 'Add the Session to the Tile Grid'); expectShortcut(helpModal, ['Ctrl/Cmd', 'Click'], 'Add the Session to the Tile Grid');
expectShortcut(helpModal, ['Right-click'], 'Choose Which Sessions to Tile'); expectShortcut(helpModal, ['Right-click'], 'Choose How Many Tiles (2, 4 or 6)');
}); });
it('documents terminal input shortcuts without advertising stale run shortcuts', () => { it('documents terminal input shortcuts without advertising stale run shortcuts', () => {
+13 -1
View File
@@ -13,6 +13,10 @@ import { resolve } from 'node:path';
import vm from 'node:vm'; import vm from 'node:vm';
import { vi } from 'vitest'; import { vi } from 'vitest';
/** What has the keyboard: the last FakeEl focused (document.activeElement). */
let focusedEl: unknown = null;
export const activeElement = () => focusedEl;
/** Just enough DOM for tile-grid.js: elements with classes, children, styles and listeners. */ /** Just enough DOM for tile-grid.js: elements with classes, children, styles and listeners. */
export class FakeEl { export class FakeEl {
id = ''; id = '';
@@ -59,7 +63,10 @@ export class FakeEl {
textContent = ''; textContent = '';
value = ''; value = '';
type = ''; type = '';
focus = vi.fn(); /** Records itself as `document.activeElement` (see activeElement()). */
focus = vi.fn(() => {
focusedEl = this;
});
select = vi.fn(); select = vi.fn();
setPointerCapture = vi.fn(); setPointerCapture = vi.fn();
releasePointerCapture = vi.fn(); releasePointerCapture = vi.fn();
@@ -226,6 +233,10 @@ const context = vm.createContext({
removeEventListener: documentRemoveEventListener, removeEventListener: documentRemoveEventListener,
documentElement: { dataset: {} }, documentElement: { dataset: {} },
createElement: () => new FakeEl(), createElement: () => new FakeEl(),
createElementNS: () => new FakeEl(),
get activeElement() {
return focusedEl;
},
getElementById: (id: string) => (id === 'tileGrid' ? section : (bySelector.get(`#${id}`) ?? null)), getElementById: (id: string) => (id === 'tileGrid' ? section : (bySelector.get(`#${id}`) ?? null)),
body, body,
querySelector: (sel: string) => querySelector: (sel: string) =>
@@ -313,6 +324,7 @@ export function makeGridApp(ids: string[] = ['s-a', 's-b', 's-c']): GridApp {
/** Resets the shared fake DOM and tile registry between tests. */ /** Resets the shared fake DOM and tile registry between tests. */
export function resetGridHarness() { export function resetGridHarness() {
FakeTile.all = []; FakeTile.all = [];
focusedEl = null;
idleCallbacks.length = 0; idleCallbacks.length = 0;
rafCallbacks.length = 0; rafCallbacks.length = 0;
localStore.clear(); localStore.clear();
+13 -22
View File
@@ -5,7 +5,7 @@
* *
* Every way into the grid stops at the cap even where the window would fit * Every way into the grid stops at the cap even where the window would fit
* nine (the harness window is 2400x1200): opening, adding, a session Run * nine (the harness window is 2400x1200): opening, adding, a session Run
* makes, Ctrl/Cmd+click, the picker and "Open group as tiles". * makes, Ctrl/Cmd+click, the count menu and "Open group as tiles".
* The texts say which limit binds: "at most 6" for the cap, "what this window * The texts say which limit binds: "at most 6" for the cap, "what this window
* fits" for a smaller window. * fits" for a smaller window.
* *
@@ -36,7 +36,7 @@ function fullGrid(): GridApp {
describe('the cap', () => { describe('the cap', () => {
it('is 6, worded as the cap where the window would fit more', () => { it('is 6, worded as the cap where the window would fit more', () => {
const app = makeGridApp(EIGHT); const app = makeGridApp(EIGHT);
expect(app._tileGridLimit()).toEqual({ capacity: 6, hint: 'Up to 6 tiles', full: AT_MOST }); expect(app._tileGridLimit()).toEqual({ capacity: 6, full: AT_MOST });
}); });
it('a smaller window is worded by the window', () => { it('a smaller window is worded by the window', () => {
@@ -44,7 +44,6 @@ describe('the cap', () => {
wrapRect = { width: 1200, height: 900 }; wrapRect = { width: 1200, height: 900 };
expect(app._tileGridLimit()).toEqual({ expect(app._tileGridLimit()).toEqual({
capacity: 4, capacity: 4,
hint: 'This window fits 4 tiles',
full: 'The grid already holds what this window fits (4)', full: 'The grid already holds what this window fits (4)',
}); });
}); });
@@ -77,26 +76,18 @@ describe('every way in stops at the cap', () => {
expect(app.showToast).toHaveBeenCalledWith(AT_MOST, 'info'); expect(app.showToast).toHaveBeenCalledWith(AT_MOST, 'info');
}); });
it('the picker greys out the seventh box', () => { it('the count menu offers nothing past the cap, and its 6 opens six of eight', () => {
const app = makeGridApp(EIGHT); const app = makeGridApp(EIGHT);
app.openTilePicker({ stopPropagation: vi.fn() }); app.openTileCountMenu({ preventDefault: vi.fn() });
const picker = body.children.find((c) => c.id === 'tilePickerMenu')!; const menu = body.children.find((c) => c.id === 'tileCountMenu')!;
const boxes = picker.children[0].children.map((row) => row.children[0]); const items = menu.children.filter((c) => c.attrs.role === 'menuitemradio');
expect(picker.children[1].children[0].textContent).toBe('Up to 6 tiles'); expect(items.map((i) => Number(i.dataset.count))).toEqual([2, 4, 6]);
for (const box of boxes) { // The cap binds, not the window: nothing greyed out, no "fits" line.
// A disabled box cannot be ticked (the browser ignores the click). expect(items.every((i) => !i.disabled)).toBe(true);
if (!EIGHT.includes(box.value) || box.checked || box.disabled) continue; expect(menu.children.some((c) => c.className === 'tile-count-hint')).toBe(false);
box.checked = true; items.at(-1)!.dispatch('click', { stopPropagation: vi.fn() });
box.dispatch('change'); expect(app._tileGrid.ids).toHaveLength(6);
} expect(app._tileGrid.ids).toEqual(['s-other', ...EIGHT.slice(0, 5)]);
const checked = boxes.filter((b) => b.checked).map((b) => b.value);
expect(checked).toHaveLength(6);
const left = boxes.filter((b) => !b.checked);
expect(left.length).toBeGreaterThan(0);
for (const box of left) {
expect(box.disabled).toBe(true);
expect(box.title).toBe(AT_MOST);
}
}); });
it('"Open group as tiles" shows the first six of a larger group', () => { it('"Open group as tiles" shows the first six of a larger group', () => {
+453
View File
@@ -0,0 +1,453 @@
/**
* @fileoverview The header Tiles button and its right-click count menu (owner
* decision 10, which replaced the session picker).
*
* - The button is opt-in (`showTileGridButton`, hidden by its `--hidden` marker
* class) and hard-gated to desktop widths like Split: a JS width check plus a
* CSS `@media (max-width: 1179px)` backstop, and never in a solo window.
* With the grid open it closes it (`aria-pressed`).
* - Right-click (contextmenu, which Shift+F10 and the Menu key fire too) opens
* a menu of three counts, 2, 4 and 6, each with its shape, the remembered
* count checked (default 6). No session names in it, so no user text.
* - A count the window cannot fit is greyed out with the reason, and the
* keyboard starts on the largest that fits.
* - Arrows move over the counts that fit (wrapping), Home/End, Enter or Space
* picks, Escape closes and gives the keyboard back to the Tiles button (the
* menu owns its Escape in the global handler, like the tab-group menu), Tab
* and a click elsewhere close it.
* - A pick is remembered per device (`codeman:tile-count`; `codeman:tile-grid`
* stays ids only) and opens that many tiles: the session to focus included,
* the rest from tab order. The click and Ctrl+Shift+G then open with it.
* - With the grid open a pick re-forms it: a shape change under the cell
* model's rule, the focused tile always kept, tiles dropped from the end,
* new ones filling the empty cells first, all mounted and laid out before
* any connects. Fewer open sessions than the count give fewer tiles.
* - The session picker is gone (no method, no CSS, no markup hook).
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
FakeTile,
activeElement,
body,
bySelector,
localStore,
makeGridApp,
resetGridHarness,
windowStub,
type GridApp,
} from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f', 's-g'];
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const css = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
const html = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
const appSrc = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
const gridSrc = readFileSync(resolve(PUBLIC, 'tile-grid.js'), 'utf8');
function makeButton() {
const btn = new FakeEl();
btn.className = 'btn-icon-header btn-tile-grid btn-tile-grid--hidden';
bySelector.set('.btn-tile-grid', btn);
return btn;
}
const menu = () => body.children.find((c) => c.id === 'tileCountMenu') ?? null;
const items = () => menu()!.children.filter((c) => c.attrs.role === 'menuitemradio');
const item = (n: number) => items().find((i) => i.dataset.count === String(n))!;
const hint = () => menu()!.children.find((c) => c.className === 'tile-count-hint') ?? null;
const focused = () => (activeElement() as FakeEl | null)?.dataset?.count ?? null;
const key = (app: GridApp, k: string) => {
const ev = { key: k, preventDefault: vi.fn() };
app._tileCountMenu.onKey(ev);
return ev;
};
const open = (app: GridApp) => app.openTileCountMenu({ preventDefault: vi.fn() });
let wrapRect = { width: 2400, height: 1200 };
beforeEach(() => {
resetGridHarness();
wrapRect = { width: 2400, height: 1200 };
const wrap = new FakeEl();
wrap.getBoundingClientRect = () => ({ ...wrapRect, top: 0, left: 0, right: wrapRect.width, bottom: wrapRect.height });
bySelector.set('.terminal-wrap', wrap);
makeButton();
});
afterEach(() => {
vi.useRealTimers();
});
function gridApp(ids = IDS): GridApp {
const app = makeGridApp(ids);
app.selectSession = vi.fn((id: string) => app._selectTiledSession(id, {}));
return app;
}
describe('the Tiles button', () => {
it('shows only when its setting is on and the window is desktop-wide', () => {
const app = makeGridApp(IDS);
const btn = makeButton();
app._applyTileGridButtonVisibility(false);
expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(true);
app._applyTileGridButtonVisibility(true);
expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(false);
windowStub.innerWidth = 1100;
app._applyTileGridButtonVisibility(true);
expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(true);
});
it('never in a solo window', () => {
const app = makeGridApp(IDS);
app.isSoloWindow = true;
const btn = makeButton();
app._applyTileGridButtonVisibility(true);
expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(true);
});
it('has the CSS backstops: the hidden marker, the 1179px media query, solo mode', () => {
expect(css).toMatch(/\.btn-tile-grid--hidden\s*\{\s*display: none !important;/);
expect(css).toMatch(
/@media \(max-width: 1179px\)\s*\{[^}]*\.btn-icon-header\.btn-tile-grid[^{]*\{\s*display: none !important;/
);
expect(css).toMatch(/body\.solo-mode \.btn-tile-grid,/);
});
it('with the grid open, a click closes it, and the button says so meanwhile', () => {
const app = gridApp(IDS.slice(0, 3));
const btn = bySelector.get('.btn-tile-grid')!;
app.openTileGrid(IDS.slice(0, 3));
expect(btn.getAttribute('aria-pressed')).toBe('true');
expect(btn.classList.contains('tiles-open')).toBe(true);
expect(btn.title).toBe('Tiles: back to a single session (right-click for how many tiles)');
app.toggleTileGrid();
expect(app._tilesOwnTerminal()).toBe(false);
expect(btn.getAttribute('aria-pressed')).toBe('false');
expect(btn.title).toBe('Tiles: show several sessions side by side (right-click for how many)');
expect(menu()).toBeNull();
});
it('right-click opens the count menu, the click still toggles (one function with Ctrl+Shift+G)', () => {
expect(html).toContain('onclick="app.toggleTileGrid()" oncontextmenu="app.openTileCountMenu(event)"');
expect(html).toContain('title="Tiles: show several sessions side by side (right-click for how many)"');
});
});
describe('the count menu', () => {
it('offers 2, 4 and 6 with their shapes, the default 6 checked and focused; no user text', () => {
const app = gridApp();
const ev = { preventDefault: vi.fn() };
app.openTileCountMenu(ev);
expect(ev.preventDefault).toHaveBeenCalled();
expect(menu()!.attrs.role).toBe('menu');
expect(menu()!.attrs['aria-label']).toBe('How many tiles');
expect(items().map((i) => i.dataset.count)).toEqual(['2', '4', '6']);
expect(items().map((i) => i.attrs['aria-checked'])).toEqual(['false', 'false', 'true']);
expect(items().map((i) => i.children[1].textContent)).toEqual(['2 tiles', '4 tiles', '6 tiles']);
// Each shape is the grid's own layout for that count: 2x1, 2x2, 3x2 cells.
expect(items().map((i) => i.children[0].children.length)).toEqual([2, 4, 6]);
expect(items().every((i) => !i.disabled)).toBe(true);
expect(hint()).toBeNull();
expect(focused()).toBe('6');
expect(app._tilesOwnTerminal()).toBe(false);
// No session name anywhere in it.
expect(JSON.stringify(menu()!.children.map((c) => c.textContent))).not.toMatch(/s-[a-g]/);
});
it('checks the remembered count; a bad stored value reads as the default', () => {
const app = gridApp();
localStore.set('codeman:tile-count', '4');
open(app);
expect(items().map((i) => i.attrs['aria-checked'])).toEqual(['false', 'true', 'false']);
expect(focused()).toBe('4');
app.closeTileCountMenu();
localStore.set('codeman:tile-count', '5');
open(app);
expect(item(6).attrs['aria-checked']).toBe('true');
});
it('greys out a count the window cannot fit, says why, and starts on the largest that fits', () => {
const app = gridApp();
wrapRect = { width: 1200, height: 900 }; // fits 4 (2x2 of 600x450), not 3x2
open(app);
expect(item(6).disabled).toBe(true);
expect(item(6).attrs['aria-disabled']).toBe('true');
expect(item(6).title).toBe('This window fits 4 tiles');
expect(item(4).disabled).toBe(false);
expect(hint()!.textContent).toBe('This window fits 4 tiles');
// The remembered 6 stays checked (owner answer), the keyboard starts on 4.
expect(item(6).attrs['aria-checked']).toBe('true');
expect(focused()).toBe('4');
app.closeTileCountMenu();
wrapRect = { width: 1000, height: 400 }; // fits 2
open(app);
expect(items().map((i) => i.disabled)).toEqual([false, true, true]);
expect(hint()!.textContent).toBe('This window fits 2 tiles');
});
it('a greyed count is never picked, by click or by Enter', () => {
const app = gridApp();
wrapRect = { width: 1200, height: 900 };
open(app);
item(6).dispatch('click', { stopPropagation: vi.fn() });
expect(menu()).not.toBeNull();
item(6).focus();
key(app, 'Enter');
expect(app._tilesOwnTerminal()).toBe(false);
expect(localStore.has('codeman:tile-count')).toBe(false);
});
it('arrows move over the counts that fit and wrap; Home and End', () => {
const app = gridApp();
open(app);
expect(key(app, 'ArrowDown').preventDefault).toHaveBeenCalled();
expect(focused()).toBe('2');
key(app, 'ArrowRight');
expect(focused()).toBe('4');
key(app, 'ArrowUp');
key(app, 'ArrowLeft');
expect(focused()).toBe('6');
key(app, 'Home');
expect(focused()).toBe('2');
key(app, 'End');
expect(focused()).toBe('6');
app.closeTileCountMenu();
wrapRect = { width: 1200, height: 900 };
open(app);
key(app, 'ArrowDown');
expect(focused()).toBe('2');
key(app, 'ArrowUp');
expect(focused()).toBe('4');
// The greyed 6 is skipped both ways.
key(app, 'ArrowDown');
expect(focused()).toBe('2');
});
it('Escape closes it and gives the keyboard back to the Tiles button; closing twice is harmless', () => {
const app = gridApp();
open(app);
const btn = bySelector.get('.btn-tile-grid')!;
const ev = key(app, 'Escape');
expect(ev.preventDefault).toHaveBeenCalled();
expect(menu()).toBeNull();
expect(activeElement()).toBe(btn);
expect(() => app.closeTileCountMenu()).not.toThrow();
});
it('the menu owns its Escape in the global handler: it closes alone, with the keyboard back on Tiles', () => {
const escape = appSrc.slice(appSrc.indexOf("if (e.key === 'Escape') {"), appSrc.indexOf('this.closeAllPanels();'));
expect(escape).toMatch(/if \(this\._tileCountMenu\) \{\s*this\.closeTileCountMenu\(\{ refocus: true \}\);\s*return;/);
const app = gridApp();
open(app);
app.closeTileCountMenu({ refocus: true });
expect(activeElement()).toBe(bySelector.get('.btn-tile-grid'));
});
it('Tab closes it; a click elsewhere closes it, a click inside does not', () => {
const app = gridApp();
open(app);
key(app, 'Tab');
expect(menu()).toBeNull();
open(app);
app._tileCountMenu.onOutside({ target: item(4) });
expect(menu()).not.toBeNull();
app._tileCountMenu.onOutside({ target: new FakeEl() });
expect(menu()).toBeNull();
});
it('a second right-click closes it; a click on Tiles closes it and toggles', () => {
const app = gridApp();
open(app);
open(app);
expect(menu()).toBeNull();
open(app);
app.toggleTileGrid();
expect(menu()).toBeNull();
expect(app._tilesOwnTerminal()).toBe(true);
});
it('refuses in a narrow window', () => {
const app = gridApp();
windowStub.innerWidth = 1100;
open(app);
expect(menu()).toBeNull();
});
});
describe('picking a count', () => {
it('Enter on 2 opens two tiles, the active session included and focused, and remembers 2', () => {
const app = gridApp();
app.activeSessionId = 's-c';
open(app);
key(app, 'ArrowDown'); // 6 -> 2
key(app, 'Enter');
expect(menu()).toBeNull();
expect(app._tileGrid.ids).toEqual(['s-other', 's-c']);
expect(app._tileGrid.focusedId).toBe('s-c');
expect(localStore.get('codeman:tile-count')).toBe('2');
// The grid's own key stays ids only.
const stored = JSON.parse(localStore.get('codeman:tile-grid')!);
expect(Object.keys(stored).sort()).toEqual(['colFr', 'focused', 'ids', 'open', 'rowFr', 'v', 'zoomed']);
});
it('a click on 4 opens four, Space works too', () => {
const app = gridApp();
open(app);
item(4).dispatch('click', { stopPropagation: vi.fn() });
expect(app._tileGrid.ids).toHaveLength(4);
app.closeTileGrid({ reselect: false });
open(app);
item(2).focus();
key(app, ' ');
expect(app._tileGrid.ids).toHaveLength(2);
});
it('then the click and Ctrl+Shift+G open with the remembered count', () => {
const app = gridApp();
localStore.set('codeman:tile-count', '4');
app.toggleTileGrid();
expect(app._tileGrid.ids).toEqual(['s-other', 's-a', 's-b', 's-c']);
app.toggleTileGrid();
localStore.delete('codeman:tile-grid');
localStore.set('codeman:tile-count', '2');
app.runTileShortcut('toggle-tile-grid');
expect(app._tileGrid.ids).toHaveLength(2);
});
it('the default is 6, and the click never opens more than the window fits', () => {
const app = gridApp();
app.toggleTileGrid();
expect(app._tileGrid.ids).toHaveLength(6);
app.closeTileGrid({ keepStored: false, reselect: false });
wrapRect = { width: 1200, height: 900 };
app.toggleTileGrid();
expect(app._tileGrid.ids).toHaveLength(4);
});
it('fewer open sessions than the count: fewer tiles', () => {
const app = gridApp(['s-a', 's-b']);
open(app);
item(6).dispatch('click', { stopPropagation: vi.fn() });
expect(app._tileGrid.ids).toEqual(['s-other', 's-a', 's-b']);
});
});
describe('Ctrl/Cmd+click on a tab with the grid closed', () => {
it('opens the remembered count in total, the clicked session among them and focused (owner answer 2)', () => {
const app = gridApp();
localStore.set('codeman:tile-count', '4');
app.addSessionToTiles('s-f');
expect(app._tileGrid.ids).toEqual(['s-other', 's-a', 's-b', 's-f']);
expect(app._tileGrid.focusedId).toBe('s-f');
});
it('with the default 6, six in total, never seven', () => {
const app = gridApp();
app.addSessionToTiles('s-g');
expect(app._tileGrid.ids).toHaveLength(6);
expect(app._tileGrid.ids.at(-1)).toBe('s-g');
});
});
describe('re-forming an open grid', () => {
it('6 to 2 keeps the tiles it has first, drops from the end, never the focused one', () => {
const app = gridApp();
app.openTileGrid(IDS.slice(0, 6), { focusedId: 's-e' });
open(app);
item(2).dispatch('click', { stopPropagation: vi.fn() });
expect(app._tileGrid.ids).toEqual(['s-a', 's-e']);
expect(app._tileGrid.focusedId).toBe('s-e');
expect(app.activeSessionId).toBe('s-e');
// The dropped ones were destroyed, not reloaded or kept connected.
const destroyed = FakeTile.all.filter((t) => t.destroy.mock.calls.length).map((t) => t.sessionId);
expect(destroyed.sort()).toEqual(['s-b', 's-c', 's-d', 's-f']);
expect(localStore.get('codeman:tile-count')).toBe('2');
});
it('2 to 6 keeps both in their cells and fills from tab order; nothing remounts', () => {
const app = gridApp();
app.openTileGrid(['s-c', 's-a']);
const before = new Map(FakeTile.all.map((t) => [t.sessionId, t]));
open(app);
item(6).dispatch('click', { stopPropagation: vi.fn() });
expect(app._tileGrid.cells).toEqual(['s-c', 's-a', 's-other', 's-b', 's-d', 's-e']);
expect(app._tileFor('s-c')).toBe(before.get('s-c'));
expect(app._tileFor('s-a')).toBe(before.get('s-a'));
expect(before.get('s-c')!.connect).toHaveBeenCalledTimes(1);
});
it('every joining tile connects only once the grid has its final cells (one fit, one PTY resize)', () => {
const app = gridApp();
app.openTileGrid(['s-a', 's-b']);
const seen: string[] = [];
const realMount = app._mountTile.bind(app);
app._mountTile = (id: string, opts?: unknown) => {
const ok = realMount(id, opts);
const tile = app._tileGrid.tiles.get(id)?.tile as FakeTile | undefined;
tile?.connect.mockImplementation(async () => {
seen.push(`${id}:${app._tileGrid.cells.length}:${app._tileGrid.cells.indexOf(id)}`);
});
return ok;
};
open(app);
item(6).dispatch('click', { stopPropagation: vi.fn() });
// Each one connected into the 3x2 grid, already in its final cell.
expect(seen).toEqual(['s-other:6:2', 's-c:6:3', 's-d:6:4', 's-e:6:5']);
});
it('holes fill first: a 3x2 of five with an empty middle cell grows to six there', () => {
const app = gridApp();
app.openTileGrid(IDS.slice(0, 5));
app._tileGrid.cells = ['s-a', null, 's-b', 's-c', 's-d', 's-e'];
app._applyTileLayout();
open(app);
item(6).dispatch('click', { stopPropagation: vi.fn() });
expect(app._tileGrid.cells).toEqual(['s-a', 's-other', 's-b', 's-c', 's-d', 's-e']);
});
it('6 to 4: the cell model rule (a tile left in the third column packs the rest)', () => {
const app = gridApp();
app.openTileGrid(IDS.slice(0, 6), { focusedId: 's-a' });
open(app);
item(4).dispatch('click', { stopPropagation: vi.fn() });
expect(app._tileGrid.cols).toBe(2);
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-d']);
});
it('the same count changes nothing; a zoom the user chose ends with a re-form', () => {
const app = gridApp();
app.openTileGrid(IDS.slice(0, 4));
const cells = app._tileGrid.cells.slice();
open(app);
item(4).dispatch('click', { stopPropagation: vi.fn() });
expect(app._tileGrid.cells).toEqual(cells);
app.zoomTile('s-b');
open(app);
item(2).dispatch('click', { stopPropagation: vi.fn() });
expect(app._tileGrid.zoomedId).toBeNull();
expect(app._tileGrid.ids).toEqual(['s-a', 's-b']);
});
});
describe('the session picker is gone', () => {
it('no picker method, element, style or markup hook is left', () => {
const app = makeGridApp(IDS);
expect(app.openTilePicker).toBeUndefined();
expect(app.closeTilePicker).toBeUndefined();
expect(gridSrc).not.toMatch(/tilePickerMenu|tile-picker|openTilePicker|closeTilePicker/);
expect(css).not.toMatch(/tile-picker/);
expect(html).not.toMatch(/openTilePicker/);
expect(appSrc).not.toMatch(/closeTilePicker/);
});
it('a tile has no + (owner decision 9): tiles come from the Tiles button, its menu, Ctrl/Cmd+click, a tab, a group or Run', () => {
const app = gridApp();
app.openTileGrid(['s-a', 's-b']);
const header = app._tileGrid.tiles.get('s-a').header as FakeEl;
const actions = header.children.find((c) => c.className === 'tile-actions')!;
expect(actions.children.map((b) => b.className)).toEqual(['tile-btn tile-menu', 'tile-btn tile-zoom', 'tile-btn tile-remove']);
});
});
+31 -40
View File
@@ -5,7 +5,7 @@
* *
* - Runtime strings are HARVESTED from the real tile code (the shared vm * - Runtime strings are HARVESTED from the real tile code (the shared vm
* harness, test/mocks/tile-grid-vm.ts) driven through every state that * harness, test/mocks/tile-grid-vm.ts) driven through every state that
* writes text: the picker (cap and window wording), the Attach * writes text: the count menu (cap and window wording), the Attach
* overlay (not attached, attaching, exited, ended), zoom, the header * overlay (not attached, attaching, exited, ended), zoom, the header
* tooltip, the dividers, the empty slot, every toast, the crash-restart * tooltip, the dividers, the empty slot, every toast, the crash-restart
* confirm, the Tiles and Split button titles. Each must translate to text * confirm, the Tiles and Split button titles. Each must translate to text
@@ -14,8 +14,8 @@
* - Static strings: the shortcut registry's tile entries (overlay and App * - Static strings: the shortcut registry's tile entries (overlay and App
* Settings list), and index.html run through the real translator in JSDOM * Settings list), and index.html run through the real translator in JSDOM
* (the Tiles button, the App Settings chips, the Help modal's Tiles rows). * (the Tiles button, the App Settings chips, the Help modal's Tiles rows).
* - User text stays as typed: session names (tile header, picker) * - User text stays as typed: session names (tile header) and group names
* and group names carry data-i18n-skip, and a session name inside the * carry data-i18n-skip, and a session name inside the
* confirm passes through the pattern untranslated. * confirm passes through the pattern untranslated.
* *
* Port: N/A. * Port: N/A.
@@ -163,34 +163,22 @@ async function exercise() {
app.sessions.get('s-2').pid = null; app.sessions.get('s-2').pid = null;
app.sessions.get('s-3').paneExit = { status: 3 }; app.sessions.get('s-3').paneExit = { status: 3 };
app.sessions.get('s-4').paneExit = { signal: 9 }; app.sessions.get('s-4').paneExit = { signal: 9 };
// A session named like a UI string: user text, never translated. // A session named like a UI string (a count menu label): user text, never translated.
app.sessions.get('s-5').name = 'Open tiles'; app.sessions.get('s-5').name = '6 tiles';
// The picker, the cap wording, then the window wording. // The count menu: the cap (nothing greyed), then the window wording.
app.openTilePicker({ preventDefault: vi.fn(), stopPropagation: vi.fn() }); app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
const boxes = () => harvestAll(app, 'count menu, cap');
body.children.find((c) => c.id === 'tilePickerMenu')!.children[0].children.map((r) => r.children[0]); app.closeTileCountMenu();
for (const b of boxes()) {
if (b.checked || b.disabled) continue;
b.checked = true;
b.dispatch('change');
}
harvestAll(app, 'picker, cap');
app.closeTilePicker();
wrapRect = { width: 1200, height: 900 }; wrapRect = { width: 1200, height: 900 };
app.openTilePicker({ preventDefault: vi.fn(), stopPropagation: vi.fn() }); app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
for (const b of boxes()) { harvestAll(app, 'count menu, window');
if (b.checked || b.disabled) continue; app.closeTileCountMenu();
b.checked = true;
b.dispatch('change');
}
harvestAll(app, 'picker, window');
app.closeTilePicker();
// A window that fits one tile words it in the singular. // A window that fits one tile words it in the singular.
wrapRect = { width: 700, height: 500 }; wrapRect = { width: 700, height: 500 };
app.openTilePicker({ preventDefault: vi.fn(), stopPropagation: vi.fn() }); app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
harvestAll(app, 'picker, one tile'); harvestAll(app, 'count menu, one tile');
app.closeTilePicker(); app.closeTileCountMenu();
wrapRect = { width: 2400, height: 1200 }; wrapRect = { width: 2400, height: 1200 };
// The grid: five tiles first (an empty slot), then the sixth. // The grid: five tiles first (an empty slot), then the sixth.
@@ -270,9 +258,11 @@ describe('every tile grid string the code puts on screen translates to zh-CN', (
({ confirmText } = await exercise()); ({ confirmText } = await exercise());
// ('Tiled sessions', the grid region's label, is markup: the JSDOM test below.) // ('Tiled sessions', the grid region's label, is markup: the JSDOM test below.)
const expected = [ const expected = [
'Show sessions as tiles', 'How many tiles',
'Open tiles', 'Tiles',
'Up to 6 tiles', '2 tiles',
'4 tiles',
'6 tiles',
'The grid holds at most 6 tiles', 'The grid holds at most 6 tiles',
'This window fits 4 tiles', 'This window fits 4 tiles',
'This window fits 1 tile', 'This window fits 1 tile',
@@ -303,9 +293,9 @@ describe('every tile grid string the code puts on screen translates to zh-CN', (
'This group has no session to show as tiles', 'This group has no session to show as tiles',
'No sessions to show as tiles', 'No sessions to show as tiles',
'Split: unavailable while tiles are open', 'Split: unavailable while tiles are open',
'Tiles: show several sessions side by side (right-click to choose which)', 'Tiles: show several sessions side by side (right-click for how many)',
'Tiles: back to a single session (right-click to choose which sessions)', 'Tiles: back to a single session (right-click for how many tiles)',
'Open tiles was stopped after crashing repeatedly. Restart it?', '6 tiles was stopped after crashing repeatedly. Restart it?',
]; ];
const missing = expected.filter((s) => !seen.has(s)); const missing = expected.filter((s) => !seen.has(s));
expect(missing).toEqual([]); expect(missing).toEqual([]);
@@ -316,7 +306,7 @@ describe('every tile grid string the code puts on screen translates to zh-CN', (
for (const [source, where] of seen) { for (const [source, where] of seen) {
const text = zh.api.t(source); const text = zh.api.t(source);
// The session name inside the confirm is user text, allowed to stay. // The session name inside the confirm is user text, allowed to stay.
const words = leftover(text.replace('Open tiles', '')); const words = leftover(text.replace('6 tiles', ''));
if (text === source || words.length) bad.push(`${where}: "${source}" -> "${text}"`); if (text === source || words.length) bad.push(`${where}: "${source}" -> "${text}"`);
} }
expect(bad).toEqual([]); expect(bad).toEqual([]);
@@ -386,7 +376,7 @@ describe('the static markup through the real translator (JSDOM, zh-CN)', () => {
const doc = dom.window.document; const doc = dom.window.document;
const extra = doc.createElement('div'); const extra = doc.createElement('div');
extra.innerHTML = extra.innerHTML =
'<span id="u-session" data-i18n-skip>Open tiles</span>' + '<span id="u-session" data-i18n-skip>6 tiles</span>' +
'<span id="u-group" class="tab-layout-group-name" data-i18n-skip>Tiles</span>' + '<span id="u-group" class="tab-layout-group-name" data-i18n-skip>Tiles</span>' +
'<span id="control">Tiles</span>'; '<span id="control">Tiles</span>';
doc.body.appendChild(extra); doc.body.appendChild(extra);
@@ -417,6 +407,7 @@ describe('the static markup through the real translator (JSDOM, zh-CN)', () => {
expect(leftover(tiles!.textContent!)).toEqual([]); expect(leftover(tiles!.textContent!)).toEqual([]);
expect(tiles!.textContent).toContain('切换平铺网格'); expect(tiles!.textContent).toContain('切换平铺网格');
expect(tiles!.textContent).toContain('右键单击'); expect(tiles!.textContent).toContain('右键单击');
expect(tiles!.textContent).toContain('选择窗格数量(2、4 或 6)');
// Moving tiles: the chords and the header drag. // Moving tiles: the chords and the header drag.
expect(tiles!.textContent).toContain('向左 / 右 / 上 / 下移动窗格'); expect(tiles!.textContent).toContain('向左 / 右 / 上 / 下移动窗格');
expect(tiles!.textContent).toContain('拖动'); expect(tiles!.textContent).toContain('拖动');
@@ -424,16 +415,16 @@ describe('the static markup through the real translator (JSDOM, zh-CN)', () => {
}); });
it('user text stays as typed: a session name and a group name that are also UI words', () => { it('user text stays as typed: a session name and a group name that are also UI words', () => {
expect(doc.getElementById('u-session')!.textContent).toBe('Open tiles'); expect(doc.getElementById('u-session')!.textContent).toBe('6 tiles');
expect(doc.getElementById('u-group')!.textContent).toBe('Tiles'); expect(doc.getElementById('u-group')!.textContent).toBe('Tiles');
expect(doc.getElementById('control')!.textContent).toBe('平铺'); expect(doc.getElementById('control')!.textContent).toBe('平铺');
}); });
}); });
describe('user text in the tile code', () => { describe('user text in the tile code', () => {
it('session names in a tile header and the picker are marked data-i18n-skip', () => { it('session names in a tile header are marked data-i18n-skip', () => {
// The harvest saw them only inside skipped subtrees. // The harvest saw them only inside skipped subtrees.
expect(userText.has('Open tiles')).toBe(true); expect(userText.has('6 tiles')).toBe(true);
expect([...seen.keys()]).not.toContain('s-1'); expect([...seen.keys()]).not.toContain('s-1');
const src = read('tile-grid.js'); const src = read('tile-grid.js');
expect(src.match(/setAttribute\('data-i18n-skip', ''\)/g)?.length).toBeGreaterThanOrEqual(2); expect(src.match(/setAttribute\('data-i18n-skip', ''\)/g)?.length).toBeGreaterThanOrEqual(2);
@@ -444,7 +435,7 @@ describe('user text in the tile code', () => {
}); });
it('a session name inside the crash-restart confirm passes through the pattern untranslated', () => { it('a session name inside the crash-restart confirm passes through the pattern untranslated', () => {
expect(confirmText).toEqual(['Open tiles was stopped after crashing repeatedly. Restart it?']); expect(confirmText).toEqual(['6 tiles was stopped after crashing repeatedly. Restart it?']);
expect(zh.api.t(confirmText[0])).toBe('Open tiles 因反复崩溃已被停止。要重启吗?'); expect(zh.api.t(confirmText[0])).toBe('6 tiles 因反复崩溃已被停止。要重启吗?');
}); });
}); });
+27 -27
View File
@@ -8,8 +8,8 @@
* focused. Detached sessions and ones that no longer exist are left out, as * focused. Detached sessions and ones that no longer exist are left out, as
* in the picker. * in the picker.
* - The button's click and Ctrl+Shift+G are the same function * - The button's click and Ctrl+Shift+G are the same function
* (`toggleTileGrid`); right-click (contextmenu) opens the picker. With the * (`toggleTileGrid`); right-click (contextmenu) opens the count menu (owner
* grid open the picker shows the current tiles, and Open replaces them. * decision 10). With the grid open a pick re-forms it, the focused tile kept.
* *
* The pure helper and the app both via the shared harness (test/mocks/tile-grid-vm.ts). * The pure helper and the app both via the shared harness (test/mocks/tile-grid-vm.ts).
* Port: N/A. * Port: N/A.
@@ -99,11 +99,11 @@ describe('the Tiles button and Ctrl+Shift+G', () => {
return html; return html;
}; };
it('a click opens the grid at once (the toggle), a right-click opens the picker', () => { it('a click opens the grid at once (the toggle), a right-click opens the count menu', () => {
expect(button()).toContain('onclick="app.toggleTileGrid()"'); expect(button()).toContain('onclick="app.toggleTileGrid()"');
expect(button()).toContain('oncontextmenu="app.openTilePicker(event)"'); expect(button()).toContain('oncontextmenu="app.openTileCountMenu(event)"');
expect(button()).toContain('right-click to choose which'); expect(button()).toContain('right-click for how many');
expect(button()).not.toContain('onclick="app.openTilePicker'); expect(button()).not.toContain('onclick="app.openTileCountMenu');
}); });
it('Ctrl+Shift+G runs the same toggle', () => { it('Ctrl+Shift+G runs the same toggle', () => {
@@ -114,14 +114,18 @@ describe('the Tiles button and Ctrl+Shift+G', () => {
describe('opening at once, in the app', () => { describe('opening at once, in the app', () => {
const IDS = ['s-a', 's-b', 's-c']; const IDS = ['s-a', 's-b', 's-c'];
const picker = () => body.children.find((c) => c.id === 'tilePickerMenu') ?? null; const picker = () => body.children.find((c) => c.id === 'tileCountMenu') ?? null;
const pick = (n: number) =>
picker()!
.children.find((c) => c.dataset.count === String(n))!
.dispatch('click', { stopPropagation: vi.fn() });
beforeEach(() => { beforeEach(() => {
resetGridHarness(); resetGridHarness();
const wrap = new FakeEl(); const wrap = new FakeEl();
bySelector.set('.terminal-wrap', wrap); bySelector.set('.terminal-wrap', wrap);
}); });
it('a click with the grid closed shows the tiles, no picker', () => { it('a click with the grid closed shows the tiles, no menu', () => {
const app = makeGridApp(IDS); const app = makeGridApp(IDS);
app.activeSessionId = 's-b'; app.activeSessionId = 's-b';
app.toggleTileGrid(); app.toggleTileGrid();
@@ -131,19 +135,19 @@ describe('opening at once, in the app', () => {
expect(app.activeSessionId).toBe('s-b'); expect(app.activeSessionId).toBe('s-b');
}); });
it('a click closes a picker that a right-click left open', () => { it('a click closes a menu that a right-click left open', () => {
const app = makeGridApp(IDS); const app = makeGridApp(IDS);
app.openTilePicker({ preventDefault: vi.fn(), stopPropagation: vi.fn() }); app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
expect(picker()).not.toBeNull(); expect(picker()).not.toBeNull();
app.toggleTileGrid(); app.toggleTileGrid();
expect(picker()).toBeNull(); expect(picker()).toBeNull();
expect(app._tilesOwnTerminal()).toBe(true); expect(app._tilesOwnTerminal()).toBe(true);
}); });
it('a click elsewhere closes the picker; a click inside it does not', () => { it('a click elsewhere closes the menu; a click inside it does not', () => {
const app = makeGridApp(IDS); const app = makeGridApp(IDS);
const before = documentAddEventListener.mock.calls.length; const before = documentAddEventListener.mock.calls.length;
app.openTilePicker({ preventDefault: vi.fn() }); app.openTileCountMenu({ preventDefault: vi.fn() });
const calls = documentAddEventListener.mock.calls.slice(before) as Array<[string, (e: unknown) => void]>; const calls = documentAddEventListener.mock.calls.slice(before) as Array<[string, (e: unknown) => void]>;
const onClick = calls.find(([type]) => type === 'click')![1]; const onClick = calls.find(([type]) => type === 'click')![1];
onClick({ target: picker()!.children[0] }); onClick({ target: picker()!.children[0] });
@@ -152,31 +156,27 @@ describe('opening at once, in the app', () => {
expect(picker()).toBeNull(); expect(picker()).toBeNull();
}); });
it('a right-click opens the picker and keeps the browser menu away', () => { it('a right-click opens the count menu and keeps the browser menu away', () => {
const app = makeGridApp(IDS); const app = makeGridApp(IDS);
const ev = { preventDefault: vi.fn(), stopPropagation: vi.fn() }; const ev = { preventDefault: vi.fn(), stopPropagation: vi.fn() };
app.openTilePicker(ev); app.openTileCountMenu(ev);
expect(ev.preventDefault).toHaveBeenCalled(); expect(ev.preventDefault).toHaveBeenCalled();
expect(picker()).not.toBeNull(); expect(picker()).not.toBeNull();
expect(app._tilesOwnTerminal()).toBe(false); expect(app._tilesOwnTerminal()).toBe(false);
}); });
it('a right-click with the grid open shows its tiles; Open replaces them', () => { it('a right-click with the grid open re-forms it to the count picked, the focused tile kept', () => {
const app = makeGridApp(IDS); const app = makeGridApp(IDS);
app.openTileGrid(['s-a', 's-b'], { focusedId: 's-b' }); app.openTileGrid(['s-a', 's-b'], { focusedId: 's-b' });
app.openTilePicker({ preventDefault: vi.fn(), stopPropagation: vi.fn() }); app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
expect(app._tilesOwnTerminal()).toBe(true); expect(app._tilesOwnTerminal()).toBe(true);
const menu = picker()!; pick(4);
const boxes = menu.children[0].children.map((row) => row.children[0]); // Its two first, then the open sessions in tab order.
expect(boxes.filter((b) => b.checked).map((b) => b.value)).toEqual(['s-a', 's-b']); expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-other', 's-c']);
const boxA = boxes.find((b) => b.value === 's-a')!; expect(app.activeSessionId).toBe('s-b');
boxA.checked = false; app.openTileCountMenu({ preventDefault: vi.fn(), stopPropagation: vi.fn() });
boxA.dispatch('change'); pick(2);
const boxC = boxes.find((b) => b.value === 's-c')!; expect(app._tileGrid.ids).toEqual(['s-a', 's-b']);
boxC.checked = true;
boxC.dispatch('change');
menu.children[1].children[1].dispatch('click');
expect(app._tileGrid.ids).toEqual(['s-b', 's-c']);
expect(app.activeSessionId).toBe('s-b'); expect(app.activeSessionId).toBe('s-b');
}); });
}); });
-198
View File
@@ -1,198 +0,0 @@
/**
* @fileoverview The header Tiles button and its picker.
*
* - The button is opt-in (`showTileGridButton`, hidden by its `--hidden` marker
* class) and hard-gated to desktop widths like Split: a JS width check plus a
* CSS `@media (max-width: 1179px)` backstop, and never in a solo window.
* With the grid open it closes it (`aria-pressed`).
* - The picker has a checkbox per open session, in tab order, never a session
* popped out to its own window; names are text, never markup. It is
* preselected with the grid this tab last left, else the active session and
* an open split's two. Boxes past what the window can fit are disabled; Open
* opens the grid on the checked ones, focusing the active session if checked.
* - Escape closes it, and its close method is idempotent (the global Escape
* handler calls every close method).
* - A tile has no + (owner decision 9): tiles are added from this picker,
* Ctrl/Cmd+click, a dragged tab, a tab group or Run.
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
body,
bySelector,
makeGridApp,
resetGridHarness,
windowStub,
type GridApp,
} from './mocks/tile-grid-vm.js';
const IDS = ['s-a', 's-b', 's-c'];
const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
function makeButton() {
const btn = new FakeEl();
btn.className = 'btn-icon-header btn-tile-grid btn-tile-grid--hidden';
bySelector.set('.btn-tile-grid', btn);
return btn;
}
const picker = () => body.children.find((c) => c.id === 'tilePickerMenu') ?? null;
const rows = () => picker()!.children[0].children;
const boxOf = (id: string) => rows().find((r) => r.children[0].value === id)!.children[0];
const openButton = () => picker()!.children[1].children[1];
const check = (id: string, on = true) => {
const box = boxOf(id);
box.checked = on;
box.dispatch('change');
};
let wrapRect = { width: 2400, height: 1200 };
beforeEach(() => {
resetGridHarness();
wrapRect = { width: 2400, height: 1200 };
const wrap = new FakeEl();
wrap.getBoundingClientRect = () => ({ ...wrapRect, top: 0, left: 0, right: wrapRect.width, bottom: wrapRect.height });
bySelector.set('.terminal-wrap', wrap);
});
afterEach(() => {
vi.useRealTimers();
});
describe('the Tiles button', () => {
it('shows only when its setting is on and the window is desktop-wide', () => {
const app = makeGridApp(IDS);
const btn = makeButton();
app._applyTileGridButtonVisibility(false);
expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(true);
app._applyTileGridButtonVisibility(true);
expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(false);
windowStub.innerWidth = 1100;
app._applyTileGridButtonVisibility(true);
expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(true);
});
it('never in a solo window', () => {
const app = makeGridApp(IDS);
app.isSoloWindow = true;
const btn = makeButton();
app._applyTileGridButtonVisibility(true);
expect(btn.classList.contains('btn-tile-grid--hidden')).toBe(true);
});
it('has the CSS backstops: the hidden marker, the 1179px media query, solo mode', () => {
expect(css).toMatch(/\.btn-tile-grid--hidden\s*\{\s*display: none !important;/);
expect(css).toMatch(
/@media \(max-width: 1179px\)\s*\{[^}]*\.btn-icon-header\.btn-tile-grid[^{]*\{\s*display: none !important;/
);
expect(css).toMatch(/body\.solo-mode \.btn-tile-grid,/);
});
it('with the grid open, a click closes it, and the button says so meanwhile', () => {
const app = makeGridApp(IDS);
const btn = makeButton();
app.selectSession = vi.fn();
app.openTileGrid(IDS);
expect(btn.getAttribute('aria-pressed')).toBe('true');
expect(btn.classList.contains('tiles-open')).toBe(true);
app.toggleTileGrid();
expect(app._tilesOwnTerminal()).toBe(false);
expect(btn.getAttribute('aria-pressed')).toBe('false');
expect(picker()).toBeNull();
});
});
describe('the picker', () => {
it('lists open sessions in tab order, never a detached one, names as text', () => {
const app = makeGridApp(IDS);
app.sessions.get('s-b').name = '<i>b</i>';
app.detachedSessions.add('s-c');
app.sessions.get('s-a').pid = null; // offered: its tile shows the Attach overlay
app.openTilePicker({ stopPropagation: vi.fn() });
const labels = rows().map((r) => r.children[1]);
expect(rows().map((r) => r.children[0].value)).toEqual(['s-other', 's-a', 's-b']);
expect(labels[2].textContent).toBe('<i>b</i>');
expect(labels[2].children).toHaveLength(0);
expect(labels[2].getAttribute('data-i18n-skip')).toBe('');
});
it('preselects the active session', () => {
const app = makeGridApp(IDS);
app.activeSessionId = 's-b';
app.openTilePicker({ stopPropagation: vi.fn() });
expect(
rows()
.filter((r) => r.children[0].checked)
.map((r) => r.children[0].value)
).toEqual(['s-b']);
});
it('preselects the grid this tab last left', () => {
const app = makeGridApp(IDS);
app.selectSession = vi.fn();
app.openTileGrid(['s-a', 's-c']);
app.closeTileGrid({ reselect: false });
app.openTilePicker({ stopPropagation: vi.fn() });
expect(
rows()
.filter((r) => r.children[0].checked)
.map((r) => r.children[0].value)
).toEqual(['s-a', 's-c']);
});
it('disables boxes past what the window fits, and says how many', () => {
const app = makeGridApp(IDS);
wrapRect = { width: 1000, height: 400 }; // fits 2 (2x1 of 500x400)
app.openTilePicker({ stopPropagation: vi.fn() });
expect(picker()!.children[1].children[0].textContent).toBe('This window fits 2 tiles');
check('s-b');
expect(boxOf('s-c').disabled).toBe(true);
expect(boxOf('s-other').disabled).toBe(true);
check('s-b', false);
expect(boxOf('s-c').disabled).toBe(false);
});
it('Open opens the grid on the checked sessions, in tab order, focusing the active one', () => {
const app = makeGridApp(IDS);
app.activeSessionId = 's-b';
app.openTilePicker({ stopPropagation: vi.fn() });
check('s-a');
check('s-c');
openButton().dispatch('click');
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-c']);
expect(app.activeSessionId).toBe('s-b');
expect(picker()).toBeNull();
});
it('Open is disabled with nothing checked', () => {
const app = makeGridApp(IDS);
app.openTilePicker({ stopPropagation: vi.fn() });
check('s-a', false);
expect(openButton().disabled).toBe(true);
});
it('Escape closes it, and closing twice is harmless (the global Escape calls every close method)', () => {
const app = makeGridApp(IDS);
app.openTilePicker({ stopPropagation: vi.fn() });
const onKey = app._tilePicker.onKey;
onKey({ key: 'Escape' });
expect(picker()).toBeNull();
expect(() => app.closeTilePicker()).not.toThrow();
});
it('the global Escape handler closes the picker', () => {
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
const escape = src.slice(src.indexOf("if (e.key === 'Escape') {"), src.indexOf('Option/Alt session navigation'));
expect(escape).toContain('this.closeTilePicker?.();');
});
it('refuses in a narrow window', () => {
const app = makeGridApp(IDS);
windowStub.innerWidth = 1100;
app.openTilePicker({ stopPropagation: vi.fn() });
expect(picker()).toBeNull();
});
});
+30
View File
@@ -168,6 +168,7 @@ describe('page load with a stored open grid', () => {
it('a stored closed grid leaves the single view, and the Tiles toggle brings it back', () => { it('a stored closed grid leaves the single view, and the Tiles toggle brings it back', () => {
localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: ['s-b', 's-c'], focused: 's-c' })); localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: ['s-b', 's-c'], focused: 's-c' }));
localStore.set('codeman:tile-count', '2');
const app = pageLoad(IDS, (a) => localStore.set('codeman-active-session', 's-a')); const app = pageLoad(IDS, (a) => localStore.set('codeman-active-session', 's-a'));
expect(app._tilesOwnTerminal()).toBe(false); expect(app._tilesOwnTerminal()).toBe(false);
expect(app.selectSession).toHaveBeenCalledWith('s-a', { auto: true }); expect(app.selectSession).toHaveBeenCalledWith('s-a', { auto: true });
@@ -178,6 +179,35 @@ describe('page load with a stored open grid', () => {
expect(app.activeSessionId).toBe('s-c'); expect(app.activeSessionId).toBe('s-c');
}); });
it('the toggle fills a stored grid to the remembered count: its tiles first, in their cells, then tab order', () => {
// Default 6 (owner answer 1, superseding decision 8's "exactly the stored set").
localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: ['s-b', 's-c'], focused: 's-c' }));
const app = pageLoad(IDS, (a) => localStore.set('codeman-active-session', 's-a'));
app.activeSessionId = 's-a';
app.toggleTileGrid();
// The two stay in the first row (2x1 to 2x2 keeps them), the rest join
// in tab order: every live session, fewer than the count.
expect(app._tileGrid.cells).toEqual(['s-b', 's-c', 's-a', 's-d']);
expect(app.activeSessionId).toBe('s-c');
});
it('a stored hole is filled first when the count needs more tiles', () => {
// A 2x2 of three with the hole first; the count asks for four.
localStore.set(KEY, JSON.stringify({ v: 1, open: false, ids: [null, 's-b', 's-c', 's-d'], focused: 's-b' }));
localStore.set('codeman:tile-count', '4');
const app = pageLoad(IDS, (a) => localStore.set('codeman-active-session', 's-a'));
app.toggleTileGrid();
// s-a joins in the hole (packing would have put it last).
expect(app._tileGrid.cells).toEqual(['s-a', 's-b', 's-c', 's-d']);
});
it('a reload brings back exactly the stored grid, whatever the count', () => {
localStore.set('codeman:tile-count', '6');
storeGrid({ ids: ['s-b', 's-c'], focused: 's-c' });
const app = pageLoad(IDS);
expect(app._tileGrid.ids).toEqual(['s-b', 's-c']);
});
it('a window too narrow for the grid keeps the single view (the stored grid waits)', () => { it('a window too narrow for the grid keeps the single view (the stored grid waits)', () => {
storeGrid({ ids: IDS, focused: 's-a' }); storeGrid({ ids: IDS, focused: 's-a' });
windowStub.innerWidth = 1100; windowStub.innerWidth = 1100;
+6 -3
View File
@@ -224,13 +224,14 @@ describe('the capture-phase handler', () => {
}); });
describe('the actions', () => { describe('the actions', () => {
it('the toggle brings back the grid this tab last left, focus included', () => { it('the toggle brings back the grid this tab last left, focus included, filled to the count', () => {
const app = makeGridApp(IDS); const app = makeGridApp(IDS);
app.selectSession = vi.fn(); app.selectSession = vi.fn();
app.openTileGrid(IDS, { focusedId: 's-c' }); app.openTileGrid(IDS, { focusedId: 's-c' });
app.closeTileGrid({ reselect: false }); app.closeTileGrid({ reselect: false });
app.toggleTileGrid(); app.toggleTileGrid();
expect(app._tileGrid.ids).toEqual(IDS); // Its three first, then the open sessions in tab order (the default count, 6).
expect(app._tileGrid.ids).toEqual([...IDS, 's-other']);
expect(app.activeSessionId).toBe('s-c'); expect(app.activeSessionId).toBe('s-c');
}); });
@@ -240,7 +241,9 @@ describe('the actions', () => {
app.closeTileGrid({ reselect: false }); app.closeTileGrid({ reselect: false });
app.sessions.delete('s-b'); app.sessions.delete('s-b');
app.toggleTileGrid(); app.toggleTileGrid();
expect(app._tileGrid.ids).toEqual(['s-a', 's-c']); expect(app._tileGrid.ids).not.toContain('s-b');
// Its cell stays where it was, and the count fills it first (s-other, tab order).
expect(app._tileGrid.cells).toEqual(['s-a', 's-other', 's-c']);
}); });
it('with every remembered session gone, it opens on the open sessions in tab order instead', () => { it('with every remembered session gone, it opens on the open sessions in tab order instead', () => {
+15 -3
View File
@@ -21,6 +21,7 @@ import {
FakeTile, FakeTile,
body, body,
bySelector, bySelector,
localStore,
main, main,
makeGridApp, makeGridApp,
resetGridHarness, resetGridHarness,
@@ -59,25 +60,36 @@ describe('opening the grid over an open split', () => {
expect(paneB.destroy).toHaveBeenCalledTimes(1); expect(paneB.destroy).toHaveBeenCalledTimes(1);
expect(app._splitPane).toBeNull(); expect(app._splitPane).toBeNull();
expect(app._tileGrid.ids).toEqual(['s-a', 's-b']); // The split's two first, then tab order up to the count (default 6).
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-other', 's-c']);
expect(app.activeSessionId).toBe('s-a'); expect(app.activeSessionId).toBe('s-a');
// Pane A is about to park: no closing resize for it. // Pane A is about to park: no closing resize for it.
expect(app.sendResize).toHaveBeenCalledTimes(1); // the split's own opening resize only expect(app.sendResize).toHaveBeenCalledTimes(1); // the split's own opening resize only
}); });
it('a remembered grid wins over an open split: exactly its tiles, the split closed and not merged', () => { it("with a count of 2, exactly the split's two", () => {
const app = makeGridApp(IDS);
localStore.set('codeman:tile-count', '2');
openSplit(app);
app.toggleTileGrid();
expect(app._tileGrid.ids).toEqual(['s-a', 's-b']);
});
it('a remembered grid wins over an open split: its tiles first, the split closed and not merged', () => {
const app = makeGridApp(IDS); const app = makeGridApp(IDS);
app.selectSession = vi.fn(); app.selectSession = vi.fn();
app.openTileGrid(['s-c']); app.openTileGrid(['s-c']);
app.closeTileGrid({ reselect: false }); app.closeTileGrid({ reselect: false });
app.activeSessionId = 's-a'; app.activeSessionId = 's-a';
localStore.set('codeman:tile-count', '2');
const paneB = openSplit(app); const paneB = openSplit(app);
app.toggleTileGrid(); app.toggleTileGrid();
expect(paneB.destroy).toHaveBeenCalledTimes(1); expect(paneB.destroy).toHaveBeenCalledTimes(1);
expect(app._splitPane).toBeNull(); expect(app._splitPane).toBeNull();
expect(app._tileGrid.ids).toEqual(['s-c']); // Filled from tab order (s-other first), not seeded with the split's two.
expect(app._tileGrid.ids).toEqual(['s-c', 's-other']);
expect(app.activeSessionId).toBe('s-c'); expect(app.activeSessionId).toBe('s-c');
}); });