mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
feat(tiles): drag a tile by its header to move it
Owner request: "give me the option to move the tiles around". A tile's header (its free area, not the buttons or the rename input) is now a native drag handle: dropped on another tile the two trade places, dropped on an empty slot it moves there (the last cell; the tiles after it close up). Escape or a drop anywhere else is the browser's own cancel and moves nothing. Every move goes through one path, _reorderTiles: the header drag, a tab of a tiled session dropped on a tile or a slot, and (next commit) the Move Tile chords. Nothing is remounted, reconnected or reloaded. Divider sizes belong to the cells, so a moved tile takes its new cell's size: each tile whose cell size changed fits once (one PTY resize, #464) and every other tile is left alone, in place of the debounced refit of every tile the swap used to schedule. The drag reuses the tab drop targets (capture phase, stopped before xterm), carries a type of its own and never text, and is not draggedTabId, so neither a text field nor the tab strip takes it. Moving is off while a tile is zoomed (draggable off, and a tab drag of a tiled session onto the zoomed tile is refused too) and with a single tile. The handle shows a grab cursor and says it drags in its tooltip (zh-CN included); the dragged tile is dimmed and the target highlighted. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+20
-1
@@ -133,6 +133,8 @@
|
||||
'Restore the grid': '恢复平铺网格',
|
||||
'Remove tile (the session keeps running)': '移除窗格(会话继续运行)',
|
||||
'Drop a tab here': '将标签页拖放到此处',
|
||||
// A tile header's tooltip while tiles can move (with the state above it: a pattern below).
|
||||
'Drag to move the tile': '拖动可移动窗格',
|
||||
'Resize tile columns': '调整窗格列宽',
|
||||
'Resize tile rows': '调整窗格行高',
|
||||
Attach: '附加',
|
||||
@@ -1012,6 +1014,17 @@
|
||||
return value.replace(/\{([a-zA-Z][\w]*)\}/g, (_match, key) => String(variables[key] ?? ''));
|
||||
}
|
||||
|
||||
// The six-state words of a tile header's tooltip (tile-grid.js _paintTileHandle).
|
||||
const TILE_STATE_ZH = {
|
||||
'needs you': '需要你',
|
||||
error: '错误',
|
||||
waiting: '等待中',
|
||||
working: '工作中',
|
||||
idle: '空闲',
|
||||
done: '已完成',
|
||||
exited: '已退出',
|
||||
};
|
||||
|
||||
function translateDynamic(source) {
|
||||
const patterns = [
|
||||
[/^(\d+) tokens?$/, (_m, count) => `${count} 个 Token`],
|
||||
@@ -1087,8 +1100,14 @@
|
||||
// state strings on other surfaces (see mobile-overview.js).
|
||||
[
|
||||
/^(needs you|error|waiting|working|idle|done|exited) (<1m|\d+[dhm](?: \d+[hm])?)$/,
|
||||
(_m, state, duration) => `${TILE_STATE_ZH[state]} ${duration}`,
|
||||
],
|
||||
// The same while tiles can move, with the drag hint on a second line.
|
||||
// Anchored on the hint, so a bare state word is safe here.
|
||||
[
|
||||
/^(needs you|error|waiting|working|idle|done|exited)(?: (<1m|\d+[dhm](?: \d+[hm])?))?\nDrag to move the tile$/,
|
||||
(_m, state, duration) =>
|
||||
`${{ 'needs you': '需要你', error: '错误', waiting: '等待中', working: '工作中', idle: '空闲', done: '已完成', exited: '已退出' }[state]} ${duration}`,
|
||||
`${TILE_STATE_ZH[state]}${duration ? ` ${duration}` : ''}\n${ZH_CN['Drag to move the tile']}`,
|
||||
],
|
||||
];
|
||||
for (const [pattern, replacement] of patterns) {
|
||||
|
||||
@@ -19671,7 +19671,8 @@ body.tile-grid-resizing--row * {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* A tab dragged over a tile (replace it) or an empty slot (add it). */
|
||||
/* A tab dragged over a tile (replace it) or an empty slot (add it), or a tile
|
||||
dragged there by its header (swap, move). */
|
||||
.tile.tile--drop-target,
|
||||
.tile-slot.tile--drop-target {
|
||||
border-color: var(--accent, #4a9eff);
|
||||
@@ -19679,6 +19680,23 @@ body.tile-grid-resizing--row * {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* The header's free area moves its tile (tile-grid.js _installTileMoveDrag).
|
||||
`draggable` is off while moving is (a zoomed grid, a single tile, a rename),
|
||||
and the buttons keep their own pointer. */
|
||||
.tile-header[draggable='true'] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.tile-header[draggable='true']:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* The tile being dragged by its header. Opacity only: nothing that moves
|
||||
layout, which would resize its PTY. */
|
||||
.tile.tile--dragging {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
|
||||
+164
-33
@@ -253,8 +253,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Remembered (closed) for one-click return, or forgotten.
|
||||
if (keepStored) this._persistTileGrid({ open: false });
|
||||
else this._forgetStoredTileGrid();
|
||||
// A divider drag in progress ends with the grid.
|
||||
// A divider drag or a header drag in progress ends with the grid.
|
||||
this._tileDividerDragTeardown?.();
|
||||
this._endTileMoveDrag();
|
||||
// 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;
|
||||
@@ -681,8 +682,10 @@ 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.
|
||||
// A divider drag in progress was measured against this tile; a header
|
||||
// drag of this tile has nothing left to drop.
|
||||
this._tileDividerDragTeardown?.();
|
||||
if (this._draggedTileId === sessionId) this._endTileMoveDrag();
|
||||
// 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;
|
||||
@@ -748,20 +751,26 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* strip's own drag sets `draggedTabId`) and for a tile dragged by its header
|
||||
* (`_draggedTileId`, _installTileMoveDrag). Capture phase, with the event
|
||||
* stopped: a tab drag carries the session id as text, and xterm's helper
|
||||
* textarea would otherwise accept that drop and type the id into a PTY. Over
|
||||
* the dragged session's own tile the drag is held there too, but refused
|
||||
* (`dropEffect: 'none'`, so no drop follows). Any other drag (a file) is
|
||||
* left alone.
|
||||
*/
|
||||
_acceptTabDrops(el, onDrop) {
|
||||
const dragged = () => (this._tileGrid?.open ? this.draggedTabId || this._draggedTileId || null : null);
|
||||
el.addEventListener(
|
||||
'dragover',
|
||||
(e) => {
|
||||
if (!this.draggedTabId || !this._tileGrid?.open) return;
|
||||
const id = dragged();
|
||||
if (!id) return;
|
||||
e.preventDefault?.();
|
||||
e.stopPropagation?.();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
||||
el.classList.add('tile--drop-target');
|
||||
const own = id === el.dataset.sessionId;
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = own ? 'none' : 'move';
|
||||
if (!own) el.classList.add('tile--drop-target');
|
||||
},
|
||||
true
|
||||
);
|
||||
@@ -772,31 +781,76 @@ Object.assign(CodemanApp.prototype, {
|
||||
'drop',
|
||||
(e) => {
|
||||
el.classList.remove('tile--drop-target');
|
||||
if (!this.draggedTabId || !this._tileGrid?.open) return;
|
||||
const id = dragged();
|
||||
if (!id) return;
|
||||
e.preventDefault?.();
|
||||
e.stopPropagation?.();
|
||||
onDrop(this.draggedTabId);
|
||||
onDrop(id);
|
||||
},
|
||||
true
|
||||
);
|
||||
},
|
||||
|
||||
/** Whether tiles can be moved now: more than one, and none zoomed (moving is off while one is). */
|
||||
_tilesMovable() {
|
||||
const grid = this._tileGrid;
|
||||
return !!grid?.open && !grid.zoomedId && grid.ids.length > 1;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Puts the tiles in a new reading order (the same sessions): the one path
|
||||
* every move takes, a header drag, a tab dragged onto a tile or a slot, and
|
||||
* the Move Tile chords. Nothing is remounted, reconnected or reloaded, and
|
||||
* no session joins or leaves. Divider sizes belong to the cells, so a moved
|
||||
* tile takes its new cell's size: each tile whose cell size changed fits
|
||||
* once (its xterm and one PTY resize together, #464), every other tile is
|
||||
* left alone. Refused while a tile is zoomed (moving is off then).
|
||||
*
|
||||
* @param {string[]} ids - the new order
|
||||
* @returns {boolean} false when refused, true otherwise (also when nothing moved)
|
||||
*/
|
||||
_reorderTiles(ids) {
|
||||
const grid = this._tileGrid;
|
||||
if (!grid?.open || grid.zoomedId) return false;
|
||||
if (ids.length !== grid.ids.length || ids.some((id) => !grid.tiles.has(id))) return false;
|
||||
if (ids.every((id, k) => grid.ids[k] === id)) return true;
|
||||
// A divider drag in progress measured the tiles at their old places.
|
||||
this._tileDividerDragTeardown?.();
|
||||
const cell = (k) => `${grid.colFr[k % grid.cols]}x${grid.rowFr[Math.floor(k / grid.cols)]}`;
|
||||
const before = new Map(grid.ids.map((id, k) => [id, cell(k)]));
|
||||
grid.ids = ids.slice();
|
||||
this._applyTileLayout();
|
||||
// Synchronous: the fit's measurement forces the new placement's layout.
|
||||
grid.ids.forEach((id, k) => {
|
||||
if (before.get(id) !== cell(k)) grid.tiles.get(id).tile.fit();
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
/** Two tiles trade places (_reorderTiles). */
|
||||
_swapTiles(a, b) {
|
||||
const ids = this._tileGrid.ids.slice();
|
||||
const i = ids.indexOf(a);
|
||||
const j = ids.indexOf(b);
|
||||
if (i === -1 || j === -1) return false;
|
||||
ids[i] = b;
|
||||
ids[j] = a;
|
||||
return this._reorderTiles(ids);
|
||||
},
|
||||
|
||||
/**
|
||||
* A tab dropped on a tile, or a tile dragged there by its header: a session
|
||||
* not yet tiled REPLACES that tile (same place; the replaced session keeps
|
||||
* running); one already tiled swaps places with it (_swapTiles, refused while
|
||||
* a tile is zoomed). 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();
|
||||
if (!this._swapTiles(draggedId, targetId)) return;
|
||||
} else {
|
||||
this._tileDividerDragTeardown?.();
|
||||
const index = grid.ids.indexOf(targetId);
|
||||
@@ -818,16 +872,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.selectSession(draggedId);
|
||||
},
|
||||
|
||||
/** A tab dropped on an empty slot joins the grid there (an already tiled one moves there). */
|
||||
/**
|
||||
* A tab dropped on an empty slot joins the grid there; a tiled session (its
|
||||
* tab, or the tile dragged by its header) moves there (_reorderTiles).
|
||||
*/
|
||||
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();
|
||||
// Empty slots are always the last cells in reading order: the tile goes
|
||||
// last, the tiles after it close up.
|
||||
if (!this._reorderTiles([...grid.ids.filter((id) => id !== draggedId), draggedId])) return;
|
||||
} else if (!this.addTile(draggedId)) {
|
||||
return;
|
||||
}
|
||||
@@ -1083,7 +1138,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
* session name (double-click renames), the model it runs when known, the
|
||||
* session menu (the tab rail's own), zoom and remove-tile. Its
|
||||
* buttons stop pointerdown, so acting on a tile that is not focused does not
|
||||
* also focus it (and spend its idle alert).
|
||||
* also focus it (and spend its idle alert). The rest of it is the handle that
|
||||
* moves the tile (_installTileMoveDrag).
|
||||
*/
|
||||
_buildTileHeader(sessionId) {
|
||||
const el = document.createElement('div');
|
||||
@@ -1148,9 +1204,84 @@ Object.assign(CodemanApp.prototype, {
|
||||
button('tile-remove', 'Remove tile (the session keeps running)', '\u00D7', () => this.removeTile(sessionId))
|
||||
);
|
||||
el.append(dot, harness, title, actions);
|
||||
this._installTileMoveDrag(sessionId, el, actions);
|
||||
return { el, dot, harness, name, model, modelName, zoomBtn };
|
||||
},
|
||||
|
||||
/**
|
||||
* The header moves its tile: dragged onto another tile the two trade places,
|
||||
* onto an empty slot it moves there (dropSessionOnTile / dropSessionOnSlot,
|
||||
* the path a dragged tab takes, through the same capture-phase drop targets,
|
||||
* _acceptTabDrops). A native drag, so Escape and a drop anywhere else are the
|
||||
* browser's own cancel: nothing moves, and dragend clears what the drag
|
||||
* painted. The drag carries a type of its own and never text, so no text
|
||||
* field or terminal, in this page or another application, can take it as
|
||||
* typing; and it is not `draggedTabId`, so the tab strip ignores it.
|
||||
*
|
||||
* The handle is the header's free area: a press on a button or the rename
|
||||
* input starts no drag. dragstart's target is the header whatever was
|
||||
* pressed, so where the press landed is noted in the capture phase, ahead of
|
||||
* the buttons' own stopPropagation. `draggable` is off while moving is
|
||||
* (_paintTileHandle: a zoomed grid, a single tile, a rename in progress).
|
||||
*/
|
||||
_installTileMoveDrag(sessionId, header, actions) {
|
||||
let pressedOnControl = false;
|
||||
header.addEventListener(
|
||||
'pointerdown',
|
||||
(e) => {
|
||||
const target = e.target;
|
||||
pressedOnControl = !!actions.contains?.(target) || /^(INPUT|BUTTON)$/i.test(target?.tagName || '');
|
||||
},
|
||||
true
|
||||
);
|
||||
header.addEventListener('dragstart', (e) => {
|
||||
const entry = this._tileGrid?.open ? this._tileGrid.tiles.get(sessionId) : null;
|
||||
if (!entry || pressedOnControl || entry.renaming || !this._tilesMovable()) {
|
||||
e.preventDefault?.();
|
||||
return;
|
||||
}
|
||||
this._draggedTileId = sessionId;
|
||||
entry.el.classList.add('tile--dragging');
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
// Firefox starts no drag without data.
|
||||
e.dataTransfer.setData('application/x-codeman-tile', sessionId);
|
||||
}
|
||||
});
|
||||
header.addEventListener('dragend', () => this._endTileMoveDrag());
|
||||
},
|
||||
|
||||
/**
|
||||
* A header drag is over (dropped, cancelled with Escape, dropped outside),
|
||||
* or its tile or the grid went mid-drag: forget it and clear what it
|
||||
* painted. The drop targets' highlight too: a cancelled drag does not
|
||||
* reliably send dragleave.
|
||||
*/
|
||||
_endTileMoveDrag() {
|
||||
this._draggedTileId = null;
|
||||
const grid = this._tileGrid;
|
||||
for (const { el } of grid?.tiles.values() || []) el.classList.remove('tile--dragging', 'tile--drop-target');
|
||||
for (const slot of grid?.slots || []) slot.classList.remove('tile--drop-target');
|
||||
},
|
||||
|
||||
/**
|
||||
* The header as a handle: `draggable` while the tile can move (not while a
|
||||
* tile is zoomed, alone, or being renamed), and its tooltip, the state and
|
||||
* how long ("working 3m") plus, while it can move, that it drags. Diffs on
|
||||
* the last English text, never the DOM (translated in zh-CN; see
|
||||
* _renderTileOverlay).
|
||||
*/
|
||||
_paintTileHandle(entry) {
|
||||
const movable = this._tilesMovable();
|
||||
const title = [entry.stateLabel, movable ? 'Drag to move the tile' : ''].filter(Boolean).join('\n');
|
||||
if (entry.headerLabel !== title) {
|
||||
entry.headerLabel = title;
|
||||
entry.header.title = title;
|
||||
}
|
||||
const draggable = movable && !entry.renaming ? 'true' : 'false';
|
||||
if (entry.header.getAttribute('draggable') !== draggable) entry.header.setAttribute('draggable', draggable);
|
||||
},
|
||||
|
||||
/**
|
||||
* Refreshes one tile's header from the session: dot state, harness logo,
|
||||
* name, model, the hover label ("working 3m") and the `needs` border. Diffs on existing nodes only,
|
||||
@@ -1165,12 +1296,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const dotClass = `tile-dot home-sessions-dot home-sessions-dot--${row?.exited ? 'done' : state}`;
|
||||
if (entry.dot.className !== dotClass) entry.dot.className = dotClass;
|
||||
const since = row?.since?.at ? this._mobileOverviewStampText?.(row.since.at, 'for') : '';
|
||||
const label = row ? [row.pill, since].filter(Boolean).join(' ') : '';
|
||||
// Against the last English label, never the DOM (translated in zh-CN; see _renderTileOverlay).
|
||||
if (entry.headerLabel !== label) {
|
||||
entry.headerLabel = label;
|
||||
entry.header.title = label;
|
||||
}
|
||||
entry.stateLabel = row ? [row.pill, since].filter(Boolean).join(' ') : '';
|
||||
this._paintTileHandle(entry);
|
||||
// The input of a rename in progress has taken the name's place in the
|
||||
// header, so updating the detached name never touches what is being typed.
|
||||
// A rename still in flight shows as already done, as on the tab.
|
||||
@@ -1212,6 +1339,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!entry || !session || entry.renaming) return;
|
||||
entry.renaming = true;
|
||||
// No drag from the header while its input is in it (_paintTileHandle).
|
||||
this._paintTileHandle(entry);
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'tile-rename-input';
|
||||
@@ -1301,6 +1430,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const zoomed = grid.zoomedId && grid.tiles.has(grid.zoomedId) ? grid.zoomedId : null;
|
||||
section.classList.toggle('tile-grid--zoomed', !!zoomed);
|
||||
this._syncTileZoom(zoomed);
|
||||
// Moving is off while a tile is zoomed or alone: the headers say so.
|
||||
for (const entry of grid.tiles.values()) this._paintTileHandle(entry);
|
||||
// Zoomed: one cell; the other tiles stay connected but hidden (CSS), so
|
||||
// they measure nothing and send no resize. Otherwise every tile is placed
|
||||
// explicitly in reading order, with a divider track between columns and
|
||||
|
||||
@@ -99,7 +99,8 @@ describe('the header', () => {
|
||||
app.sessions.get('s-b').status = 'busy';
|
||||
app.sessions.get('s-b').lastActivityAt = Date.now() - 180_000;
|
||||
app._renderTileChrome();
|
||||
expect(headerOf('s-b').title).toBe('working 3m');
|
||||
// While tiles can move, a second line says the header drags.
|
||||
expect(headerOf('s-b').title).toBe('working 3m\nDrag to move the tile');
|
||||
});
|
||||
|
||||
it('every tab render refreshes the headers', () => {
|
||||
@@ -353,10 +354,12 @@ describe('a translated label survives a refresh (zh-CN)', () => {
|
||||
app.openTileGrid(['s-a', 's-b']);
|
||||
const a = app._tileGrid.tiles.get('s-a');
|
||||
const b = app._tileGrid.tiles.get('s-b');
|
||||
expect(a.header.title).toBe('idle 3m');
|
||||
expect(a.header.title).toBe('idle 3m\nDrag to move the tile');
|
||||
expect(b.overlayText.textContent).toBe('Not attached');
|
||||
app.zoomTile('s-a');
|
||||
expect(a.zoomBtn.title).toBe('Restore the grid');
|
||||
// Zoomed, nothing moves: the drag hint goes.
|
||||
expect(a.header.title).toBe('idle 3m');
|
||||
|
||||
// What the translator does to them.
|
||||
a.header.title = '空闲 3m';
|
||||
|
||||
@@ -285,8 +285,9 @@ describe('every tile grid string the code puts on screen translates to zh-CN', (
|
||||
'Session actions',
|
||||
'Remove tile (the session keeps running)',
|
||||
'idle 3m',
|
||||
'needs you 3m',
|
||||
'exited 3m',
|
||||
'idle 3m\nDrag to move the tile',
|
||||
'needs you 3m\nDrag to move the tile',
|
||||
'exited 3m\nDrag to move the tile',
|
||||
'Not attached',
|
||||
'Attach',
|
||||
'Attaching…',
|
||||
@@ -346,6 +347,16 @@ describe('every tile grid string the code puts on screen translates to zh-CN', (
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
|
||||
it('the header tooltip with the drag hint: every state, with and without a duration', () => {
|
||||
// The harvest sees the hint under a state with a duration; a session the
|
||||
// overview gives no time yet shows the bare state above it.
|
||||
expect(zh.api.t('working 3m\nDrag to move the tile')).toBe('工作中 3m\n拖动可移动窗格');
|
||||
expect(zh.api.t('needs you <1m\nDrag to move the tile')).toBe('需要你 <1m\n拖动可移动窗格');
|
||||
expect(zh.api.t('idle\nDrag to move the tile')).toBe('空闲\n拖动可移动窗格');
|
||||
expect(zh.api.t('Drag to move the tile')).toBe('拖动可移动窗格');
|
||||
expect(en.api.t('idle\nDrag to move the tile')).toBe('idle\nDrag to move the tile');
|
||||
});
|
||||
|
||||
it('no UI label sits inside a skipped subtree (where the translator cannot reach it)', () => {
|
||||
expect(labelsInSkip).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* @fileoverview Moving tiles (owner request): a tile dragged by its header
|
||||
* onto another tile trades places with it, onto an empty slot it moves there.
|
||||
*
|
||||
* - One path for every move (`_reorderTiles`): the header drag, a tab dragged
|
||||
* onto a tile or a slot, the Move Tile chords. Nothing is remounted,
|
||||
* reconnected or reloaded (no new TerminalTile, no `connect()`, no fetch),
|
||||
* no session joins or leaves, the grid stays open, the order is persisted.
|
||||
* - Divider sizes belong to the cells: a tile whose cell size changed fits
|
||||
* once (one PTY resize, #464), every other tile is left alone.
|
||||
* - The header drag is native: Escape or a drop anywhere else ends in a
|
||||
* `dragend` with no drop, which moves nothing and clears what the drag
|
||||
* painted. It carries a type of its own (never text) and is not
|
||||
* `draggedTabId`, so neither a text field nor the tab strip takes it. The
|
||||
* drop targets hold it in the capture phase, before xterm.
|
||||
* - The handle is the header's free area: a press on a button or the rename
|
||||
* input starts no drag, and a double-click on the name still renames.
|
||||
* - Moving is off while a tile is zoomed (the header drag, a tab drag of a
|
||||
* tiled session, the chords) and with a single tile.
|
||||
*
|
||||
* 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,
|
||||
fetchSpy,
|
||||
localStore,
|
||||
makeGridApp,
|
||||
resetGridHarness,
|
||||
section,
|
||||
type GridApp,
|
||||
tileEl,
|
||||
} from './mocks/tile-grid-vm.js';
|
||||
|
||||
const FOUR = ['s-a', 's-b', 's-c', 's-d'];
|
||||
const SIX = ['s-a', 's-b', 's-c', 's-d', 's-e', 's-f'];
|
||||
const TILE_TYPE = 'application/x-codeman-tile';
|
||||
|
||||
const headerOf = (id: string) => tileEl(id).children[0];
|
||||
const actionsOf = (id: string) => headerOf(id).children[3];
|
||||
const nameOf = (id: string) => headerOf(id).children[2].children[0];
|
||||
const tile = (id: string) => FakeTile.all.find((t) => t.sessionId === id && !t._destroyed) as FakeTile;
|
||||
const slots = () => section.children.filter((el) => el.className.split(' ').includes('tile-slot'));
|
||||
const stored = () => JSON.parse(localStore.get('codeman:tile-grid') ?? 'null');
|
||||
|
||||
const dragEvent = () => ({
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
dataTransfer: { effectAllowed: '', dropEffect: '', setData: vi.fn() },
|
||||
});
|
||||
|
||||
/** A press on `target` inside the header (the capture-phase listener notes where), then the native dragstart. */
|
||||
function startDrag(id: string, target: FakeEl = headerOf(id)) {
|
||||
headerOf(id).dispatch('pointerdown', { target, button: 0 });
|
||||
const start = dragEvent();
|
||||
headerOf(id).dispatch('dragstart', start);
|
||||
return start;
|
||||
}
|
||||
function over(el: FakeEl) {
|
||||
const e = dragEvent();
|
||||
el.dispatch('dragover', e);
|
||||
return e;
|
||||
}
|
||||
function drop(el: FakeEl) {
|
||||
const e = dragEvent();
|
||||
el.dispatch('drop', e);
|
||||
return e;
|
||||
}
|
||||
const end = (id: string) => headerOf(id).dispatch('dragend', dragEvent());
|
||||
|
||||
/** A full header drag: press, start, over and drop on `target`, end. */
|
||||
function dragTileOnto(id: string, target: FakeEl) {
|
||||
const start = startDrag(id);
|
||||
const o = over(target);
|
||||
const d = drop(target);
|
||||
end(id);
|
||||
return { start, over: o, drop: d };
|
||||
}
|
||||
|
||||
function openGrid(ids: string[], focusedId = ids[0]): GridApp {
|
||||
const app = makeGridApp(ids);
|
||||
app.openTileGrid(ids, { focusedId });
|
||||
return app;
|
||||
}
|
||||
|
||||
/** What a move must never cost: a new tile, a connect, a destroy, a fetch. */
|
||||
function snapshotCost() {
|
||||
const tiles = FakeTile.all.length;
|
||||
const connects = FakeTile.all.map((t) => t.connect.mock.calls.length);
|
||||
fetchSpy.mockClear();
|
||||
return () => {
|
||||
expect(FakeTile.all.length).toBe(tiles);
|
||||
expect(FakeTile.all.map((t) => t.connect.mock.calls.length)).toEqual(connects);
|
||||
expect(FakeTile.all.every((t) => t.destroy.mock.calls.length === 0)).toBe(true);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
};
|
||||
}
|
||||
const clearFits = () => {
|
||||
for (const t of FakeTile.all) t.fit.mockClear();
|
||||
};
|
||||
const fitCounts = (ids: string[]) => Object.fromEntries(ids.map((id) => [id, tile(id).fit.mock.calls.length]));
|
||||
|
||||
beforeEach(() => {
|
||||
resetGridHarness();
|
||||
});
|
||||
|
||||
describe('dragging a tile by its header', () => {
|
||||
it('onto another tile: the two trade places, the dragged one takes focus, nothing remounts or reloads', () => {
|
||||
const app = openGrid(FOUR, 's-b');
|
||||
app.markIdleAlertSeen.mockClear();
|
||||
const costsNothing = snapshotCost();
|
||||
|
||||
const start = startDrag('s-a');
|
||||
expect(start.preventDefault).not.toHaveBeenCalled();
|
||||
expect(tileEl('s-a').classList.contains('tile--dragging')).toBe(true);
|
||||
const o = over(tileEl('s-d'));
|
||||
expect(o.preventDefault).toHaveBeenCalled();
|
||||
expect(o.dataTransfer.dropEffect).toBe('move');
|
||||
expect(tileEl('s-d').classList.contains('tile--drop-target')).toBe(true);
|
||||
drop(tileEl('s-d'));
|
||||
end('s-a');
|
||||
|
||||
expect(app._tileGrid.ids).toEqual(['s-d', 's-b', 's-c', 's-a']);
|
||||
expect([tileEl('s-a').style.gridColumn, tileEl('s-a').style.gridRow]).toEqual(['3', '3']);
|
||||
expect([tileEl('s-d').style.gridColumn, tileEl('s-d').style.gridRow]).toEqual(['1', '1']);
|
||||
costsNothing();
|
||||
expect(app._tilesOwnTerminal()).toBe(true);
|
||||
expect([...app._tileGrid.tiles.keys()].sort()).toEqual(FOUR);
|
||||
// A drag is a human action: the dropped tile takes focus, as a tab drop does.
|
||||
expect(app.activeSessionId).toBe('s-a');
|
||||
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-a');
|
||||
// What the drag painted is gone.
|
||||
for (const id of FOUR) {
|
||||
expect(tileEl(id).classList.contains('tile--dragging')).toBe(false);
|
||||
expect(tileEl(id).classList.contains('tile--drop-target')).toBe(false);
|
||||
}
|
||||
expect(app._draggedTileId).toBeNull();
|
||||
});
|
||||
|
||||
it('the new order is stored (ids only) and comes back on a reload', () => {
|
||||
const app = openGrid(FOUR);
|
||||
dragTileOnto('s-a', tileEl('s-c'));
|
||||
expect(stored().ids).toEqual(['s-c', 's-b', 's-a', 's-d']);
|
||||
expect(stored().focused).toBe('s-a');
|
||||
expect(stored().open).toBe(true);
|
||||
|
||||
// A reload: a fresh page on the same device restores the stored grid.
|
||||
app._tileGrid.open = false;
|
||||
section.children = [];
|
||||
const reloaded = makeGridApp(FOUR);
|
||||
expect(reloaded._restoreTileGrid()).toBe(true);
|
||||
expect(reloaded._tileGrid.ids).toEqual(['s-c', 's-b', 's-a', 's-d']);
|
||||
expect(reloaded.activeSessionId).toBe('s-a');
|
||||
});
|
||||
|
||||
describe('onto an empty slot', () => {
|
||||
// Under 1800px wide three tiles take a 2x2 with one empty slot, five a 3x2.
|
||||
beforeEach(() => {
|
||||
section.getBoundingClientRect = () => ({ width: 1700, height: 1000, top: 0, left: 0, right: 1700, bottom: 1000 });
|
||||
});
|
||||
afterEach(() => {
|
||||
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
|
||||
});
|
||||
|
||||
it('it moves there, the last cell (the tiles after it close up); the slot count stays', () => {
|
||||
const app = openGrid(['s-a', 's-b', 's-c']);
|
||||
const costsNothing = snapshotCost();
|
||||
expect(slots()).toHaveLength(1);
|
||||
dragTileOnto('s-a', slots()[0]);
|
||||
expect(app._tileGrid.ids).toEqual(['s-b', 's-c', 's-a']);
|
||||
expect([tileEl('s-a').style.gridColumn, tileEl('s-a').style.gridRow]).toEqual(['1', '3']);
|
||||
expect(slots()).toHaveLength(1);
|
||||
expect(slots()[0].classList.contains('tile--drop-target')).toBe(false);
|
||||
expect(app.activeSessionId).toBe('s-a');
|
||||
costsNothing();
|
||||
expect(stored().ids).toEqual(['s-b', 's-c', 's-a']);
|
||||
});
|
||||
});
|
||||
|
||||
it('Escape, or a drop anywhere else, cancels: dragend with no drop moves nothing and clears what was painted', () => {
|
||||
const app = openGrid(FOUR);
|
||||
localStore.delete('codeman:tile-grid');
|
||||
const costsNothing = snapshotCost();
|
||||
clearFits();
|
||||
startDrag('s-a');
|
||||
over(tileEl('s-c'));
|
||||
// The browser cancels (Escape) or the pointer is released outside every
|
||||
// target: no drop, and not always a dragleave either.
|
||||
end('s-a');
|
||||
expect(app._tileGrid.ids).toEqual(FOUR);
|
||||
expect(tileEl('s-c').classList.contains('tile--drop-target')).toBe(false);
|
||||
expect(tileEl('s-a').classList.contains('tile--dragging')).toBe(false);
|
||||
expect(app._draggedTileId).toBeNull();
|
||||
expect(localStore.has('codeman:tile-grid')).toBe(false);
|
||||
expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true);
|
||||
costsNothing();
|
||||
// The drag is over: a later drag that is neither a tab nor a tile (a file) is left alone.
|
||||
expect(over(tileEl('s-c')).preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('carries a type of its own, never text, and is not a tab drag (the strip ignores it)', () => {
|
||||
const app = openGrid(FOUR);
|
||||
const start = startDrag('s-b');
|
||||
expect(start.dataTransfer.setData).toHaveBeenCalledWith(TILE_TYPE, 's-b');
|
||||
expect(start.dataTransfer.setData.mock.calls.every(([type]) => type === TILE_TYPE)).toBe(true);
|
||||
expect(start.dataTransfer.effectAllowed).toBe('move');
|
||||
expect(app.draggedTabId).toBeFalsy();
|
||||
end('s-b');
|
||||
});
|
||||
|
||||
it('never reaches xterm: tiles and slots hold the drag in the capture phase and stop it', () => {
|
||||
openGrid(FOUR);
|
||||
for (const id of FOUR) {
|
||||
expect(tileEl(id).captureFlags.dragover).toEqual([true]);
|
||||
expect(tileEl(id).captureFlags.drop).toEqual([true]);
|
||||
}
|
||||
startDrag('s-a');
|
||||
const o = over(tileEl('s-b'));
|
||||
const d = drop(tileEl('s-b'));
|
||||
for (const e of [o, d]) {
|
||||
expect(e.preventDefault).toHaveBeenCalled();
|
||||
expect(e.stopPropagation).toHaveBeenCalled();
|
||||
}
|
||||
end('s-a');
|
||||
});
|
||||
|
||||
it('over its own tile the drag is held there too (never reaching its xterm) but refused', () => {
|
||||
const app = openGrid(FOUR);
|
||||
startDrag('s-a');
|
||||
const o = over(tileEl('s-a'));
|
||||
expect(o.preventDefault).toHaveBeenCalled();
|
||||
expect(o.stopPropagation).toHaveBeenCalled();
|
||||
expect(o.dataTransfer.dropEffect).toBe('none');
|
||||
expect(tileEl('s-a').classList.contains('tile--drop-target')).toBe(false);
|
||||
drop(tileEl('s-a'));
|
||||
end('s-a');
|
||||
expect(app._tileGrid.ids).toEqual(FOUR);
|
||||
});
|
||||
|
||||
it('a tile removed mid-drag, or the grid closed, ends the drag', () => {
|
||||
const app = openGrid(FOUR);
|
||||
startDrag('s-a');
|
||||
app.removeTile('s-a');
|
||||
expect(app._draggedTileId).toBeNull();
|
||||
expect(over(tileEl('s-b')).preventDefault).not.toHaveBeenCalled();
|
||||
|
||||
startDrag('s-b');
|
||||
over(tileEl('s-c'));
|
||||
app.selectSession = vi.fn();
|
||||
app.closeTileGrid();
|
||||
expect(app._draggedTileId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the handle', () => {
|
||||
it('is the header: draggable, with a tooltip that says so under the state', () => {
|
||||
const app = openGrid(FOUR);
|
||||
app._sidebarRichRow = () => ({ state: 'idle', pill: 'idle', since: { at: 1 } });
|
||||
app._mobileOverviewStampText = () => '3m';
|
||||
app._renderTileChrome();
|
||||
expect(headerOf('s-a').getAttribute('draggable')).toBe('true');
|
||||
expect(headerOf('s-a').title).toBe('idle 3m\nDrag to move the tile');
|
||||
});
|
||||
|
||||
it('a press on a header button starts no drag (and a press on the free area after it does)', () => {
|
||||
const app = openGrid(FOUR);
|
||||
for (const button of actionsOf('s-a').children) {
|
||||
const start = startDrag('s-a', button);
|
||||
expect(start.preventDefault).toHaveBeenCalled();
|
||||
expect(app._draggedTileId).toBeFalsy();
|
||||
expect(tileEl('s-a').classList.contains('tile--dragging')).toBe(false);
|
||||
}
|
||||
const start = startDrag('s-a', nameOf('s-a'));
|
||||
expect(start.preventDefault).not.toHaveBeenCalled();
|
||||
expect(app._draggedTileId).toBe('s-a');
|
||||
end('s-a');
|
||||
});
|
||||
|
||||
it('a double-click on the name still renames, and no drag starts while the input is there', () => {
|
||||
const app = openGrid(FOUR);
|
||||
app._queueInlineSessionName = vi.fn();
|
||||
nameOf('s-a').dispatch('dblclick', { stopPropagation: vi.fn() });
|
||||
const entry = app._tileGrid.tiles.get('s-a');
|
||||
expect(entry.renaming).toBe(true);
|
||||
expect(headerOf('s-a').getAttribute('draggable')).toBe('false');
|
||||
const input = headerOf('s-a').children[2].children[0];
|
||||
expect(input.className).toBe('tile-rename-input');
|
||||
// A press in the input, then a would-be drag.
|
||||
expect(startDrag('s-a', input).preventDefault).toHaveBeenCalled();
|
||||
expect(app._draggedTileId).toBeFalsy();
|
||||
input.dispatch('keydown', { key: 'Escape', preventDefault: vi.fn() });
|
||||
expect(entry.renaming).toBe(false);
|
||||
expect(headerOf('s-a').getAttribute('draggable')).toBe('true');
|
||||
});
|
||||
|
||||
it('a single tile has nowhere to go: not draggable, no drag hint', () => {
|
||||
const app = openGrid(['s-a']);
|
||||
app._sidebarRichRow = () => ({ state: 'idle', pill: 'idle', since: { at: 1 } });
|
||||
app._mobileOverviewStampText = () => '3m';
|
||||
app._renderTileChrome();
|
||||
expect(headerOf('s-a').getAttribute('draggable')).toBe('false');
|
||||
expect(headerOf('s-a').title).toBe('idle 3m');
|
||||
expect(startDrag('s-a').preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('while a tile is zoomed, moving is off', () => {
|
||||
it('no header drags (draggable off, no hint), and a drag that starts anyway is refused', () => {
|
||||
const app = openGrid(FOUR);
|
||||
app._sidebarRichRow = () => ({ state: 'idle', pill: 'idle', since: { at: 1 } });
|
||||
app._mobileOverviewStampText = () => '3m';
|
||||
app._renderTileChrome();
|
||||
app.zoomTile('s-a');
|
||||
for (const id of FOUR) {
|
||||
expect(headerOf(id).getAttribute('draggable')).toBe('false');
|
||||
expect(headerOf(id).title).toBe('idle 3m');
|
||||
}
|
||||
expect(startDrag('s-a').preventDefault).toHaveBeenCalled();
|
||||
expect(app._draggedTileId).toBeFalsy();
|
||||
|
||||
app.zoomTile('s-a');
|
||||
expect(headerOf('s-a').getAttribute('draggable')).toBe('true');
|
||||
});
|
||||
|
||||
it('a tab of a tiled session dropped on the zoomed tile moves nothing either', () => {
|
||||
const app = openGrid(FOUR);
|
||||
app.zoomTile('s-a');
|
||||
app.draggedTabId = 's-c';
|
||||
over(tileEl('s-a'));
|
||||
drop(tileEl('s-a'));
|
||||
expect(app._tileGrid.ids).toEqual(FOUR);
|
||||
expect(app._tileGrid.zoomedId).toBe('s-a');
|
||||
});
|
||||
|
||||
it('an automatic zoom (the window too small for the tiles) counts too', () => {
|
||||
const app = openGrid(FOUR);
|
||||
section.getBoundingClientRect = () => ({ width: 700, height: 400, top: 0, left: 0, right: 700, bottom: 400 });
|
||||
app._applyTileLayout();
|
||||
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
|
||||
expect(app._tileGrid.autoZoom).toBe(true);
|
||||
expect(headerOf('s-b').getAttribute('draggable')).toBe('false');
|
||||
expect(app._swapTiles('s-a', 's-b')).toBe(false);
|
||||
expect(app._tileGrid.ids).toEqual(FOUR);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sizes belong to the cells: only a tile whose size changed fits, once', () => {
|
||||
it('equal cells: a swap fits nothing (no PTY resize anywhere)', () => {
|
||||
const app = openGrid(SIX);
|
||||
clearFits();
|
||||
dragTileOnto('s-a', tileEl('s-f'));
|
||||
expect(app._tileGrid.ids).toEqual(['s-f', 's-b', 's-c', 's-d', 's-e', 's-a']);
|
||||
expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('a wider first column: the two swapped tiles fit once each, the other four not at all', () => {
|
||||
const app = openGrid(SIX);
|
||||
app._tileGrid.colFr = [2, 1, 1];
|
||||
app._applyTileLayout();
|
||||
clearFits();
|
||||
dragTileOnto('s-a', tileEl('s-b'));
|
||||
expect(fitCounts(SIX)).toEqual({ 's-a': 1, 's-b': 1, 's-c': 0, 's-d': 0, 's-e': 0, 's-f': 0 });
|
||||
// The fractions stay with the columns.
|
||||
expect(app._tileGrid.colFr).toEqual([2, 1, 1]);
|
||||
});
|
||||
|
||||
it('a swap between two cells of the same size fits nothing, whatever the other sizes', () => {
|
||||
const app = openGrid(SIX);
|
||||
app._tileGrid.colFr = [2, 1, 1];
|
||||
app._tileGrid.rowFr = [1, 3];
|
||||
app._applyTileLayout();
|
||||
clearFits();
|
||||
// s-c (column 2, row 0) and s-b (column 1, row 0): both 1 x 1.
|
||||
dragTileOnto('s-c', tileEl('s-b'));
|
||||
expect(FakeTile.all.every((t) => t.fit.mock.calls.length === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('a move to the slot: every tile whose cell size changed fits once, the others not', () => {
|
||||
section.getBoundingClientRect = () => ({ width: 1700, height: 1000, top: 0, left: 0, right: 1700, bottom: 1000 });
|
||||
try {
|
||||
const five = SIX.slice(0, 5);
|
||||
const app = openGrid(five);
|
||||
app._tileGrid.colFr = [2, 1, 1];
|
||||
app._applyTileLayout();
|
||||
clearFits();
|
||||
dragTileOnto('s-a', slots()[0]);
|
||||
// [a b c / d e _] -> [b c d / e a _]: b (2 -> 1 wide), d (row 1 col 0 -> row 0 col 2: 2 -> 1),
|
||||
// e (1 -> 2) and a (2 -> 1) changed size; c (1 -> 1) did not.
|
||||
expect(app._tileGrid.ids).toEqual(['s-b', 's-c', 's-d', 's-e', 's-a']);
|
||||
expect(fitCounts(five)).toEqual({ 's-a': 1, 's-b': 1, 's-c': 0, 's-d': 1, 's-e': 1 });
|
||||
} finally {
|
||||
delete (section as unknown as Record<string, unknown>).getBoundingClientRect;
|
||||
}
|
||||
});
|
||||
|
||||
it('no debounced refit of every tile follows a move', async () => {
|
||||
const app = openGrid(SIX);
|
||||
app._tileGrid.colFr = [2, 1, 1];
|
||||
app._applyTileLayout();
|
||||
clearFits();
|
||||
dragTileOnto('s-a', tileEl('s-b'));
|
||||
// Past TILE_GRID_REFIT_MS (150ms): still only the two moved tiles.
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
expect(fitCounts(SIX)).toEqual({ 's-a': 1, 's-b': 1, 's-c': 0, 's-d': 0, 's-e': 0, 's-f': 0 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user