feat(tiles): draggable column and row dividers

The grid now places every tile explicitly (grid-column / grid-row, reading
order) with a 6px divider track between columns and between rows, instead of
relying on DOM order and a gap. Track sizes are fractions (grid-template fr
values) that reset to equal whenever the column or row count changes.

Dragging a divider trades size between the two tracks either side, each kept
at the minimum tile size (the pure dragTrackFractions in constants.js, always
computed from the fractions the drag started with, so it cannot drift). The
affected tiles reflow locally at most once per animation frame, with no PTY
resize; each hears exactly one fit (one PTY resize) at pointer-up, and tiles
in other tracks hear nothing. Pointer capture keeps the drag on the divider,
the body locks the resize cursor and text selection for its duration, and
closing the grid or removing a tile mid-drag tears it down, as the split's
divider does. A zoomed grid shows no dividers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-06 17:59:16 +02:00
parent b4618bb853
commit 65e8271fbe
7 changed files with 518 additions and 11 deletions
+31
View File
@@ -1741,6 +1741,36 @@ function sanitizeTileGridState(raw, liveSessions, detachedIds) {
};
}
/**
* New track fractions after a divider drag (grid-template `fr` values): the two
* tracks either side of divider `index` trade `deltaPx` of size, each kept at
* least `minPx` (or half the pair, if the pair cannot give both the minimum).
* Every other track keeps its size. Computed from the fractions the drag
* STARTED with and the pointer's total travel, so a drag never drifts.
*
* @param {number[]} fr - the fractions when the drag started
* @param {number} index - the divider: between track `index` and `index + 1`
* @param {number} deltaPx - pointer travel since the drag started
* @param {number} totalPx - the size the tracks share (dividers and padding excluded)
* @param {number} minPx - the smallest a track may get
* @returns {number[]} new fractions, same length
*/
function dragTrackFractions(fr, index, deltaPx, totalPx, minPx) {
const out = fr.slice();
if (index < 0 || index + 1 >= fr.length || !(totalPx > 0)) return out;
const sum = fr.reduce((a, b) => a + b, 0);
if (!(sum > 0)) return out;
const a = (fr[index] / sum) * totalPx;
const b = (fr[index + 1] / sum) * totalPx;
const pair = a + b;
const lo = Math.min(minPx, pair / 2);
const hi = pair - lo;
const nextA = Math.min(Math.max(a + (Number(deltaPx) || 0), lo), hi);
out[index] = (nextA / totalPx) * sum;
out[index + 1] = ((pair - nextA) / totalPx) * sum;
return out;
}
/**
* The sessions the Tiles picker (and a tile's + menu) offers, in tab order:
* live ones only, never a session popped out to its own window (that window
@@ -2074,6 +2104,7 @@ if (typeof window !== 'undefined') {
tileGridCapacity,
sanitizeTileGridState,
buildTilePickerSessions,
dragTrackFractions,
tileNeighbor,
tileInDirection,
cycleTile,
+61 -1
View File
@@ -19386,7 +19386,9 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
flex: 1;
min-width: 0;
min-height: 0;
gap: 4px;
/* No gap: the divider tracks between columns and rows are the spacing
(tile-grid.js TILE_DIVIDER_PX; the padding is TILE_GRID_PADDING_PX). */
gap: 0;
padding: 4px;
background: var(--bg-dark);
}
@@ -19501,6 +19503,64 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
outline: none;
}
/* Column and row dividers: their own grid tracks, dragged to trade size
between neighbours. The line only shows on hover or while dragging. */
.tile-divider {
position: relative;
z-index: 1;
touch-action: none;
}
.tile-divider--col {
cursor: col-resize;
}
.tile-divider--row {
cursor: row-resize;
}
.tile-divider::after {
content: '';
position: absolute;
inset: 0;
margin: auto;
background: transparent;
}
.tile-divider--col::after {
width: 2px;
}
.tile-divider--row::after {
height: 2px;
}
.tile-divider:hover::after,
.tile-divider.dragging::after {
background: var(--accent, #4a9eff);
}
.tile-grid.tile-grid--zoomed .tile-divider {
display: none;
}
/* While a divider drags: the resize cursor everywhere and no text selection
(mirrors body.split-pane-resizing). */
body.tile-grid-resizing,
body.tile-grid-resizing * {
user-select: none !important;
}
body.tile-grid-resizing--col,
body.tile-grid-resizing--col * {
cursor: col-resize !important;
}
body.tile-grid-resizing--row,
body.tile-grid-resizing--row * {
cursor: row-resize !important;
}
/* Zoom (tmux-style): one tile fills the grid; the others stay connected but
hidden, so they measure nothing and send no resize. */
.tile-grid.tile-grid--zoomed .tile:not(.tile--zoomed) {
+167 -3
View File
@@ -26,6 +26,15 @@ const TILE_GRID_FONT_KEY = 'codeman-tile-font-size';
// 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.
const TILE_GRID_REFIT_MS = 150;
// Width of the draggable column and row dividers (their own grid tracks), and
// the grid section's padding (styles.css .tile-grid), for the drag math.
const TILE_DIVIDER_PX = 6;
const TILE_GRID_PADDING_PX = 4;
/** `minmax(0, 1fr) 6px minmax(0, 2fr) ...`: tracks with a divider track between each. */
function tileGridTracks(fr) {
return fr.map((f) => `minmax(0, ${Math.round(f * 1000) / 1000}fr)`).join(` ${TILE_DIVIDER_PX}px `);
}
// Registry ids of the tile chords (DEFAULT_SHORTCUTS, app.js), and whether each
// needs the grid open. The toggle applies wherever a grid could open.
const TILE_SHORTCUTS = {
@@ -52,6 +61,12 @@ class TileGridModel {
// lifts once the window fits again.
this.zoomedId = null;
this.autoZoom = false;
// Track fractions (grid-template fr values) set by the dividers; equal
// again whenever the column or row count changes.
this.colFr = [];
this.rowFr = [];
// 'col-<i>' / 'row-<i>' -> the divider element between track i and i+1.
this.dividers = new Map();
this.cols = 0;
this.rows = 0;
this.queue = null;
@@ -224,6 +239,8 @@ Object.assign(CodemanApp.prototype, {
const focusedId = grid.focusedId;
const ids = grid.ids.slice();
this._tileGridRemembered = keepStored ? { ids, focusedId } : null;
// A divider drag in progress ends with the grid.
this._tileDividerDragTeardown?.();
// Closed BEFORE the tiles go: each destroy() updates the header's connection
// state, which must read the main terminal again, not half-destroyed tiles.
grid.open = false;
@@ -237,6 +254,10 @@ Object.assign(CodemanApp.prototype, {
el.remove();
}
grid.tiles.clear();
for (const el of grid.dividers.values()) el.remove();
grid.dividers.clear();
grid.colFr = [];
grid.rowFr = [];
grid.ids = [];
grid.focusedId = null;
grid.zoomedId = null;
@@ -646,6 +667,8 @@ Object.assign(CodemanApp.prototype, {
}
const wasFocused = grid.focusedId === sessionId;
const neighbor = window.CodemanTileGrid.tileNeighbor(grid.ids, sessionId);
// A divider drag in progress was measured against this tile.
this._tileDividerDragTeardown?.();
// The zoomed tile leaving restores the grid (an automatic zoom moves to
// the neighbour with focus, below).
if (grid.zoomedId === sessionId) grid.zoomedId = grid.autoZoom && refocus ? neighbor : null;
@@ -1010,6 +1033,8 @@ Object.assign(CodemanApp.prototype, {
width: rect.width || window.innerWidth,
height: rect.height || window.innerHeight,
});
if (grid.colFr.length !== cols) grid.colFr = new Array(cols).fill(1);
if (grid.rowFr.length !== rows) grid.rowFr = new Array(rows).fill(1);
grid.cols = cols;
grid.rows = rows;
// A window too small for the tiles' minimum size shows the focused tile
@@ -1039,9 +1064,148 @@ Object.assign(CodemanApp.prototype, {
}
}
// Zoomed: one cell; the other tiles stay connected but hidden (CSS), so
// they measure nothing and send no resize.
section.style.gridTemplateColumns = zoomed ? 'minmax(0, 1fr)' : `repeat(${cols}, minmax(0, 1fr))`;
section.style.gridTemplateRows = zoomed ? 'minmax(0, 1fr)' : `repeat(${rows}, minmax(0, 1fr))`;
// they measure nothing and send no resize. Otherwise every tile is placed
// explicitly in reading order, with a divider track between columns and
// between rows.
section.style.gridTemplateColumns = zoomed ? 'minmax(0, 1fr)' : tileGridTracks(grid.colFr);
section.style.gridTemplateRows = zoomed ? 'minmax(0, 1fr)' : tileGridTracks(grid.rowFr);
grid.ids.forEach((id, k) => {
const el = grid.tiles.get(id)?.el;
if (!el) return;
el.style.gridColumn = id === zoomed ? '1' : String(2 * (k % cols) + 1);
el.style.gridRow = id === zoomed ? '1' : String(2 * Math.floor(k / cols) + 1);
});
this._syncTileDividers(zoomed ? 0 : cols, zoomed ? 0 : rows);
},
// One divider per gap between columns and between rows, created and dropped
// as the counts change (never rebuilt while they stay, so a drag in progress
// keeps its element).
_syncTileDividers(cols, rows) {
const grid = this._tileGrid;
const section = this._tileGridSection();
const wanted = new Set();
for (let i = 0; i < cols - 1; i++) wanted.add(`col-${i}`);
for (let i = 0; i < rows - 1; i++) wanted.add(`row-${i}`);
for (const [key, el] of grid.dividers) {
if (wanted.has(key)) continue;
if (this._tileDividerDrag?.key === key) this._tileDividerDragTeardown?.();
el.remove();
grid.dividers.delete(key);
}
for (const key of wanted) {
let el = grid.dividers.get(key);
const [axis, n] = key.split('-');
const index = Number(n);
if (!el) {
el = document.createElement('div');
el.className = `tile-divider tile-divider--${axis}`;
el.setAttribute('role', 'separator');
el.setAttribute('aria-orientation', axis === 'col' ? 'vertical' : 'horizontal');
el.setAttribute('aria-label', axis === 'col' ? 'Resize tile columns' : 'Resize tile rows');
el.addEventListener('pointerdown', (e) => this._startTileDividerDrag(e, axis, index, el, key));
section.appendChild(el);
grid.dividers.set(key, el);
}
el.style.gridColumn = axis === 'col' ? String(2 * index + 2) : '1 / -1';
el.style.gridRow = axis === 'col' ? '1 / -1' : String(2 * index + 2);
}
},
/**
* Drags a column or row divider: the two tracks either side trade size, each
* kept at the minimum tile size (dragTrackFractions, constants.js). The
* affected tiles reflow locally once per animation frame; their PTYs hear
* ONE resize each, at pointer-up, never per move (each one is a tmux resize
* and a SIGWINCH). Pointer capture keeps the drag on the divider whatever is
* under the pointer; closing the grid or removing a tile mid-drag tears it
* down through _tileDividerDragTeardown.
*/
_startTileDividerDrag(e, axis, index, divider, key) {
if (e.button !== undefined && e.button !== 0) return;
const grid = this._tileGrid;
if (!grid?.open) return;
e.preventDefault?.();
e.stopPropagation?.();
this._tileDividerDragTeardown?.();
const T = window.CodemanTileGrid;
const section = this._tileGridSection();
const rect = section.getBoundingClientRect();
const isCol = axis === 'col';
const count = isCol ? grid.cols : grid.rows;
const total = (isCol ? rect.width : rect.height) - 2 * TILE_GRID_PADDING_PX - TILE_DIVIDER_PX * (count - 1);
const startFr = (isCol ? grid.colFr : grid.rowFr).slice();
const start = isCol ? e.clientX : e.clientY;
const minPx = isCol ? T.TILE_MIN_W : T.TILE_MIN_H;
const affected = [];
grid.ids.forEach((id, k) => {
const track = isCol ? k % grid.cols : Math.floor(k / grid.cols);
if (track === index || track === index + 1) affected.push(grid.tiles.get(id).tile);
});
let raf = null;
let pending = start;
let capturedPointerId = null;
const apply = (pos) => {
if (!grid.open) return;
const fr = T.dragTrackFractions(startFr, index, pos - start, total, minPx);
if (isCol) grid.colFr = fr;
else grid.rowFr = fr;
section.style[isCol ? 'gridTemplateColumns' : 'gridTemplateRows'] = tileGridTracks(fr);
for (const tile of affected) tile.localFit();
};
const onMove = (ev) => {
pending = isCol ? ev.clientX : ev.clientY;
if (raf !== null) return;
raf = requestAnimationFrame(() => {
raf = null;
apply(pending);
});
};
const endDrag = () => {
divider.classList.remove('dragging');
document.body.classList.remove('tile-grid-resizing', `tile-grid-resizing--${axis}`);
if (capturedPointerId !== null) {
try {
divider.releasePointerCapture?.(capturedPointerId);
} catch {
/* Already released. */
}
capturedPointerId = null;
}
divider.removeEventListener('pointermove', onMove);
divider.removeEventListener('pointerup', onUp);
divider.removeEventListener('pointercancel', onUp);
if (raf !== null) {
cancelAnimationFrame(raf);
raf = null;
}
if (this._tileDividerDragTeardown === endDrag) {
this._tileDividerDragTeardown = null;
this._tileDividerDrag = null;
}
};
const onUp = () => {
// The last queued frame carries the final pointer position.
const queued = raf !== null;
endDrag();
if (queued) apply(pending);
for (const tile of affected) {
if (!tile._destroyed) tile.fit();
}
};
divider.classList.add('dragging');
document.body.classList.add('tile-grid-resizing', `tile-grid-resizing--${axis}`);
try {
divider.setPointerCapture?.(e.pointerId);
capturedPointerId = e.pointerId ?? null;
} catch {
/* The drag still works through the listeners below. */
}
divider.addEventListener('pointermove', onMove);
divider.addEventListener('pointerup', onUp);
divider.addEventListener('pointercancel', onUp);
this._tileDividerDragTeardown = endDrag;
this._tileDividerDrag = { key };
},
/**
+12 -2
View File
@@ -61,6 +61,11 @@ export class FakeEl {
type = '';
focus = vi.fn();
select = vi.fn();
setPointerCapture = vi.fn();
releasePointerCapture = vi.fn();
removeEventListener(type: string, fn: (ev: unknown) => void) {
this.listeners[type] = (this.listeners[type] ?? []).filter((f) => f !== fn);
}
appendChild(child: FakeEl) {
child.remove();
child.parentElement = this;
@@ -95,7 +100,6 @@ export class FakeEl {
addEventListener(type: string, fn: (ev: unknown) => void) {
(this.listeners[type] ||= []).push(fn);
}
removeEventListener() {}
dispatch(type: string, ev: unknown = {}) {
for (const fn of this.listeners[type] ?? []) fn(ev);
}
@@ -169,6 +173,8 @@ export function advanceClock(ms: number) {
export const clockNow = () => clock;
/** Every callback the code under test handed a PerformanceObserver, newest last. */
export const perfObserverCallbacks: Array<(list: { getEntries(): unknown[] }) => void> = [];
/** Animation-frame callbacks the code under test queued (id = index + 1); a test runs them. */
export const rafCallbacks: Array<() => void> = [];
/** What the code under test deferred with requestIdleCallback; a test runs them. */
export const idleCallbacks: Array<() => void> = [];
export const windowStub: Record<string, unknown> = {
@@ -194,7 +200,10 @@ const context = vm.createContext({
clearInterval: vi.fn(),
setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms),
clearTimeout: (id: ReturnType<typeof setTimeout>) => globalThis.clearTimeout(id),
requestAnimationFrame: vi.fn(),
requestAnimationFrame: (cb: () => void) => rafCallbacks.push(cb),
cancelAnimationFrame: (id: number) => {
if (id > 0) rafCallbacks[id - 1] = () => {};
},
requestIdleCallback: (cb: () => void) => idleCallbacks.push(cb),
HTMLCanvasElement: class HTMLCanvasElement {},
WebSocket: { OPEN: 1 },
@@ -295,6 +304,7 @@ export function makeGridApp(ids: string[] = ['s-a', 's-b', 's-c']): GridApp {
export function resetGridHarness() {
FakeTile.all = [];
idleCallbacks.length = 0;
rafCallbacks.length = 0;
localStore.clear();
windowStub.innerWidth = 2400;
section.children = [];
+214
View File
@@ -0,0 +1,214 @@
/**
* @fileoverview Draggable column and row dividers.
*
* Every tile is placed explicitly (`grid-column` / `grid-row`, reading order),
* with a divider track between columns and between rows. Dragging a divider
* trades size between the two tracks either side, each kept at the minimum tile
* size. The affected tiles reflow LOCALLY at most once per animation frame
* (`localFit`, no PTY resize), and each hears exactly ONE `fit()` (one PTY
* resize) at pointer-up; tiles in other tracks hear nothing. A drag in progress
* is torn down when the grid closes or a tile is removed (the split's mid-drag
* lesson). Fractions reset when the column or row count changes.
*
* 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 { beforeEach, describe, expect, it, vi } from 'vitest';
import {
FakeEl,
FakeTile,
body,
makeGridApp,
rafCallbacks,
resetGridHarness,
section,
type GridApp,
} from './mocks/tile-grid-vm.js';
const SIX = ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f'];
const tileEl = (id: string) => section.children.find((el) => el.dataset.sessionId === id) as FakeEl;
const divider = (app: GridApp, key: string) => app._tileGrid.dividers.get(key) as FakeEl;
const tile = (id: string) => FakeTile.all.find((t) => t.sessionId === id) as FakeTile;
function grid(ids: string[]): GridApp {
const app = makeGridApp(ids);
app.openTileGrid(ids);
for (const t of FakeTile.all) {
t.fit.mockClear();
t.localFit.mockClear();
}
return app;
}
const runFrames = () => {
for (const cb of rafCallbacks.splice(0)) cb();
};
beforeEach(() => {
resetGridHarness();
});
describe('placement', () => {
it('2x2: one column divider, one row divider, tiles placed in reading order', () => {
const app = grid(['s-a', 's-b', 's-c', 's-d']);
expect([...app._tileGrid.dividers.keys()].sort()).toEqual(['col-0', 'row-0']);
expect(section.style.gridTemplateColumns).toBe('minmax(0, 1fr) 6px minmax(0, 1fr)');
expect(section.style.gridTemplateRows).toBe('minmax(0, 1fr) 6px minmax(0, 1fr)');
expect([tileEl('s-d').style.gridColumn, tileEl('s-d').style.gridRow]).toEqual(['3', '3']);
expect([tileEl('s-b').style.gridColumn, tileEl('s-b').style.gridRow]).toEqual(['3', '1']);
expect([divider(app, 'col-0').style.gridColumn, divider(app, 'col-0').style.gridRow]).toEqual(['2', '1 / -1']);
expect([divider(app, 'row-0').style.gridColumn, divider(app, 'row-0').style.gridRow]).toEqual(['1 / -1', '2']);
});
it('3x2 has two column dividers; a single tile has none', () => {
expect([...grid(SIX)._tileGrid.dividers.keys()].sort()).toEqual(['col-0', 'col-1', 'row-0']);
resetGridHarness();
expect(grid(['s-a'])._tileGrid.dividers.size).toBe(0);
});
it('dividers are separators a screen reader can name', () => {
const app = grid(['s-a', 's-b']);
const d = divider(app, 'col-0');
expect(d.getAttribute('role')).toBe('separator');
expect(d.getAttribute('aria-orientation')).toBe('vertical');
});
});
describe('dragging', () => {
function drag(app: GridApp, key: string, from: number, to: number[]) {
const d = divider(app, key);
const axisCol = key.startsWith('col');
const at = (v: number) => (axisCol ? { clientX: v, clientY: 0 } : { clientX: 0, clientY: v });
d.dispatch('pointerdown', {
button: 0,
pointerId: 7,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
...at(from),
});
for (const v of to) d.dispatch('pointermove', at(v));
return d;
}
it('reflows locally once per frame and resizes each affected PTY exactly once, at pointer-up', () => {
const app = grid(SIX);
const d = drag(app, 'col-1', 1600, [1620, 1640, 1660, 1680]);
// Four moves, one frame.
expect(rafCallbacks).toHaveLength(1);
runFrames();
// Column 1 and 2 tiles: b, c (row 0) and e, f (row 1).
for (const id of ['s-b', 's-c', 's-e', 's-f']) expect(tile(id).localFit).toHaveBeenCalledTimes(1);
expect(tile('s-a').localFit).not.toHaveBeenCalled();
expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true);
d.dispatch('pointerup', {});
for (const id of ['s-b', 's-c', 's-e', 's-f']) expect(tile(id).fit).toHaveBeenCalledTimes(1);
expect(tile('s-a').fit).not.toHaveBeenCalled();
expect(tile('s-d').fit).not.toHaveBeenCalled();
});
it('the frame still queued at pointer-up is applied, so the tracks end where the pointer did', () => {
const app = grid(['s-a', 's-b']);
const d = drag(app, 'col-0', 1200, [1300]);
d.dispatch('pointerup', {});
// 2400 wide, 8px padding, one 6px divider: 2386px shared; the left track gained 100px.
const [a, b] = app._tileGrid.colFr;
expect(Math.round((a / (a + b)) * 2386)).toBe(1293);
expect(section.style.gridTemplateColumns).toMatch(/^minmax\(0, [\d.]+fr\) 6px minmax\(0, [\d.]+fr\)$/);
});
it('keeps both neighbours at the minimum tile size', () => {
const app = grid(['s-a', 's-b']);
const d = drag(app, 'col-0', 1200, [100]);
d.dispatch('pointerup', {});
const [a, b] = app._tileGrid.colFr;
expect(Math.round((a / (a + b)) * 2386)).toBe(480);
});
it('a row divider moves rows and resizes the tiles in them', () => {
const app = grid(['s-a', 's-b', 's-c', 's-d']);
const d = drag(app, 'row-0', 600, [650]);
d.dispatch('pointerup', {});
for (const id of ['s-a', 's-b', 's-c', 's-d']) expect(tile(id).fit).toHaveBeenCalledTimes(1);
expect(app._tileGrid.rowFr[0]).toBeGreaterThan(app._tileGrid.rowFr[1]);
});
it('locks the cursor and text selection for the drag, released at the end', () => {
const app = grid(['s-a', 's-b']);
const d = drag(app, 'col-0', 1200, []);
expect(body.classList.contains('tile-grid-resizing')).toBe(true);
expect(d.classList.contains('dragging')).toBe(true);
d.dispatch('pointerup', {});
expect(body.classList.contains('tile-grid-resizing')).toBe(false);
expect(d.setPointerCapture).toHaveBeenCalledWith(7);
expect(d.releasePointerCapture).toHaveBeenCalledWith(7);
});
});
describe('a drag torn down mid-way', () => {
function startDrag(app: GridApp) {
const d = divider(app, 'col-0');
d.dispatch('pointerdown', {
button: 0,
pointerId: 1,
clientX: 800,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
});
d.dispatch('pointermove', { clientX: 900 });
return d;
}
it('closing the grid ends it: no body lock left behind, no late resize', () => {
const app = grid(['s-a', 's-b']);
app.selectSession = vi.fn();
const d = startDrag(app);
app.closeTileGrid({ reselect: false });
expect(body.classList.contains('tile-grid-resizing')).toBe(false);
expect(d.listeners.pointermove ?? []).toHaveLength(0);
runFrames();
d.dispatch('pointerup', {});
expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true);
});
it('removing a tile ends it', () => {
const app = grid(['s-a', 's-b', 's-c']);
startDrag(app);
app.removeTile('s-c');
expect(body.classList.contains('tile-grid-resizing')).toBe(false);
expect(app._tileDividerDragTeardown ?? null).toBeNull();
});
});
describe('fractions', () => {
it('reset to equal when the column count changes', () => {
const app = grid(['s-a', 's-b', 's-c', 's-d']);
app._tileGrid.colFr = [3, 1];
app._applyTileLayout();
expect(app._tileGrid.colFr).toEqual([3, 1]);
app.sessions.set('s-e', { id: 's-e', name: 's-e', mode: 'claude', pid: 1 });
app.addTile('s-e');
expect(app._tileGrid.colFr).toEqual([1, 1, 1]);
});
});
describe('fractions on fewer columns', () => {
it('reset to equal when the column count drops (no stale track left over)', () => {
const app = grid(['s-a', 's-b', 's-c', 's-d', 's-e']);
app._tileGrid.colFr = [3, 1, 1];
app.removeTile('s-e');
expect(app._tileGrid.colFr).toEqual([1, 1]);
expect(section.style.gridTemplateColumns).toBe('minmax(0, 1fr) 6px minmax(0, 1fr)');
});
});
describe('zoomed', () => {
it('shows no dividers', () => {
const app = grid(['s-a', 's-b', 's-c', 's-d']);
app.zoomTile('s-a');
expect(app._tileGrid.dividers.size).toBe(0);
const css = readFileSync(resolve(import.meta.dirname, '../src/web/public/styles.css'), 'utf8');
expect(css).toMatch(/\.tile-grid\.tile-grid--zoomed \.tile-divider\s*\{\s*display: none;/);
});
});
+32 -4
View File
@@ -144,10 +144,7 @@ describe('sanitizeTileGridState', () => {
});
it('drops malformed track fractions', () => {
const out = T.sanitizeTileGridState(
{ v: 1, open: true, ids: ['a'], colFr: [1, -1], rowFr: [1, 1, 1, 1] },
live
);
const out = T.sanitizeTileGridState({ v: 1, open: true, ids: ['a'], colFr: [1, -1], rowFr: [1, 1, 1, 1] }, live);
expect(out?.colFr).toBeNull();
expect(out?.rowFr).toBeNull();
});
@@ -188,6 +185,37 @@ describe('focus helpers', () => {
});
});
describe('dragTrackFractions (divider drags)', () => {
const drag = (fr: number[], i: number, d: number, total = 1200, min = 300) =>
(T as unknown as { dragTrackFractions: (...a: unknown[]) => number[] }).dragTrackFractions(fr, i, d, total, min);
const px = (fr: number[], total = 1200) => fr.map((f) => Math.round((f / fr.reduce((a, b) => a + b, 0)) * total));
it('moves size from one neighbour to the other, the rest untouched', () => {
// 3 equal tracks of 400px; divider 0 moves 100px right.
expect(px(drag([1, 1, 1], 0, 100))).toEqual([500, 300, 400]);
expect(px(drag([1, 1, 1], 1, -50))).toEqual([400, 350, 450]);
});
it('clamps both neighbours to the minimum size', () => {
expect(px(drag([1, 1, 1], 0, 300))).toEqual([500, 300, 400]);
expect(px(drag([1, 1, 1], 0, -300))).toEqual([300, 500, 400]);
});
it('a pair too small for two minimums splits evenly instead of inverting', () => {
expect(px(drag([1, 1], 0, 200, 500, 300), 500)).toEqual([250, 250]);
});
it('works from the fractions the drag started with (no drift)', () => {
const start = [2, 1];
expect(px(drag(start, 0, 0))).toEqual([800, 400]);
expect(start).toEqual([2, 1]);
});
it('ignores a divider that is not between two tracks', () => {
expect(drag([1, 1], 1, 100)).toEqual([1, 1]);
});
});
describe('tile constants', () => {
it('a tile keeps 10,000 lines of scrollback, not the primary pane 50,000', () => {
expect(T.TILE_SCROLLBACK).toBe(10000);
+1 -1
View File
@@ -71,7 +71,7 @@ describe('zoom and restore', () => {
expect(app._tileGrid.zoomedId).toBeNull();
expect(section.classList.contains('tile-grid--zoomed')).toBe(false);
expect(section.style.gridTemplateColumns).toBe('repeat(2, minmax(0, 1fr))');
expect(section.style.gridTemplateColumns).toBe('minmax(0, 1fr) 6px minmax(0, 1fr)');
expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 1)).toBe(true);
expect(zoomButton('s-a').getAttribute('aria-pressed')).toBe('false');
});