mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
feat(tiles): move tiles by hand, and detach a tile into its own window
Gesture control now moves tiles: with the tile grid open, pinch anywhere on a tile and carry it onto another tile (they swap) or an empty cell (it moves there); a session tab carried onto the grid joins it there. The overlay asks tile-grid.js what is under the hand (tileAtPoint, tileDropTargetAt) and drops through the mouse drag's own paths (dropSessionOnTile / dropSessionOnSlot, so _reorderTiles stays the one move path). Detach Tiles (App Settings > Appearance, per device, OFF by default, desktop only): - a tile's header let go outside the browser window opens the session as a pop-out (detachSession, which now takes an optional size and screen position), the tile's size, near the drop point, after a 120 ms settle; - dropped on another Codeman window's tiles it moves there: that window docks it through the target's own drop and posts 'tile-adopted' on the window channel, and only the window it was dragged from lets go of its copy (a last tile goes to the welcome screen, never onto that session); - a pop-out's title drags back onto any grid: the session is taken back, the pop-out is asked to close and is released (stops answering roll-calls; one a script cannot close says where the session went), and its stale 'detached' answers are ignored for 2 s; - a tile's menu offers "Open in a new window", and a hand that lets a tile go outside the grid pops it out too. With the setting off nothing changes. No server change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3d59e59e37
commit
2c55fe907e
16 files changed
+1469
-39
No files matched your search
+71
-4
@@ -1667,8 +1667,12 @@ class CodemanApp {
|
||||
* side-effect-light. Calling it again for an already-open window just raises
|
||||
* that window.
|
||||
* @param {string} id session id
|
||||
* @param {{width?: number, height?: number, left?: number, top?: number}} [placement]
|
||||
* the new window's size and screen position (a detached tile opens its own
|
||||
* size, where it was let go); omitted, the default 960x680 wherever the
|
||||
* browser puts it. A host window ignores it.
|
||||
*/
|
||||
detachSession(id) {
|
||||
detachSession(id, placement = null) {
|
||||
if (this.isSoloWindow) return; // a solo window can't spawn more
|
||||
if (!this.sessions.has(id)) return;
|
||||
// Already detached → raise the existing popup instead of opening (or
|
||||
@@ -1696,7 +1700,7 @@ class CodemanApp {
|
||||
this._postWindowMessage({ type: 'detached', id });
|
||||
return;
|
||||
}
|
||||
const features = 'width=960,height=680,menubar=no,toolbar=no,location=no,status=no';
|
||||
const features = this._detachWindowFeatures(placement);
|
||||
let win = null;
|
||||
try { win = window.open(CodemanBase.url('/session/' + encodeURIComponent(id)), 'codeman-session-' + id, features); } catch {}
|
||||
if (!win) {
|
||||
@@ -1710,6 +1714,21 @@ class CodemanApp {
|
||||
try { win.focus(); } catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* The window.open features of a pop-out: `placement`'s size (default
|
||||
* 960x680) and, when it has one, its screen position. Only finite numbers
|
||||
* make it into the string.
|
||||
*/
|
||||
_detachWindowFeatures(placement) {
|
||||
const int = (v) => (Number.isFinite(v) ? Math.round(v) : null);
|
||||
const width = int(placement?.width) ?? 960;
|
||||
const height = int(placement?.height) ?? 680;
|
||||
const left = int(placement?.left);
|
||||
const top = int(placement?.top);
|
||||
const at = left !== null && top !== null ? `,left=${left},top=${top}` : '';
|
||||
return `width=${width},height=${height}${at},menubar=no,toolbar=no,location=no,status=no`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The embedding app's window opener, when there is one. A native wrapper
|
||||
* exposes `window.CodemanHost.openWindow(absoluteUrl)` (anything but false
|
||||
@@ -1878,11 +1897,15 @@ class CodemanApp {
|
||||
if (!msg || typeof msg !== 'object') return;
|
||||
if (this.isSoloWindow) {
|
||||
// Roll-call has no id (broadcast to all) — answer before the id filter.
|
||||
if (msg.type === 'roll-call') { this._postWindowMessage({ type: 'detached', id: this.soloSessionId }); return; }
|
||||
// A window that has handed its session back stays silent.
|
||||
if (msg.type === 'roll-call') {
|
||||
if (!this._soloReleased) this._postWindowMessage({ type: 'detached', id: this.soloSessionId });
|
||||
return;
|
||||
}
|
||||
if (msg.id !== this.soloSessionId) return;
|
||||
// A host window ignores window.close()/focus() from script it did not
|
||||
// open by window.open, so ask the host when it offers the call.
|
||||
if (msg.type === 'close-request') { this._closeSoloWindow(); }
|
||||
if (msg.type === 'close-request') { this._releaseSoloWindow(); }
|
||||
else if (msg.type === 'focus-request') {
|
||||
try { if (typeof window.CodemanHost?.focusWindow === 'function') window.CodemanHost.focusWindow(); else window.focus(); } catch {}
|
||||
}
|
||||
@@ -1890,6 +1913,10 @@ class CodemanApp {
|
||||
}
|
||||
// Dashboard side.
|
||||
if (msg.type === 'detached' && msg.id) {
|
||||
// A pop-out this window just docked as a tile (Detach Tiles), answering
|
||||
// a roll-call on its way out: not a new pop-out, which would take the
|
||||
// tile straight back off.
|
||||
if (this._tileDockedRecently?.(msg.id)) return;
|
||||
this._cancelPendingRedock(msg.id); // a re-announce (e.g. popup reload) cancels a deferred redock
|
||||
this._detachPingPending?.delete(msg.id); // and proves liveness for this tick
|
||||
this._detachOrphanStrikes.delete(msg.id); // any answer clears accumulated misses
|
||||
@@ -1899,6 +1926,9 @@ class CodemanApp {
|
||||
} else if (msg.type === 'detach-request' && msg.id) {
|
||||
// Future gesture hook: another window asks the dashboard to detach a tab.
|
||||
this.detachSession(msg.id);
|
||||
} else if (msg.type === 'tile-adopted' && msg.id && msg.by !== this._wsTabNonce) {
|
||||
// Another window docked a tile dragged out of this one (tile-grid.js).
|
||||
this._onTileAdoptedElsewhere?.(msg.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1945,6 +1975,41 @@ class CodemanApp {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Solo window: a dashboard took the session back (its re-dock, or a
|
||||
* dashboard docked it as a tile). Says so at once and stops answering
|
||||
* roll-calls, then closes. A window the browser will not let a script
|
||||
* close (one opened by typing its URL) says where the session went
|
||||
* instead, rather than staying a live pop-out that every dashboard would
|
||||
* mark detached again at its next roll-call.
|
||||
*/
|
||||
_releaseSoloWindow() {
|
||||
if (this._soloReleased) return;
|
||||
this._soloReleased = true;
|
||||
this._postWindowMessage({ type: 'redocked', id: this.soloSessionId });
|
||||
this._closeSoloWindow();
|
||||
setTimeout(() => {
|
||||
if (!window.closed) this._showSoloReleased();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/** Solo window: the session went back to a dashboard and this window could not close. */
|
||||
_showSoloReleased() {
|
||||
if (document.querySelector('.solo-gone-overlay')) return;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'solo-gone-overlay';
|
||||
const title = document.createElement('h2');
|
||||
title.textContent = 'Session moved';
|
||||
const text = document.createElement('p');
|
||||
text.textContent = 'This session is back in a Codeman window. This one can be closed.';
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn-primary';
|
||||
btn.textContent = 'Close window';
|
||||
btn.addEventListener('click', () => this._closeSoloWindow());
|
||||
el.append(title, text, btn);
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
|
||||
/** Solo window: select the target session and apply minimal single-session
|
||||
* chrome. Called from handleInit once the session list has loaded. */
|
||||
_applySoloMode() {
|
||||
@@ -1960,6 +2025,8 @@ class CodemanApp {
|
||||
if (titleEl) { titleEl.textContent = name; titleEl.style.display = ''; }
|
||||
const redock = document.getElementById('soloRedockBtn');
|
||||
if (redock) redock.style.display = '';
|
||||
// Detach Tiles: the title drags onto a dashboard's tiles to dock there.
|
||||
this._installSoloTileHandle?.();
|
||||
document.title = name + ' — ' + (window.CodemanI18n?.displayName || 'Codeman');
|
||||
if (this.notificationManager) this.notificationManager.originalTitle = document.title;
|
||||
// Neutralize the dashboard-only brand click in a solo window.
|
||||
|
||||
@@ -4448,6 +4448,7 @@ var GestureController = class {
|
||||
|
||||
// packages/gesture-control/src/codeman/entry.ts
|
||||
var TAB_SELECTOR = ".session-tab";
|
||||
var TILE_SELECTOR = "#tileGrid .tile";
|
||||
var PANEL_SELECTOR = ".cg-float";
|
||||
var WINDOW_SELECTOR = ".subagent-window, .ultracode-window";
|
||||
var DOCK_SELECTOR = ".session-tabs";
|
||||
@@ -4457,6 +4458,8 @@ var FLOAT_W = 640;
|
||||
var FLOAT_H = 420;
|
||||
var DETACH_PULL_PX = 70;
|
||||
var TAP_CANCEL_PX = 45;
|
||||
var TILE_MOVE_PX = 40;
|
||||
var TILE_GHOST_W = 280;
|
||||
var handColor = (handedness, pinching) => pinching ? "#4ade80" : handedness === "Right" ? "#a78bfa" : "#38bdf8";
|
||||
var GestureBridge = class {
|
||||
constructor() {
|
||||
@@ -4533,7 +4536,7 @@ var GestureBridge = class {
|
||||
await this.gc.start();
|
||||
this.running = true;
|
||||
this.button.classList.add("on");
|
||||
this.status.textContent = "on \u2014 pinch a tab, window, or button";
|
||||
this.status.textContent = "on: pinch a tab, tile, window, or button";
|
||||
} catch (err) {
|
||||
const msg = describeError(err);
|
||||
this.status.textContent = `failed: ${msg}`;
|
||||
@@ -4588,6 +4591,30 @@ var GestureBridge = class {
|
||||
this.status.textContent = "moving window";
|
||||
return;
|
||||
}
|
||||
const tileId = window.app?.tileAtPoint?.(x2, y2) ?? null;
|
||||
const tileEl = tileId ? this.hitClosest(x2, y2, TILE_SELECTOR) : null;
|
||||
if (tileId && tileEl && window.app?.canGrabTile?.(tileId)) {
|
||||
const rect = tileEl.getBoundingClientRect();
|
||||
const ghost = this.tileGhost(tileEl, rect);
|
||||
document.body.append(ghost);
|
||||
tileEl.classList.add("tile--dragging");
|
||||
this.grabs.set(hand, {
|
||||
kind: "tile",
|
||||
id: tileId,
|
||||
el: tileEl,
|
||||
ghost,
|
||||
offsetX: Math.max(0, Math.min(x2 - rect.left, rect.width)),
|
||||
offsetY: Math.max(0, Math.min(y2 - rect.top, rect.height)),
|
||||
ox: x2,
|
||||
oy: y2,
|
||||
armed: false,
|
||||
target: null,
|
||||
out: false
|
||||
});
|
||||
this.positionGhost(ghost, x2, y2);
|
||||
this.status.textContent = "moving tile";
|
||||
return;
|
||||
}
|
||||
const tab = this.hitClosest(x2, y2, TAB_SELECTOR);
|
||||
const id = tab?.dataset.id;
|
||||
if (tab && id) {
|
||||
@@ -4599,7 +4626,7 @@ var GestureBridge = class {
|
||||
ghost.style.height = `${rect.height}px`;
|
||||
document.body.append(ghost);
|
||||
tab.classList.add("cg-grabbed");
|
||||
this.grabs.set(hand, { kind: "tab", id, tab, ghost, ox: x2, oy: y2, armed: false });
|
||||
this.grabs.set(hand, { kind: "tab", id, tab, ghost, ox: x2, oy: y2, armed: false, target: null });
|
||||
this.positionGhost(ghost, x2, y2);
|
||||
return;
|
||||
}
|
||||
@@ -4613,13 +4640,30 @@ var GestureBridge = class {
|
||||
}
|
||||
onDrag(hand, x2, y2) {
|
||||
const grab = this.grabs.get(hand);
|
||||
if (grab?.kind === "tile") {
|
||||
this.positionGhost(grab.ghost, x2, y2);
|
||||
if (!grab.armed && Math.hypot(x2 - grab.ox, y2 - grab.oy) >= TILE_MOVE_PX) grab.armed = true;
|
||||
if (!grab.armed) return;
|
||||
const app = window.app;
|
||||
const target = app?.tileDropTargetAt?.(x2, y2, grab.id) ?? null;
|
||||
this.setTileTarget(grab, target);
|
||||
const out = !target && !!app?.tileDetachEnabled?.() && !app?.isOverTileGrid?.(x2, y2);
|
||||
grab.out = out;
|
||||
grab.ghost.classList.toggle("cg-armed", !!target);
|
||||
grab.ghost.classList.toggle("cg-out", out);
|
||||
this.status.textContent = target ? target.kind === "tile" ? "release to swap tiles" : "release to move the tile here" : out ? "release to open in a new window" : "moving tile";
|
||||
return;
|
||||
}
|
||||
if (grab?.kind === "tab") {
|
||||
this.positionGhost(grab.ghost, x2, y2);
|
||||
const pulled = Math.hypot(x2 - grab.ox, y2 - grab.oy) >= DETACH_PULL_PX;
|
||||
if (pulled !== grab.armed) {
|
||||
const target = pulled ? window.app?.tileDropTargetAt?.(x2, y2, grab.id) ?? null : null;
|
||||
const targetChanged = (target?.el ?? null) !== (grab.target?.el ?? null);
|
||||
this.setTileTarget(grab, target);
|
||||
if (pulled !== grab.armed || targetChanged) {
|
||||
grab.armed = pulled;
|
||||
grab.ghost.classList.toggle("cg-armed", pulled);
|
||||
this.status.textContent = pulled ? "release to float out" : "on \u2014 pinch a tab";
|
||||
this.status.textContent = target ? "release to tile it here" : pulled ? "release to float out" : "on \u2014 pinch a tab";
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -4641,16 +4685,40 @@ var GestureBridge = class {
|
||||
if (tap && Math.hypot(x2 - tap.ox, y2 - tap.oy) > TAP_CANCEL_PX) {
|
||||
tap.el.classList.remove("cg-tap-armed");
|
||||
this.taps.delete(hand);
|
||||
this.status.textContent = "on \u2014 pinch a tab, window, or button";
|
||||
this.status.textContent = "on: pinch a tab, tile, window, or button";
|
||||
}
|
||||
}
|
||||
onDrop(hand, x2, y2) {
|
||||
const grab = this.grabs.get(hand);
|
||||
if (grab?.kind === "tile") {
|
||||
this.grabs.delete(hand);
|
||||
grab.ghost.remove();
|
||||
grab.el.classList.remove("tile--dragging");
|
||||
this.setTileTarget(grab, null);
|
||||
const app = window.app;
|
||||
if (!grab.armed) {
|
||||
this.flash("cancelled");
|
||||
return;
|
||||
}
|
||||
const target = app?.tileDropTargetAt?.(x2, y2, grab.id) ?? null;
|
||||
if (target) {
|
||||
this.flash(app?.dropOnTileTarget?.(grab.id, target) ? "moved tile" : "cancelled");
|
||||
} else if (grab.out && !app?.isOverTileGrid?.(x2, y2)) {
|
||||
const opened = app?.detachTileAtPoint?.(grab.id, x2, y2, { offsetX: grab.offsetX, offsetY: grab.offsetY });
|
||||
this.flash(opened ? "opened in a new window" : "pop-out blocked: allow popups for this site");
|
||||
} else {
|
||||
this.flash("cancelled");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (grab?.kind === "tab") {
|
||||
this.grabs.delete(hand);
|
||||
grab.ghost.remove();
|
||||
grab.tab.classList.remove("cg-grabbed");
|
||||
if (grab.armed) this.floatPanel(grab.id, x2, y2);
|
||||
this.setTileTarget(grab, null);
|
||||
const target = grab.armed ? window.app?.tileDropTargetAt?.(x2, y2, grab.id) ?? null : null;
|
||||
if (target) this.flash(window.app?.dropOnTileTarget?.(grab.id, target) ? "tiled" : "cancelled");
|
||||
else if (grab.armed) this.floatPanel(grab.id, x2, y2);
|
||||
else this.flash("cancelled");
|
||||
return;
|
||||
}
|
||||
@@ -4775,11 +4843,41 @@ var GestureBridge = class {
|
||||
ghost.style.left = `${x2}px`;
|
||||
ghost.style.top = `${y2}px`;
|
||||
}
|
||||
/** A small copy of a tile to follow the hand: its header (a copy: no
|
||||
* listeners come along) over an empty body, in the tile's proportions. */
|
||||
tileGhost(tileEl, rect) {
|
||||
const ghost = el("div", "cg-ghost cg-tile-ghost");
|
||||
const width = Math.min(TILE_GHOST_W, rect.width);
|
||||
ghost.style.width = `${width}px`;
|
||||
ghost.style.height = `${Math.max(48, Math.round(width * rect.height / Math.max(1, rect.width)))}px`;
|
||||
const header = tileEl.querySelector(".tile-header");
|
||||
if (header) {
|
||||
const copy = header.cloneNode(true);
|
||||
copy.removeAttribute("draggable");
|
||||
ghost.append(copy);
|
||||
}
|
||||
ghost.append(el("div", "cg-tile-ghost-body"));
|
||||
return ghost;
|
||||
}
|
||||
/** Highlights the tile or empty cell a carried session would drop onto
|
||||
* (the mouse drag's own `tile--drop-target`), clearing the last one. */
|
||||
setTileTarget(grab, target) {
|
||||
if (grab.target?.el !== target?.el) {
|
||||
grab.target?.el.classList.remove("tile--drop-target");
|
||||
target?.el.classList.add("tile--drop-target");
|
||||
}
|
||||
grab.target = target;
|
||||
}
|
||||
cancelAllGrabs() {
|
||||
for (const grab of this.grabs.values()) {
|
||||
if (grab.kind === "tab") {
|
||||
grab.ghost.remove();
|
||||
grab.tab.classList.remove("cg-grabbed");
|
||||
this.setTileTarget(grab, null);
|
||||
} else if (grab.kind === "tile") {
|
||||
grab.ghost.remove();
|
||||
grab.el.classList.remove("tile--dragging");
|
||||
this.setTileTarget(grab, null);
|
||||
} else if (grab.kind === "panel") {
|
||||
grab.panel.el.style.pointerEvents = "";
|
||||
grab.panel.el.classList.remove("cg-float-grabbed", "cg-redock");
|
||||
@@ -4791,6 +4889,7 @@ var GestureBridge = class {
|
||||
for (const tap of this.taps.values()) tap.el.classList.remove("cg-tap-armed");
|
||||
this.taps.clear();
|
||||
document.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed, .cg-win-grabbed`).forEach((t2) => t2.classList.remove("cg-grabbed", "cg-tap-armed", "cg-win-grabbed"));
|
||||
document.querySelectorAll(`${TILE_SELECTOR}.tile--dragging`).forEach((t2) => t2.classList.remove("tile--dragging"));
|
||||
}
|
||||
onStatus(fps, hands) {
|
||||
const { width, height } = this.canvas;
|
||||
@@ -4863,6 +4962,13 @@ function injectStyles() {
|
||||
outline: 2px solid #38bdf8; outline-offset: -2px;
|
||||
}
|
||||
.cg-ghost.cg-armed { outline-color: #4ade80; box-shadow: 0 8px 28px rgba(74,222,128,.5); }
|
||||
.cg-ghost.cg-out { outline-color: #fbbf24; box-shadow: 0 8px 28px rgba(251,191,36,.5); }
|
||||
.cg-tile-ghost {
|
||||
display: flex; flex-direction: column; overflow: hidden; transform: translate(-50%, -20%) scale(1);
|
||||
background: var(--term-bg, #161b23); border: 1px solid var(--border-color, #333);
|
||||
}
|
||||
.cg-tile-ghost > .tile-header { flex: 0 0 auto; }
|
||||
.cg-tile-ghost-body { flex: 1 1 auto; opacity: .5; }
|
||||
.cg-dock {
|
||||
position: fixed; right: 12px; bottom: 156px; z-index: ${Z2 + 3};
|
||||
display: flex; align-items: center; gap: 8px; font: 12px/1 system-ui, sans-serif;
|
||||
|
||||
+15
-3
@@ -139,6 +139,8 @@
|
||||
Drag: '拖动',
|
||||
"a tile's header": '窗格的标题栏',
|
||||
'Move the Tile (onto Another: Swap)': '移动窗格(拖到另一个窗格上:互换位置)',
|
||||
"a tile's header out of the window": '窗格的标题栏到窗口之外',
|
||||
'Open the Tile in Its Own Window (Detach Tiles)': '在独立窗口中打开窗格(分离窗格)',
|
||||
'Zoom Focused Tile': '放大聚焦的窗格',
|
||||
'Remove Focused Tile': '移除聚焦的窗格',
|
||||
'Add the Session to the Tile Grid': '将该会话加入平铺网格',
|
||||
@@ -166,6 +168,11 @@
|
||||
'Failed to restore terminal size': '恢复终端尺寸失败',
|
||||
// A tile header's tooltip while tiles can move (with the state above it: a pattern below).
|
||||
'Drag to move the tile': '拖动可移动窗格',
|
||||
// The same with Detach Tiles on (tile-grid.js _paintTileHandle), and a
|
||||
// pop-out window's title as a handle back onto the tiles.
|
||||
'Drag to move the tile, or out of the window to open it on its own': '拖动可移动窗格,拖出窗口可在独立窗口中打开',
|
||||
'Drag out of the window to open the tile on its own': '拖出窗口可在独立窗口中打开此窗格',
|
||||
"Drag onto a Codeman window's tiles to dock this session there": '拖到 {name} 窗口的窗格上,即可将此会话停靠到那里',
|
||||
'Resize tile columns': '调整窗格列宽',
|
||||
'Resize tile rows': '调整窗格行高',
|
||||
Attach: '附加',
|
||||
@@ -214,6 +221,9 @@
|
||||
'Close window': '关闭窗口',
|
||||
'Session unavailable': '会话不可用',
|
||||
'This session has ended or is no longer available.': '此会话已结束或不再可用。',
|
||||
// A pop-out whose session a dashboard took back, when it cannot close itself (app.js _showSoloReleased).
|
||||
'Session moved': '会话已移走',
|
||||
'This session is back in a Codeman window. This one can be closed.': '此会话已回到 {name} 窗口中。可以关闭此窗口。',
|
||||
|
||||
// Welcome / quick start / common actions
|
||||
'Manage AI Coding tools in persistent tmux sessions.': '在持久化 tmux 会话中管理 AI 编程工具。',
|
||||
@@ -405,6 +415,9 @@
|
||||
'CLI Logos on Tabs': '标签页上的 CLI 图标',
|
||||
"Show each agent's CLI logo before the session name on tabs and the home screen's tab list. Off leaves the status dot and the shell's SH badge. Tiles, split headers and the Run menus keep their logos.":
|
||||
'在标签页和主界面的标签列表中,于会话名称前显示每个智能体的 CLI 图标。关闭后仍保留状态圆点和 Shell 的 SH 标记。平铺、分屏标题栏和运行菜单中的图标不受影响。',
|
||||
'Detach Tiles': '分离窗格',
|
||||
"Drag a tile's header out of the browser to open it in its own window, or onto another Codeman window's tiles to move it there. A popped-out window's title drags back onto tiles.":
|
||||
'将窗格的标题栏拖出浏览器,即可在独立窗口中打开它;拖到另一个 {name} 窗口的窗格上,即可将它移到那里。弹出窗口的标题也可以拖回窗格中。',
|
||||
// Tab Layout and Header Stats Style (Discussion #426). The header style's
|
||||
// "Tiles" is 磁贴, never 平铺: that is the tile grid's word (the Tiles
|
||||
// button), and "Tiles (label over value)" must not read as the grid.
|
||||
@@ -1280,9 +1293,8 @@
|
||||
// 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) =>
|
||||
`${TILE_STATE_ZH[state]}${duration ? ` ${duration}` : ''}\n${ZH_CN['Drag to move the tile']}`,
|
||||
/^(needs you|error|waiting|working|idle|done|exited)(?: (<1m|\d+[dhm](?: \d+[hm])?))?\n(Drag to move the tile|Drag to move the tile, or out of the window to open it on its own|Drag out of the window to open the tile on its own)$/,
|
||||
(_m, state, duration, hint) => `${TILE_STATE_ZH[state]}${duration ? ` ${duration}` : ''}\n${ZH_CN[hint]}`,
|
||||
],
|
||||
];
|
||||
for (const [pattern, replacement] of patterns) {
|
||||
|
||||
@@ -860,6 +860,7 @@
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>Shift</kbd>+<kbd>Arrows</kbd></div><div>Focus Tile Left / Right / Up / Down</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Arrows</kbd></div><div>Move Tile Left / Right / Up / Down</div>
|
||||
<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 out of the window</div><div>Open the Tile in Its Own Window (Detach Tiles)</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>Right-click</kbd> the Tiles button</div><div>Choose How Many Tiles (2, 4 or 6)</div>
|
||||
@@ -2233,6 +2234,13 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowTabDetachButton"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" id="appSettingsTileDetachItem" data-search="detach tiles tile pop out window drag move another window">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Detach Tiles <span class="set-tag">desktop</span> <span class="set-tag set-tag-beta">beta</span></span>
|
||||
<span class="set-row-desc">Drag a tile's header out of the browser to open it in its own window, or onto another Codeman window's tiles to move it there. A popped-out window's title drags back onto tiles.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsTileDetach"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" id="appSettingsLineageLinesItem" data-search="lineage lines spawned worker parent connection">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Spawn Lineage Lines <span class="set-tag">desktop</span></span>
|
||||
|
||||
@@ -510,6 +510,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsShowTabDetachButton').checked =
|
||||
this.tabDetachButtonEnabled?.(settings, defaults)
|
||||
?? (settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false);
|
||||
// Detach Tiles (tile-grid.js): per-device, OFF unless switched on. The tile
|
||||
// grid is desktop-only, so the row is hidden elsewhere, like the lineage lines.
|
||||
document.getElementById('appSettingsTileDetach').checked = settings.tileDetachEnabled === true;
|
||||
const tileDetachItem = document.getElementById('appSettingsTileDetachItem');
|
||||
if (tileDetachItem) tileDetachItem.style.display = MobileDetection.getDeviceType() === 'desktop' ? '' : 'none';
|
||||
document.getElementById('appSettingsSessionListLayout').value =
|
||||
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
|
||||
const sessionSidebarFontSize = this.resolveSessionSidebarFontSize(
|
||||
@@ -2616,6 +2621,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
tabArrangement: document.getElementById('appSettingsTabArrangement').value,
|
||||
tabStateOrder: document.getElementById('appSettingsTabStateOrder').value,
|
||||
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
|
||||
tileDetachEnabled: document.getElementById('appSettingsTileDetach').checked,
|
||||
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
|
||||
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
|
||||
document.getElementById('appSettingsSessionSidebarFontSize').value
|
||||
@@ -2867,6 +2873,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
gitStatusMaxRepos: _gsm,
|
||||
gitStatusTimeoutSeconds: _gst2,
|
||||
showTabDetachButton: _tdb,
|
||||
// Detach Tiles: what a tile drag does is a property of this device's
|
||||
// windows, and the key is absent from the .strict() SettingsUpdateSchema.
|
||||
tileDetachEnabled: _tde,
|
||||
// Phone-only home surface, and absent from SettingsUpdateSchema (.strict()).
|
||||
mobileOverviewEnabled: _mov,
|
||||
// Desktop-only tab decoration, per-device, and likewise absent from the
|
||||
@@ -3902,6 +3911,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Tiles button: same gate and backstop as Split (tile-grid.js).
|
||||
const showTileGridButton = settings.showTileGridButton ?? defaults.showTileGridButton ?? true;
|
||||
this._applyTileGridButtonVisibility?.(showTileGridButton);
|
||||
// Detach Tiles changes what a tile's header does (and says): repaint the handles.
|
||||
this._renderTileChrome?.();
|
||||
|
||||
// Ultracode/Workflow agents launcher — hidden by default; reveal when enabled.
|
||||
// Marker class only (base is display:inline-flex !important) so it's auto-excluded
|
||||
@@ -4358,6 +4369,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'sessionLineageLines',
|
||||
'showSplitButton',
|
||||
'showTileGridButton',
|
||||
'tileDetachEnabled',
|
||||
]);
|
||||
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
|
||||
// handheld default OFF): desktop can show it while mobile stays hidden. Drop
|
||||
|
||||
@@ -2990,6 +2990,14 @@ body.solo-mode .btn-lifecycle-log {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* Detach Tiles: the title is a handle, dragged onto a Codeman window's tiles
|
||||
to dock the session there (tile-grid.js _installSoloTileHandle). */
|
||||
.solo-session-title.solo-session-title--handle {
|
||||
cursor: grab;
|
||||
}
|
||||
.solo-session-title.solo-session-title--handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
/* "Session unavailable" overlay for a solo window whose session has ended. */
|
||||
.solo-gone-overlay {
|
||||
position: fixed;
|
||||
|
||||
@@ -329,10 +329,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
{ label: 'Session options', run: () => this.openSessionOptions(sessionId) },
|
||||
// Group placement (vertical rail with a tab layout only; [] elsewhere).
|
||||
...(this._tabRefMoveActions?.({ kind: 'session', id: sessionId }) || []),
|
||||
...((this.tabDetachButtonEnabled?.(settings) ?? settings.showTabDetachButton) ||
|
||||
this.detachedSessions?.has(sessionId)
|
||||
? [{ label: 'Open in a new window', run: () => this.detachSession(sessionId) }]
|
||||
: []),
|
||||
// A tile (Detach Tiles on) opens its own size, over where it is: the
|
||||
// keyboard's way to what dragging its header out of the window does.
|
||||
...(this._tileGrid?.has?.(sessionId) && this._tileDetachAllowed?.()
|
||||
? [{ label: 'Open in a new window', run: () => this.detachTile(sessionId) }]
|
||||
: (this.tabDetachButtonEnabled?.(settings) ?? settings.showTabDetachButton) ||
|
||||
this.detachedSessions?.has(sessionId)
|
||||
? [{ label: 'Open in a new window', run: () => this.detachSession(sessionId) }]
|
||||
: []),
|
||||
{ label: 'Close session', className: 'danger', run: () => this.requestCloseSession(sessionId) },
|
||||
];
|
||||
for (const action of actions) {
|
||||
|
||||
+420
-14
@@ -56,6 +56,26 @@ const TILE_GRID_REFIT_MS = 150;
|
||||
// the grid section's padding (styles.css .tile-grid), for the drag math.
|
||||
const TILE_DIVIDER_PX = 6;
|
||||
const TILE_GRID_PADDING_PX = 4;
|
||||
// What a tile's header drag (and a popped-out window's title drag) carries:
|
||||
// the session id under a type of its own, never text, so no text field or
|
||||
// terminal takes it as typing. Another Codeman window reads it on drop.
|
||||
const TILE_DRAG_TYPE = 'application/x-codeman-tile';
|
||||
// Detach Tiles: a tile dropped outside every window waits this long before it
|
||||
// opens as a window of its own, so another window that took it (a
|
||||
// 'tile-adopted' message on the window channel) can still say so first.
|
||||
const TILE_TEAR_OFF_SETTLE_MS = 120;
|
||||
// How long after its drag ended a tile can still be claimed by another
|
||||
// window's drop (the claim and the dragend come from two windows).
|
||||
const TILE_ADOPT_WINDOW_MS = 5000;
|
||||
// A session docked here from another window: its old window's 'detached'
|
||||
// announcements this soon after are stale (it is closing), not a new pop-out.
|
||||
const TILE_DOCK_GRACE_MS = 2000;
|
||||
// The smallest window a detached tile opens as, and roughly how much browser
|
||||
// chrome sits above a pop-out's content (its title and address bars), so the
|
||||
// window's own header lands near where the tile was let go.
|
||||
const TILE_WINDOW_MIN_W = 480;
|
||||
const TILE_WINDOW_MIN_H = 320;
|
||||
const TILE_WINDOW_CHROME_PX = 60;
|
||||
|
||||
/** `minmax(0, 1fr) 6px minmax(0, 2fr) ...`: tracks with a divider track between each. */
|
||||
function tileGridTracks(fr) {
|
||||
@@ -1552,8 +1572,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
* `accepts(id)` is the target's own rule (a tile takes any session but its
|
||||
* own; an empty cell takes any). A session it does not accept
|
||||
* is held there too, but refused (`dropEffect: 'none'`, no highlight, so no
|
||||
* drop follows). Any other drag (a file) is left to the grid section's own
|
||||
* guard (_installTileFileDrop).
|
||||
* drop follows). With Detach Tiles on, a tile dragged from ANOTHER Codeman
|
||||
* window (no drag of this page's own, the tile type in the drag) is taken
|
||||
* too and docks here (_dockForeignTile). Any other drag (a file) is left to
|
||||
* the grid section's own guard (_installTileFileDrop).
|
||||
*/
|
||||
_acceptTabDrops(el, onDrop, { accepts = () => true } = {}) {
|
||||
const dragged = () => (this._tileGrid?.open ? this.draggedTabId || this._draggedTileId || null : null);
|
||||
@@ -1561,7 +1583,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
'dragover',
|
||||
(e) => {
|
||||
const id = dragged();
|
||||
if (!id) return;
|
||||
if (!id) {
|
||||
// A tile from another Codeman window (Detach Tiles). Its id can
|
||||
// only be read on drop, so it is held here whatever it is.
|
||||
if (!this._isForeignTileDrag(e)) return;
|
||||
e.preventDefault?.();
|
||||
e.stopPropagation?.();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
||||
el.classList.add('tile--drop-target');
|
||||
return;
|
||||
}
|
||||
e.preventDefault?.();
|
||||
e.stopPropagation?.();
|
||||
const ok = accepts(id);
|
||||
@@ -1578,7 +1609,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
(e) => {
|
||||
el.classList.remove('tile--drop-target');
|
||||
const id = dragged();
|
||||
if (!id) return;
|
||||
if (!id) {
|
||||
if (!this._isForeignTileDrag(e)) return;
|
||||
e.preventDefault?.();
|
||||
e.stopPropagation?.();
|
||||
this._dockForeignTile(e.dataTransfer?.getData?.(TILE_DRAG_TYPE), onDrop);
|
||||
return;
|
||||
}
|
||||
e.preventDefault?.();
|
||||
e.stopPropagation?.();
|
||||
if (accepts(id)) onDrop(id);
|
||||
@@ -2080,7 +2117,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
* 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).
|
||||
* (_paintTileHandle: a zoomed grid, a single tile, a rename in progress),
|
||||
* unless Detach Tiles is on: then a drop outside the window pops the tile
|
||||
* out, and a drop on another Codeman window's tiles moves it there (see the
|
||||
* Detach Tiles section); a rename still turns it off.
|
||||
*/
|
||||
_installTileMoveDrag(sessionId, header, actions) {
|
||||
let pressedOnControl = false;
|
||||
@@ -2094,7 +2134,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
);
|
||||
header.addEventListener('dragstart', (e) => {
|
||||
const entry = this._tileGrid?.open ? this._tileGrid.tiles.get(sessionId) : null;
|
||||
if (!entry || pressedOnControl || entry.renaming || !this._tilesMovable()) {
|
||||
// Detach Tiles lets a tile that cannot move (alone, or zoomed) still be
|
||||
// dragged, out of the window; no drop target here takes it then.
|
||||
const detachable = this._tileDetachAllowed();
|
||||
if (!entry || pressedOnControl || entry.renaming || !(this._tilesMovable() || detachable)) {
|
||||
e.preventDefault?.();
|
||||
return;
|
||||
}
|
||||
@@ -2102,11 +2145,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
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);
|
||||
// Firefox starts no drag without data. Another Codeman window reads
|
||||
// the id from it (Detach Tiles).
|
||||
e.dataTransfer.setData(TILE_DRAG_TYPE, sessionId);
|
||||
}
|
||||
if (detachable) this._beginTileTearOff(sessionId, entry, e);
|
||||
});
|
||||
header.addEventListener('dragend', (e) => {
|
||||
this._settleTileTearOff(sessionId, e);
|
||||
this._endTileMoveDrag();
|
||||
});
|
||||
header.addEventListener('dragend', () => this._endTileMoveDrag());
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -2117,6 +2165,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
*/
|
||||
_endTileMoveDrag() {
|
||||
this._draggedTileId = null;
|
||||
this._stopTileTearOffTracking();
|
||||
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');
|
||||
@@ -2124,19 +2173,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* tile is zoomed, alone, or being renamed) or, with Detach Tiles on, be
|
||||
* dragged out of the window (then also alone or zoomed), and its tooltip,
|
||||
* the state and how long ("working 3m") plus what a drag does. 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');
|
||||
const detachable = this._tileDetachAllowed();
|
||||
let hint = '';
|
||||
if (movable && detachable) hint = 'Drag to move the tile, or out of the window to open it on its own';
|
||||
else if (movable) hint = 'Drag to move the tile';
|
||||
else if (detachable) hint = 'Drag out of the window to open the tile on its own';
|
||||
const title = [entry.stateLabel, hint].filter(Boolean).join('\n');
|
||||
if (entry.headerLabel !== title) {
|
||||
entry.headerLabel = title;
|
||||
entry.header.title = title;
|
||||
}
|
||||
const draggable = movable && !entry.renaming ? 'true' : 'false';
|
||||
const draggable = (movable || detachable) && !entry.renaming ? 'true' : 'false';
|
||||
if (entry.header.getAttribute('draggable') !== draggable) entry.header.setAttribute('draggable', draggable);
|
||||
},
|
||||
|
||||
@@ -2859,6 +2914,357 @@ Object.assign(CodemanApp.prototype, {
|
||||
for (const { tile } of grid.tiles.values()) tile.reconnectNow();
|
||||
return true;
|
||||
},
|
||||
|
||||
// ── Detach Tiles (per-device `tileDetachEnabled`, default OFF) ───────────
|
||||
//
|
||||
// A tile leaves the grid for a window of its own, and comes back into any
|
||||
// Codeman window's grid, by dragging:
|
||||
//
|
||||
// - its header dropped OUTSIDE every window opens it as a pop-out window
|
||||
// (detachSession, the tab's own "Open in a new window"), at the drop point
|
||||
// and the tile's size;
|
||||
// - its header dropped on ANOTHER Codeman window's tile or empty cell moves
|
||||
// it there: that window docks it and says so on the window channel
|
||||
// ('tile-adopted'), and this one lets the tile go;
|
||||
// - a pop-out window's title dropped on a window's tile or empty cell docks
|
||||
// the session there, and the pop-out closes.
|
||||
//
|
||||
// Nothing new on the server: a pop-out is another client of the same session
|
||||
// (see detachSession), and a tile is a TerminalTile in whichever window has it.
|
||||
// A session stays in ONE place in this browser, as with every pop-out.
|
||||
|
||||
/** Whether Detach Tiles is on for this device. */
|
||||
tileDetachEnabled() {
|
||||
return this.loadAppSettingsFromStorage?.()?.tileDetachEnabled === true;
|
||||
},
|
||||
|
||||
/** Detach Tiles applies here: on, and a dashboard (a pop-out has no grid to drag from). */
|
||||
_tileDetachAllowed() {
|
||||
return !this.isSoloWindow && this.tileDetachEnabled();
|
||||
},
|
||||
|
||||
/**
|
||||
* A drag over this page that is a tile from another Codeman window: none of
|
||||
* this page's own drags is in flight, Detach Tiles is on and the drag
|
||||
* carries the tile type (its id is only readable on drop).
|
||||
*/
|
||||
_isForeignTileDrag(e) {
|
||||
if (this.draggedTabId || this._draggedTileId || !this._tileGrid?.open || !this._tileDetachAllowed()) return false;
|
||||
const types = e?.dataTransfer?.types;
|
||||
return !!types && Array.from(types).includes(TILE_DRAG_TYPE);
|
||||
},
|
||||
|
||||
/**
|
||||
* A tile from another window dropped on one of this grid's targets: it docks
|
||||
* through the target's own drop (`place`: a tile it replaces or trades
|
||||
* places with, an empty cell it fills), and takes focus there (a human
|
||||
* selection: the user just put it there). A session that was popped out comes
|
||||
* back first (its window is asked to close). Once it is a tile here, every
|
||||
* other window hears 'tile-adopted', and the one it was dragged from lets
|
||||
* its tile go (_onTileAdoptedElsewhere). An id this page does not know (a
|
||||
* session closed meanwhile, another user's) docks nowhere.
|
||||
*
|
||||
* @returns {boolean} whether the session is a tile here afterwards
|
||||
*/
|
||||
_dockForeignTile(sessionId, place) {
|
||||
const grid = this._tileGrid;
|
||||
if (!grid?.open || typeof sessionId !== 'string' || !this.sessions.has(sessionId)) return false;
|
||||
const poppedOut = !!this.detachedSessions?.has(sessionId);
|
||||
// A pop-out window owns its session's place: take it back before mounting
|
||||
// (a detached session never mounts).
|
||||
if (poppedOut) this._redock?.(sessionId);
|
||||
// Already a tile here too (two windows showed it): it moves to where it
|
||||
// was dropped, as a drag inside this window would move it.
|
||||
place(sessionId);
|
||||
if (!grid.has(sessionId)) return false;
|
||||
(this._tileDockedAt ||= new Map()).set(sessionId, Date.now());
|
||||
if (poppedOut) this._postWindowMessage?.({ type: 'close-request', id: sessionId });
|
||||
this._postWindowMessage?.({ type: 'tile-adopted', id: sessionId, by: this._wsTabNonce });
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* True for a session this window docked from another one a moment ago: a
|
||||
* 'detached' announcement from its old pop-out is that window on its way
|
||||
* out, never a new pop-out (it would take the tile straight back off).
|
||||
*/
|
||||
_tileDockedRecently(sessionId) {
|
||||
const at = this._tileDockedAt?.get(sessionId);
|
||||
return typeof at === 'number' && Date.now() - at < TILE_DOCK_GRACE_MS;
|
||||
},
|
||||
|
||||
/**
|
||||
* Another window docked `sessionId` ('tile-adopted'). Only the window it
|
||||
* was dragged FROM acts: its drag is still settling or ended a moment ago.
|
||||
* The tile goes without taking the keyboard anywhere (the user is in the
|
||||
* other window now). The last tile leaving closes the grid onto the welcome
|
||||
* screen, never onto that session: two windows sizing one PTY garble it.
|
||||
*/
|
||||
_onTileAdoptedElsewhere(sessionId) {
|
||||
const recent = this._lastTileDrag;
|
||||
const dragging = this._tileTearOff?.id === sessionId;
|
||||
const pending = this._tileTearOffPending?.id === sessionId;
|
||||
const justEnded = recent?.id === sessionId && Date.now() - recent.at < TILE_ADOPT_WINDOW_MS;
|
||||
if (!dragging && !pending && !justEnded) return false;
|
||||
this._cancelTileTearOff();
|
||||
this._lastTileDrag = null;
|
||||
const grid = this._tileGrid;
|
||||
if (!grid?.has(sessionId)) return false;
|
||||
if (grid.ids.length > 1) return this.removeTile(sessionId, { focus: false });
|
||||
this.closeTileGrid({ keepStored: false, reselect: false });
|
||||
this.activeSessionId = null;
|
||||
try {
|
||||
localStorage.removeItem('codeman-active-session');
|
||||
} catch {
|
||||
/* Nothing stored. */
|
||||
}
|
||||
this.terminal?.clear();
|
||||
this.showWelcome?.();
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* A header drag starts with Detach Tiles on: note the tile's size and where
|
||||
* in it the press landed (the pop-out opens that size, under the pointer),
|
||||
* and watch whether the pointer leaves the page. Two signals, since
|
||||
* browsers differ in what dragend reports for a drop outside the window:
|
||||
* its coordinates, and the page's own dragleave at the viewport's edge
|
||||
* with nothing entered (cleared again by any dragover inside).
|
||||
*/
|
||||
_beginTileTearOff(sessionId, entry, e) {
|
||||
this._stopTileTearOffTracking();
|
||||
const rect = entry.el.getBoundingClientRect();
|
||||
const within = (v, max) => (Number.isFinite(v) ? Math.max(0, Math.min(v, max)) : 0);
|
||||
const drag = {
|
||||
id: sessionId,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
offsetX: within(e?.clientX - rect.left, rect.width),
|
||||
offsetY: within(e?.clientY - rect.top, rect.height),
|
||||
outside: false,
|
||||
stop: null,
|
||||
};
|
||||
const onOver = () => {
|
||||
drag.outside = false;
|
||||
};
|
||||
const onLeave = (ev) => {
|
||||
if (ev.relatedTarget) return;
|
||||
const x = ev.clientX;
|
||||
const y = ev.clientY;
|
||||
if (x <= 0 || y <= 0 || x >= window.innerWidth - 1 || y >= window.innerHeight - 1) drag.outside = true;
|
||||
};
|
||||
document.addEventListener('dragover', onOver, true);
|
||||
document.addEventListener('dragleave', onLeave, true);
|
||||
drag.stop = () => {
|
||||
document.removeEventListener('dragover', onOver, true);
|
||||
document.removeEventListener('dragleave', onLeave, true);
|
||||
};
|
||||
this._tileTearOff = drag;
|
||||
},
|
||||
|
||||
/** The header drag is over (or the grid or tile went mid-drag): stop watching the pointer. */
|
||||
_stopTileTearOffTracking() {
|
||||
const drag = this._tileTearOff;
|
||||
if (!drag) return;
|
||||
this._tileTearOff = null;
|
||||
drag.stop?.();
|
||||
},
|
||||
|
||||
/**
|
||||
* dragend of a tile's header with Detach Tiles on. Taken by a drop target
|
||||
* (here or in another window: `dropEffect` is not 'none'), it stays where
|
||||
* that put it. Let go OUTSIDE the window (the dragend point, or the page's
|
||||
* own record of the pointer leaving), it opens as a window of its own after
|
||||
* TILE_TEAR_OFF_SETTLE_MS, unless another window claims it first. Let go
|
||||
* inside, on nothing, nothing happens, as before.
|
||||
*/
|
||||
_settleTileTearOff(sessionId, e) {
|
||||
const drag = this._tileTearOff?.id === sessionId ? this._tileTearOff : null;
|
||||
this._lastTileDrag = { id: sessionId, at: Date.now() };
|
||||
if (!drag) return false;
|
||||
this._stopTileTearOffTracking();
|
||||
const effect = e?.dataTransfer?.dropEffect;
|
||||
if (effect && effect !== 'none') return false;
|
||||
const cx = e?.clientX;
|
||||
const cy = e?.clientY;
|
||||
// (0, 0) is what a browser reports when it does not know where the drop was.
|
||||
const known = Number.isFinite(cx) && Number.isFinite(cy) && !(cx === 0 && cy === 0);
|
||||
const outside = known ? cx < 0 || cy < 0 || cx >= window.innerWidth || cy >= window.innerHeight : drag.outside;
|
||||
if (!outside) return false;
|
||||
const sx = e?.screenX;
|
||||
const sy = e?.screenY;
|
||||
const placed = Number.isFinite(sx) && Number.isFinite(sy) && !(sx === 0 && sy === 0);
|
||||
const placement = {
|
||||
width: drag.width,
|
||||
height: drag.height,
|
||||
...(placed ? { screenX: sx, screenY: sy, offsetX: drag.offsetX, offsetY: drag.offsetY } : {}),
|
||||
};
|
||||
this._cancelTileTearOff();
|
||||
const pending = { id: sessionId, timer: null };
|
||||
pending.timer = setTimeout(() => {
|
||||
if (this._tileTearOffPending !== pending) return;
|
||||
this._tileTearOffPending = null;
|
||||
this.detachTile(sessionId, placement);
|
||||
}, TILE_TEAR_OFF_SETTLE_MS);
|
||||
this._tileTearOffPending = pending;
|
||||
return true;
|
||||
},
|
||||
|
||||
_cancelTileTearOff() {
|
||||
const pending = this._tileTearOffPending;
|
||||
if (!pending) return;
|
||||
this._tileTearOffPending = null;
|
||||
clearTimeout(pending.timer);
|
||||
},
|
||||
|
||||
/**
|
||||
* Opens a tile as a window of its own (detachSession, which takes it out of
|
||||
* the grid), the tile's size (at least TILE_WINDOW_MIN_W x MIN_H). Placed
|
||||
* with its header under a screen point when given one (`screenX`/`screenY`
|
||||
* and where in the tile it was held, `offsetX`/`offsetY`: a drop outside the
|
||||
* window, a hand let go), otherwise over the tile itself (the ⋯ menu).
|
||||
*
|
||||
* @returns {boolean} whether the session is popped out afterwards
|
||||
*/
|
||||
detachTile(sessionId, { screenX, screenY, offsetX = 0, offsetY = 0, width, height } = {}) {
|
||||
const entry = this._tileGrid?.open ? this._tileGrid.tiles.get(sessionId) : null;
|
||||
if (!entry || !this._tileDetachAllowed()) return false;
|
||||
const rect = entry.el.getBoundingClientRect();
|
||||
const availW = window.screen?.availWidth || Infinity;
|
||||
const availH = window.screen?.availHeight || Infinity;
|
||||
const size = (v, min, max) => Math.round(Math.min(Math.max(Number.isFinite(v) ? v : min, min), max));
|
||||
const placement = {
|
||||
width: size(width ?? rect.width, TILE_WINDOW_MIN_W, availW),
|
||||
height: size(height ?? rect.height, TILE_WINDOW_MIN_H, availH),
|
||||
};
|
||||
if (Number.isFinite(screenX) && Number.isFinite(screenY)) {
|
||||
placement.left = Math.round(screenX - offsetX);
|
||||
placement.top = Math.round(screenY - offsetY - TILE_WINDOW_CHROME_PX);
|
||||
} else if (Number.isFinite(window.screenX) && Number.isFinite(window.screenY)) {
|
||||
// Over the tile: the page's own chrome sits above its viewport.
|
||||
const chrome = Math.max(0, (window.outerHeight || 0) - (window.innerHeight || 0));
|
||||
placement.left = Math.round(window.screenX + rect.left);
|
||||
placement.top = Math.round(window.screenY + chrome + rect.top - TILE_WINDOW_CHROME_PX);
|
||||
}
|
||||
this.detachSession?.(sessionId, placement);
|
||||
return !!this.detachedSessions?.has(sessionId);
|
||||
},
|
||||
|
||||
// ── For the gesture overlay (packages/gesture-control, src/codeman/entry.ts)
|
||||
//
|
||||
// A hand is no native drag: the overlay asks where it is and what is under
|
||||
// it, and every move still goes through the paths a mouse drag takes
|
||||
// (dropSessionOnTile / dropSessionOnSlot, and so _reorderTiles), the
|
||||
// pop-out through detachTile.
|
||||
|
||||
/** The tiled session whose tile is at viewport point (x, y), or null. */
|
||||
tileAtPoint(x, y) {
|
||||
const grid = this._tileGrid;
|
||||
if (!grid?.open) return null;
|
||||
const hit = document.elementFromPoint?.(x, y);
|
||||
if (!hit) return null;
|
||||
for (const [id, entry] of grid.tiles) if (entry.el.contains?.(hit)) return id;
|
||||
return null;
|
||||
},
|
||||
|
||||
/** Whether a hand may pick up this tile: it can move, or (Detach Tiles) leave the window. */
|
||||
canGrabTile(sessionId) {
|
||||
return !!this._tileGrid?.has(sessionId) && (this._tilesMovable() || this._tileDetachAllowed());
|
||||
},
|
||||
|
||||
/**
|
||||
* What a session dragged to viewport point (x, y) would drop onto, by the
|
||||
* mouse drag's own rules: another tile (it takes that tile's place, or the
|
||||
* two trade places), an empty cell (it moves or joins there). A tiled
|
||||
* session only while tiles can move (not zoomed). Null for anything else,
|
||||
* a popped-out session included.
|
||||
*
|
||||
* @returns {{kind: 'tile', id: string, el: HTMLElement}|{kind: 'cell', cell: number, el: HTMLElement}|null}
|
||||
*/
|
||||
tileDropTargetAt(x, y, draggedId) {
|
||||
const grid = this._tileGrid;
|
||||
if (!grid?.open || !this.sessions.has(draggedId) || this.detachedSessions?.has(draggedId)) return null;
|
||||
if (grid.tiles.has(draggedId) && !this._tilesMovable()) return null;
|
||||
const hit = document.elementFromPoint?.(x, y);
|
||||
if (!hit) return null;
|
||||
for (const [id, entry] of grid.tiles) {
|
||||
if (entry.el.contains?.(hit)) return id === draggedId ? null : { kind: 'tile', id, el: entry.el };
|
||||
}
|
||||
for (const slot of grid.slots || []) {
|
||||
if (slot.contains?.(hit)) return { kind: 'cell', cell: Number(slot.dataset.cell), el: slot };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Drops a session on a target tileDropTargetAt returned (the hand let go).
|
||||
*
|
||||
* @returns {boolean} whether the session is a tile afterwards
|
||||
*/
|
||||
dropOnTileTarget(draggedId, target) {
|
||||
if (!target || !this._tileGrid?.open) return false;
|
||||
if (target.kind === 'tile') this.dropSessionOnTile(draggedId, target.id);
|
||||
else if (target.kind === 'cell') this.dropSessionOnSlot(draggedId, target.cell);
|
||||
return !!this._tileGrid?.has(draggedId);
|
||||
},
|
||||
|
||||
/** Whether viewport point (x, y) is over the open grid (a hand let go outside it pops the tile out). */
|
||||
isOverTileGrid(x, y) {
|
||||
if (!this._tileGrid?.open) return false;
|
||||
const rect = document.getElementById('tileGrid')?.getBoundingClientRect?.();
|
||||
return !!rect && x >= rect.left && x < rect.right && y >= rect.top && y < rect.bottom;
|
||||
},
|
||||
|
||||
/**
|
||||
* A hand let a tile go outside the grid (Detach Tiles): it opens as a window
|
||||
* of its own with its header near that point. A script opening a window
|
||||
* with no click behind it is what popup blockers stop; detachSession then
|
||||
* says so and the tile stays.
|
||||
*/
|
||||
detachTileAtPoint(sessionId, x, y, { offsetX = 0, offsetY = 0 } = {}) {
|
||||
const chrome = Math.max(0, (window.outerHeight || 0) - (window.innerHeight || 0));
|
||||
const sx = (window.screenX || 0) + x;
|
||||
const sy = (window.screenY || 0) + chrome + y;
|
||||
return this.detachTile(sessionId, { screenX: sx, screenY: sy, offsetX, offsetY });
|
||||
},
|
||||
|
||||
/**
|
||||
* A pop-out window's title, as a handle (Detach Tiles): dragged onto a
|
||||
* Codeman window's tile or empty cell, the session docks there as a tile
|
||||
* and this window closes (_dockForeignTile, which asks it to). The same
|
||||
* drag type as a tile's header; on or off follows the setting, also when it
|
||||
* changes in another window.
|
||||
*/
|
||||
_installSoloTileHandle() {
|
||||
const title = document.getElementById('soloSessionTitle');
|
||||
if (!this.isSoloWindow || !title || title._codemanTileHandle) return;
|
||||
title._codemanTileHandle = true;
|
||||
const sync = () => {
|
||||
this._cachedAppSettings = null;
|
||||
const on = this.tileDetachEnabled();
|
||||
title.setAttribute('draggable', on ? 'true' : 'false');
|
||||
title.classList.toggle('solo-session-title--handle', on);
|
||||
const label = on ? "Drag onto a Codeman window's tiles to dock this session there" : '';
|
||||
if (title._codemanTileHandleLabel !== label) {
|
||||
title._codemanTileHandleLabel = label;
|
||||
title.title = label;
|
||||
}
|
||||
};
|
||||
sync();
|
||||
window.addEventListener('storage', (e) => {
|
||||
if (!e.key || e.key === this.getSettingsStorageKey?.()) sync();
|
||||
});
|
||||
title.addEventListener('dragstart', (e) => {
|
||||
if (!this.tileDetachEnabled() || !this.soloSessionId) {
|
||||
e.preventDefault?.();
|
||||
return;
|
||||
}
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData(TILE_DRAG_TYPE, this.soloSessionId);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// A tiled session deleted (here or elsewhere) loses its tile; if it held focus,
|
||||
|
||||
Reference in new issue
Block a user