diff --git a/src/web/public/app.js b/src/web/public/app.js index b9ffd575..5b1410f5 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -6438,6 +6438,10 @@ class CodemanApp { handleSessionTabClick(event, sessionId) { event?.preventDefault?.(); + // Ctrl/Cmd+click puts the session in the tile grid (opening it if needed) + // instead of switching to it; on a window too narrow for the grid it is an + // ordinary click. + if ((event?.ctrlKey || event?.metaKey) && this.addSessionToTiles?.(sessionId)) return; // On touch with the keyboard hidden, blur the tapped tab so switching // sessions doesn't pop the on-screen keyboard. Focus policy itself lives // in selectSession via _shouldFocusTerminalForTabSwitch(). @@ -6914,6 +6918,7 @@ class CodemanApp { const index = groups.findIndex((group) => group.id === groupId); if (index < 0) return false; return this._openTabLayoutMenu(event, `group:${groupId}`, 'Group actions', [ + ...(this.canOpenTileGrid?.() ? [{ label: 'Open group as tiles', run: () => this.openGroupAsTiles(groupId) }] : []), { label: 'Rename group', run: () => this.startTabGroupRename(groupId) }, ...(this._canCreateTabGroup() ? [{ label: 'New group', run: () => this.createTabGroup({ index: index + 1 }) }] : []), ...(index > 0 ? [{ label: 'Move group up', run: () => this.moveTabGroup(groupId, -1) }] : []), diff --git a/src/web/public/styles.css b/src/web/public/styles.css index e1282ec0..0cc5c850 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -19561,6 +19561,32 @@ body.tile-grid-resizing--row * { cursor: row-resize !important; } +/* An empty cell of a grid that is not full: a drop target for a tab. */ +.tile-slot { + display: flex; + align-items: center; + justify-content: center; + min-width: 0; + min-height: 0; + border: 1px dashed color-mix(in srgb, var(--text-muted, #888) 35%, transparent); + border-radius: 4px; + color: var(--text-muted); + font-size: 12px; + opacity: 0.6; +} + +.tile-grid.tile-grid--zoomed .tile-slot { + display: none; +} + +/* A tab dragged over a tile (replace it) or an empty slot (add it). */ +.tile.tile--drop-target, +.tile-slot.tile--drop-target { + border-color: var(--accent, #4a9eff); + box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent, #4a9eff) 45%, transparent); + opacity: 1; +} + /* 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) { diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index 1251acfe..bb083a56 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -256,6 +256,8 @@ Object.assign(CodemanApp.prototype, { grid.tiles.clear(); for (const el of grid.dividers.values()) el.remove(); grid.dividers.clear(); + for (const slot of grid.slots || []) slot.remove(); + grid.slots = []; grid.colFr = []; grid.rowFr = []; grid.ids = []; @@ -709,6 +711,7 @@ Object.assign(CodemanApp.prototype, { el.addEventListener('pointerdown', () => { if (this._tileGrid?.has(sessionId)) this.selectSession(sessionId); }); + this._acceptTabDrops(el, (draggedId) => this.dropSessionOnTile(draggedId, sessionId)); this._tileGridSection().appendChild(el); const tile = this._newTerminalTile(sessionId, body); grid.tiles.set(sessionId, { @@ -727,6 +730,159 @@ Object.assign(CodemanApp.prototype, { return true; }, + /** + * Makes `el` a drop target for a session tab dragged from the strip (the + * strip's own drag sets `draggedTabId`). Capture phase, with the event + * stopped: the drag carries the session id as text, and xterm's helper + * textarea would otherwise accept that drop and type the id into a PTY. Any + * other drag (a file) is left alone. + */ + _acceptTabDrops(el, onDrop) { + el.addEventListener( + 'dragover', + (e) => { + if (!this.draggedTabId || !this._tileGrid?.open) return; + e.preventDefault?.(); + e.stopPropagation?.(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; + el.classList.add('tile--drop-target'); + }, + true + ); + el.addEventListener('dragleave', (e) => { + if (!el.contains?.(e.relatedTarget)) el.classList.remove('tile--drop-target'); + }); + el.addEventListener( + 'drop', + (e) => { + el.classList.remove('tile--drop-target'); + if (!this.draggedTabId || !this._tileGrid?.open) return; + e.preventDefault?.(); + e.stopPropagation?.(); + onDrop(this.draggedTabId); + }, + true + ); + }, + + /** + * A tab dropped on a tile: a session not yet tiled REPLACES that tile (same + * place; the replaced session keeps running); one already tiled swaps places + * with it. Either way the dropped session takes focus (a human selection). + */ + dropSessionOnTile(draggedId, targetId) { + const grid = this._tileGrid; + if (!grid?.open || draggedId === targetId || !grid.tiles.has(targetId)) return; + if (!this.sessions.has(draggedId) || this.detachedSessions?.has(draggedId)) return; + if (grid.tiles.has(draggedId)) { + const a = grid.ids.indexOf(draggedId); + const b = grid.ids.indexOf(targetId); + grid.ids[a] = targetId; + grid.ids[b] = draggedId; + this._applyTileLayout(); + this._scheduleTileGridRefit(); + } else { + this._tileDividerDragTeardown?.(); + const index = grid.ids.indexOf(targetId); + if (!this._mountTile(draggedId)) return; + // _mountTile appended it; it takes the replaced tile's place instead. + grid.ids.pop(); + grid.ids.splice(index, 1, draggedId); + const old = grid.tiles.get(targetId); + grid.queue?.drop(old.tile); + old.tile.destroy(); + old.el.remove(); + grid.tiles.delete(targetId); + if (grid.zoomedId === targetId) grid.zoomedId = grid.autoZoom ? draggedId : null; + if (grid.focusedId === targetId) grid.focusedId = null; + this._applyTileLayout(); + this._connectTile(draggedId); + this._scheduleTileGridRefit(); + this.renderSessionTabs?.(); + } + this.selectSession(draggedId); + }, + + /** A tab dropped on an empty slot joins the grid there (an already tiled one moves there). */ + dropSessionOnSlot(draggedId) { + const grid = this._tileGrid; + if (!grid?.open || !this.sessions.has(draggedId) || this.detachedSessions?.has(draggedId)) return; + if (grid.tiles.has(draggedId)) { + // Empty slots are always the last cells in reading order. + grid.ids.splice(grid.ids.indexOf(draggedId), 1); + grid.ids.push(draggedId); + this._applyTileLayout(); + this._scheduleTileGridRefit(); + } else if (!this.addTile(draggedId)) { + return; + } + this.selectSession(draggedId); + }, + + /** + * 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 + * on what the Tiles toggle would bring back, plus this session. Returns false + * when the grid cannot open here (narrow or solo window), so the click is an + * ordinary one. + */ + addSessionToTiles(sessionId) { + if (!this.canOpenTileGrid() || !this.sessions.has(sessionId) || this.detachedSessions?.has(sessionId)) { + return false; + } + const T = window.CodemanTileGrid; + const capacity = Math.max(1, Math.min(this._tileGridCapacityNow(), T.TILE_GRID_MAX)); + const grid = this._tileGrid; + if (grid?.open) { + if (!grid.tiles.has(sessionId)) { + if (grid.ids.length >= capacity) { + this.showToast?.(`The grid already holds what this window fits (${capacity})`, 'info'); + return true; + } + this.addTile(sessionId); + } + this.selectSession(sessionId); + return true; + } + const remembered = (this._tileGridRemembered?.ids || []).filter( + (id) => this.sessions.has(id) && !this.detachedSessions?.has(id) + ); + const base = remembered.length ? remembered : [this.activeSessionId].filter(Boolean); + const ids = [...base.filter((id) => id !== sessionId).slice(0, capacity - 1), sessionId]; + this.openTileGrid(ids, { focusedId: sessionId, auto: false }); + return true; + }, + + /** + * "Open group as tiles" (the tab-group menu of the grouped rail): the + * group's live sessions, as many as the window fits, become the grid, + * replacing whatever it showed. Opening the grid is the app's choice of + * focus, so no idle alert is spent. + */ + openGroupAsTiles(groupId) { + const group = (this.tabLayout?.groups || []).find((g) => g.id === groupId); + if (!group || !this.canOpenTileGrid()) return false; + const T = window.CodemanTileGrid; + const capacity = Math.max(1, Math.min(this._tileGridCapacityNow(), T.TILE_GRID_MAX)); + const ids = (group.refs || []) + .filter((ref) => ref.kind === 'session') + .map((ref) => ref.id) + .filter((id) => this.sessions.has(id) && !this.detachedSessions?.has(id)) + .slice(0, capacity); + if (ids.length === 0) { + this.showToast?.('This group has no session to show as tiles', 'info'); + return false; + } + if (this._tilesOwnTerminal()) { + this.closeTileGrid({ keepStored: false, reselect: false }); + // As selectSession's tile branch: the parked terminal still holds what it + // showed before the grid, and re-parking must not snapshot it. + this.activeSessionId = null; + } + const focus = ids.includes(this.activeSessionId) ? this.activeSessionId : ids[0]; + return this.openTileGrid(ids, { focusedId: focus }); + }, + /** A grid tile's TerminalTile: the grid's one load queue, the tile scrollback, font and bounded load. */ _newTerminalTile(sessionId, body) { const session = this.sessions.get(sessionId); @@ -1076,6 +1232,28 @@ Object.assign(CodemanApp.prototype, { el.style.gridRow = id === zoomed ? '1' : String(2 * Math.floor(k / cols) + 1); }); this._syncTileDividers(zoomed ? 0 : cols, zoomed ? 0 : rows); + this._syncTileSlots(zoomed ? 0 : cols * rows - grid.ids.length, cols); + }, + + // The empty cells of a layout that is not full (3 tiles in a 2x2, 5 in a + // 3x2): drop targets for a tab, after the tiles in reading order. + _syncTileSlots(count, cols) { + const grid = this._tileGrid; + grid.slots ||= []; + while (grid.slots.length > count) grid.slots.pop().remove(); + while (grid.slots.length < count) { + const slot = document.createElement('div'); + slot.className = 'tile-slot'; + slot.textContent = 'Drop a tab here'; + this._acceptTabDrops(slot, (draggedId) => this.dropSessionOnSlot(draggedId)); + this._tileGridSection().appendChild(slot); + grid.slots.push(slot); + } + grid.slots.forEach((slot, i) => { + const k = grid.ids.length + i; + slot.style.gridColumn = String(2 * (k % cols) + 1); + slot.style.gridRow = String(2 * Math.floor(k / cols) + 1); + }); }, // One divider per gap between columns and between rows, created and dropped diff --git a/test/mocks/tile-grid-vm.ts b/test/mocks/tile-grid-vm.ts index 8e48b765..96b0d0b4 100644 --- a/test/mocks/tile-grid-vm.ts +++ b/test/mocks/tile-grid-vm.ts @@ -97,8 +97,11 @@ export class FakeEl { getAttribute(k: string) { return this.attrs[k] ?? null; } - addEventListener(type: string, fn: (ev: unknown) => void) { + /** Per event type, whether each listener was registered for the capture phase. */ + captureFlags: Record = {}; + addEventListener(type: string, fn: (ev: unknown) => void, opts?: boolean | { capture?: boolean }) { (this.listeners[type] ||= []).push(fn); + (this.captureFlags[type] ||= []).push(opts === true || (typeof opts === 'object' && !!opts?.capture)); } dispatch(type: string, ev: unknown = {}) { for (const fn of this.listeners[type] ?? []) fn(ev); diff --git a/test/tile-grid-entry-points.test.ts b/test/tile-grid-entry-points.test.ts new file mode 100644 index 00000000..15224faa --- /dev/null +++ b/test/tile-grid-entry-points.test.ts @@ -0,0 +1,222 @@ +/** + * @fileoverview The ways into the grid besides the Tiles button. + * + * - Drag a tab from the strip onto a tile: a session not yet tiled replaces it + * (same place, the replaced session keeps running); one already tiled swaps + * places. Onto an empty slot (3 tiles in a 2x2): it joins there. The tile + * handles the drag in the CAPTURE phase and stops it, because the payload is + * the session id as text and xterm's helper textarea would type it into the + * PTY; a drag that is not a tab (a file) is left alone. + * - Ctrl/Cmd+click on a tab: the session joins the grid (opening it on what + * Tiles would bring back) and takes focus, as a human selection; on a window + * too narrow for the grid it is an ordinary click. + * - "Open group as tiles" in the grouped rail's group menu: the group's live + * sessions become the grid. + * + * Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + FakeEl, + FakeTile, + makeGridApp, + resetGridHarness, + section, + windowStub, + type GridApp, +} from './mocks/tile-grid-vm.js'; + +const IDS = ['s-a', 's-b', 's-c']; +const tileEl = (id: string) => section.children.find((el) => el.dataset.sessionId === id) as FakeEl; +const slots = () => section.children.filter((el) => el.className === 'tile-slot'); +const dragEvent = () => ({ preventDefault: vi.fn(), stopPropagation: vi.fn(), dataTransfer: { dropEffect: '' } }); + +function dropOn(el: FakeEl) { + const over = dragEvent(); + el.dispatch('dragover', over); + const drop = dragEvent(); + el.dispatch('drop', drop); + return { over, drop }; +} + +beforeEach(() => { + resetGridHarness(); +}); + +describe('dragging a tab onto a tile', () => { + it('a session not yet tiled replaces the tile in place; the replaced one keeps running', () => { + const app = makeGridApp(IDS); + app.openTileGrid(IDS); + app.markIdleAlertSeen.mockClear(); + const replaced = FakeTile.all.find((t) => t.sessionId === 's-b')!; + app.draggedTabId = 's-other'; + const { over, drop } = dropOn(tileEl('s-b')); + + expect(over.preventDefault).toHaveBeenCalled(); + expect(drop.stopPropagation).toHaveBeenCalled(); + expect(app._tileGrid.ids).toEqual(['s-a', 's-other', 's-c']); + expect(replaced.destroy).toHaveBeenCalledTimes(1); + expect(app.sessions.has('s-b')).toBe(true); + expect(tileEl('s-other').style.gridColumn).toBe('3'); + expect(app.activeSessionId).toBe('s-other'); + expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-other'); + }); + + it('a session already tiled swaps places with the target', () => { + const app = makeGridApp(IDS); + app.openTileGrid(IDS); + app.draggedTabId = 's-c'; + dropOn(tileEl('s-a')); + expect(app._tileGrid.ids).toEqual(['s-c', 's-b', 's-a']); + expect(FakeTile.all.every((t) => t.destroy.mock.calls.length === 0)).toBe(true); + expect(tileEl('s-c').style.gridColumn).toBe('1'); + expect(app.activeSessionId).toBe('s-c'); + }); + + it('a drag that is not a tab (a file) is left alone', () => { + const app = makeGridApp(IDS); + app.openTileGrid(IDS); + app.draggedTabId = null; + const { over, drop } = dropOn(tileEl('s-b')); + expect(over.preventDefault).not.toHaveBeenCalled(); + expect(drop.stopPropagation).not.toHaveBeenCalled(); + expect(app._tileGrid.ids).toEqual(IDS); + }); + + it('is handled in the capture phase, before xterm sees the drop', () => { + makeGridApp(IDS).openTileGrid(IDS); + expect(tileEl('s-a').captureFlags.dragover).toEqual([true]); + expect(tileEl('s-a').captureFlags.drop).toEqual([true]); + }); +}); + +describe('dragging a tab onto an empty slot', () => { + // Under 1800px wide three tiles take a 2x2 (over it, they sit side by side). + beforeEach(() => { + section.getBoundingClientRect = () => ({ width: 1700, height: 1000, top: 0, left: 0, right: 1700, bottom: 1000 }); + }); + afterEach(() => { + delete (section as unknown as Record).getBoundingClientRect; + }); + + it('3 tiles in a 2x2 leave one slot, placed after the tiles', () => { + makeGridApp(IDS).openTileGrid(IDS); + expect(slots()).toHaveLength(1); + expect([slots()[0].style.gridColumn, slots()[0].style.gridRow]).toEqual(['3', '3']); + }); + + it('a session dropped there joins the grid and takes focus', () => { + const app = makeGridApp(IDS); + app.openTileGrid(IDS); + app.draggedTabId = 's-other'; + dropOn(slots()[0]); + expect(app._tileGrid.ids).toEqual([...IDS, 's-other']); + expect(slots()).toHaveLength(0); + expect(app.activeSessionId).toBe('s-other'); + }); + + it('a full layout has no slot', () => { + const app = makeGridApp(IDS); + app.openTileGrid(['s-a', 's-b']); + expect(slots()).toHaveLength(0); + }); +}); + +describe('Ctrl/Cmd+click on a tab', () => { + const click = (mods: Record) => ({ preventDefault: vi.fn(), ...mods }); + + it('with the grid closed: opens it with the active session and the clicked one, focusing it', () => { + const app = makeGridApp(IDS); + app.activeSessionId = 's-a'; + app.handleSessionTabClick(click({ ctrlKey: true }), 's-c'); + expect(app._tileGrid.ids).toEqual(['s-a', 's-c']); + expect(app.activeSessionId).toBe('s-c'); + expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-c'); + }); + + it('with the grid open: adds it and focuses it (Cmd works the same)', () => { + const app = makeGridApp(IDS); + app.openTileGrid(['s-a', 's-b']); + app.markIdleAlertSeen.mockClear(); + app.handleSessionTabClick(click({ metaKey: true }), 's-c'); + expect(app._tileGrid.ids).toEqual(IDS); + expect(app.activeSessionId).toBe('s-c'); + expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-c'); + }); + + it('on a tiled one: just focuses its tile', () => { + const app = makeGridApp(IDS); + app.openTileGrid(IDS); + app.handleSessionTabClick(click({ ctrlKey: true }), 's-b'); + expect(app._tileGrid.ids).toEqual(IDS); + expect(app.activeSessionId).toBe('s-b'); + }); + + it('a plain click is still a plain selection', () => { + const app = makeGridApp(IDS); + app.selectSession = vi.fn(); + app.handleSessionTabClick(click({}), 's-b'); + expect(app.selectSession).toHaveBeenCalledWith('s-b', { forceReload: true }); + expect(app._tileGrid?.open ?? false).toBe(false); + }); + + it('on a window too narrow for the grid it is an ordinary click', () => { + const app = makeGridApp(IDS); + app.selectSession = vi.fn(); + windowStub.innerWidth = 1100; + app.handleSessionTabClick(click({ ctrlKey: true }), 's-b'); + expect(app.selectSession).toHaveBeenCalledWith('s-b', { forceReload: true }); + expect(app._tileGrid?.open ?? false).toBe(false); + }); +}); + +describe('"Open group as tiles"', () => { + function withGroups(app: GridApp) { + app.tabLayout = { + groups: [ + { + id: 'g1', + refs: [ + { kind: 'session', id: 's-c' }, + // A web tab is never a tile, even one whose id a session also has. + { kind: 'webview', id: 's-b' }, + { kind: 'session', id: 'gone' }, + { kind: 'session', id: 's-a' }, + ], + }, + ], + }; + let actions: Array<{ label: string; run: () => void }> = []; + app._openTabLayoutMenu = vi.fn((_e: unknown, _k: string, _l: string, a: typeof actions) => { + actions = a; + return true; + }); + return () => actions; + } + + it('is in the group menu, and opens the group live sessions as tiles', () => { + const app = makeGridApp(IDS); + const actions = withGroups(app); + app.openTabGroupMenu({}, 'g1'); + const open = actions().find((a) => a.label === 'Open group as tiles'); + expect(open).toBeTruthy(); + open!.run(); + expect(app._tileGrid.ids).toEqual(['s-c', 's-a']); + }); + + it('replaces an open grid', () => { + const app = makeGridApp(IDS); + withGroups(app); + app.openTileGrid(['s-b']); + app.openGroupAsTiles('g1'); + expect(app._tileGrid.ids).toEqual(['s-c', 's-a']); + }); + + it('is not offered where the grid cannot open', () => { + const app = makeGridApp(IDS); + const actions = withGroups(app); + windowStub.innerWidth = 1100; + app.openTabGroupMenu({}, 'g1'); + expect(actions().some((a) => a.label === 'Open group as tiles')).toBe(false); + }); +});