mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c55fe907e |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'aicodeman': minor
|
||||
---
|
||||
|
||||
Tiles move by hand, and can leave the window. With gesture control on, pinch a tile and carry it onto another tile (they swap) or an empty cell (it moves there), or carry a tab onto the grid to tile it. A new **Detach Tiles** setting (App Settings → Appearance, per device, off by default) lets you drag a tile's header out of the browser to open it in its own window, drop it on another Codeman window's tiles to move it there, and drag a popped-out window's title back onto any grid. The tile's ⋯ menu gets "Open in a new window" too.
|
||||
@@ -841,13 +841,15 @@ Tests: `test/tab-rail-search.test.ts` (gate) and `test/tab-rail-search.browser.t
|
||||
|
||||
⚠️ **Persistence and joining.** `codeman:tile-grid` (localStorage, per browser, never sent to the server) holds `{ v: 1, open, ids, count, focused, zoomed, colFr, rowFr }`, session ids and layout only, never content, written on every change (move, divider pointer-up, add, remove, count pick, focus, zoom); its `ids` are the CELLS, `null` for an empty one, and `sanitizeTileGridState` returns them as `cells` (a dropped id a hole, never a shift) beside the packed `ids` every list consumer wants, plus `freed` (cells whose session went away since) and `count`. ⚠️ `count` is how many tiles the user's own last change left: `removeTile(..., { gone: true })` (the `_onSessionDeleted` wrapper, `_reconcileTileGrid`, `_onTileExit`, and app.js `_markDetached` for a session popped out while the grid is open) does NOT lower it, so the next activation refills that cell, while a removal by hand does; it is derived (the sessions the cells name) for a value written before it, and the format stays `v: 1` so an older build still reads a newer value. A restore keeps the cells when their shape matches the current one, else `reformTileCells` (positions kept when all fit, else packed). ⚠️ Every close keeps the grid as `open: false` (`closeTileGrid({ keepStored: true })` from every caller: the toggle, a non-tiled pick, `leaveTiles`, Home, the width gate, the last tile leaving, "Open group as tiles", kill-all); `_closeStoredTileGrid` (a `#session=` link on load) flips `open` on the RAW stored value, so a gone id still frees its cell; `_openStoredTileGrid` holds `_persistTileGrid` (`_tilePersistHold`) until the grid is back, so `openTileGrid`'s packed intermediate layout is never written over it. Only when none of its sessions survive does activation rank from scratch. ⚠️ A RELOAD's fill (`_restoreTileGrid` inside `handleInit`) ranks on status and stamps only: pending approvals arrive later through the async `seedApprovals`, so needs-input cannot rank for that one fill, and a late seed never re-forms the restored grid (holding the fill back would open fewer tiles, possibly another shape, and move the user's tiles a moment after the reload; pinned in `test/tile-grid-restore.test.ts`). A solo window never reads or writes it, an automatic zoom is not stored. Sessions created by THIS tab's Run join the open grid (`_joinTileGridFromRun`, called from session-ui.js `_ensureCreatedSessionVisible`; a wrapper from tile-grid.js would be overwritten by session-ui.js's later `Object.assign`); sessions created elsewhere arrive only by `session:created` and never join. ⚠️ A tile that joins that way connects BEFORE Run starts its pane: its resize reaches a session with no PTY, which `Session.resize()` only records (`_lastDesktopDims`) while the spawn uses a fixed 120x40, and Run's own resize step measures the parked main terminal (`display: none`, so `proposeDimensions()` is NaN and the step is skipped). Measured live: a 97x17 tile over a 120x40 pane. So `_renderTileChrome` remembers each tile's last-seen pid and calls `tile.paneStarted()` when it appears or changes (keyed on the sessions map, so a `handleInit` after an SSE drop counts too); `paneStarted()` forgets the sent size and sends it, and a hidden tile (a zoomed neighbour) sends nothing but keeps the size forgotten, so its next `fit()` sends it. A plain `fit({ force: true })` would lose that case. Tests: `test/tile-grid-*.test.ts` over the shared vm harness `test/mocks/tile-grid-vm.ts`.
|
||||
|
||||
⚠️ **Detach Tiles** (`tileDetachEnabled`, per-device, default OFF: in `displayKeys`, stripped from the settings PUT, not in `SettingsUpdateSchema`; App Settings → Appearance, its row shown on desktop only). A tile leaves the grid for a window of its own, and comes back into any Codeman window's grid, by dragging, with nothing new on the server: a pop-out is another client of the same session (`detachSession`), and a tile is a `TerminalTile` in whichever window has it. (1) A header let go OUTSIDE the window opens it with `detachSession(id, placement)`, the tile's size (at least 480x320), its header near the drop point, after `TILE_TEAR_OFF_SETTLE_MS` (120 ms) that a claim from another window cancels. "Outside" is the dragend's own point off the viewport with `dropEffect: 'none'`; where a browser reports no point (0, 0) it is the page's record of a `dragleave` at the viewport's edge with nothing entered, cleared by any later `dragover`. A drop that any target took (`dropEffect: 'move'`, in this window or another) never opens one, a drop inside on nothing changes nothing, and with the setting on a lone or zoomed tile drags too (out only: no target here takes it). (2) A header dropped on ANOTHER Codeman window's tile or empty cell moves it there: that window's `_acceptTabDrops` takes a drag that is none of its own and carries `application/x-codeman-tile` (`_isForeignTileDrag`; the id is only readable on drop), docks it through the target's own drop (`_dockForeignTile` → `dropSessionOnTile` / `dropSessionOnSlot`, so `_reorderTiles` stays the one move path) and posts `tile-adopted` on the `codeman-windows` channel. Only the window the tile was dragged FROM acts on that (its drag still settling, or ended within `TILE_ADOPT_WINDOW_MS`): the tile goes with `focus: false`, and a last tile closes the grid onto the welcome screen, never onto that session, since two windows sizing one PTY garble it. (3) A pop-out's title (`#soloSessionTitle`, `_installSoloTileHandle`) carries the same type, so it drags back onto any grid: docking a popped-out session `_redock`s it first (a detached session never mounts), posts `close-request`, and ignores that pop-out's `detached` answers for `TILE_DOCK_GRACE_MS` (`_tileDockedRecently`), which would otherwise take the new tile straight back off. A pop-out that gets `close-request` is RELEASED (`_releaseSoloWindow`): it posts `redocked` at once, stops answering roll-calls, then closes; one a script may not close (opened by typing its URL) says where the session went instead of staying a pop-out every dashboard would mark detached again. A tile's ⋯ menu offers "Open in a new window" with the setting on (`detachTile`, placed over the tile): the keyboard's way to the drag. ⚠️ Chrome keeps a script-opened window on the opener's screen unless the page holds the Window Management permission, so a drop on another monitor opens at this screen's edge (drag the window across from there), and Wayland ignores the position. The gesture overlay reaches all of it through `tileAtPoint` / `canGrabTile` / `tileDropTargetAt` / `dropOnTileTarget` / `isOverTileGrid` / `detachTileAtPoint`, never move logic of its own; its pop-out has no click behind it, so a popup blocker refuses it unless the site's pop-ups are allowed (detachSession's toast says so, and the tile stays). Tests: `test/tile-grid-detach.test.ts`.
|
||||
|
||||
### Gesture control: the setting
|
||||
|
||||
**Gesture control** (the camera hand-tracking overlay) is **opt-in, default OFF**, under App Settings → Terminal & Input → Scrolling & rendering (`gestureControlEnabled`). `CODEMAN_GESTURE=1` makes the feature _available_ on the instance (CSP widening + `/gesture/` assets) and sets `window.__codemanGestureAvailable` (the Input section only shows when set); the overlay bundle is injected by `renderIndexHtml` **only when the setting is enabled**, so that method is `async` and reads `settings.json` via `readSettings(true)` — the `true` forces a **fresh** read (bypassing the 2s `_settingsCache`), because a post-save reload happens within that TTL and the cached value would otherwise render the pre-toggle state. Toggling the setting reloads the page (the bundle is render-injected).
|
||||
|
||||
### Gesture control: the source package
|
||||
|
||||
**Gesture-control source lives in-repo** at `packages/gesture-control/` (workspace package `codeman-gesture-control`, was the standalone `Ark0N/codeman-gesture-control` repo). The transport-agnostic core is `src/gesture/*` (MediaPipe GestureRecognizer → One-Euro-filtered cursor → pinch state machine); `src/codeman/entry.ts` is the Codeman _consumer_ that maps grab/drag/drop onto real `.session-tab`/toolbar buttons and is the bundle entry. **Edit there, then run `npm run build:gesture`** (`scripts/build-gesture-bundle.mjs` → esbuild bundles `entry.ts`, MediaPipe JS included, into `src/web/public/gesture/gesture-codeman.js`) and **commit the regenerated bundle** — the committed bundle is what dev/`tsx` serves (no bundler at runtime), and `scripts/build.mjs` reruns the same step so prod always reflects current source. The MediaPipe **wasm + model** are NOT bundled — loaded at runtime from same-origin `/gesture/wasm` + `/gesture/gesture_recognizer.task`, fetched by `scripts/fetch-gesture-assets.mjs` (gitignored, see Gotchas). `entry.ts` mounts `window.__codemanGesture = new GestureBridge()` idempotently at module-eval. A standalone vite playground (`npm run dev` in the package — fake tabs, no Codeman) lets you iterate on gesture _feel_ in isolation. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision` in `packages/gesture-control/package.json`.
|
||||
**Gesture-control source lives in-repo** at `packages/gesture-control/` (workspace package `codeman-gesture-control`, was the standalone `Ark0N/codeman-gesture-control` repo). The transport-agnostic core is `src/gesture/*` (MediaPipe GestureRecognizer → One-Euro-filtered cursor → pinch state machine); `src/codeman/entry.ts` is the Codeman _consumer_ that maps grab/drag/drop onto real `.session-tab`/toolbar buttons and is the bundle entry. **Edit there, then run `npm run build:gesture`** (`scripts/build-gesture-bundle.mjs` → esbuild bundles `entry.ts`, MediaPipe JS included, into `src/web/public/gesture/gesture-codeman.js`) and **commit the regenerated bundle** — the committed bundle is what dev/`tsx` serves (no bundler at runtime), and `scripts/build.mjs` reruns the same step so prod always reflects current source. The MediaPipe **wasm + model** are NOT bundled — loaded at runtime from same-origin `/gesture/wasm` + `/gesture/gesture_recognizer.task`, fetched by `scripts/fetch-gesture-assets.mjs` (gitignored, see Gotchas). `entry.ts` mounts `window.__codemanGesture = new GestureBridge()` idempotently at module-eval. A standalone vite playground (`npm run dev` in the package — fake tabs, no Codeman) lets you iterate on gesture _feel_ in isolation. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision` in `packages/gesture-control/package.json`. The tile grid's verbs (a tile pinched anywhere on it and carried onto another tile or an empty cell; a tab carried onto the grid joins it there; with Detach Tiles a tile let go outside the grid pops out) ask tile-grid.js's gesture helpers (see Tile grid, Detach Tiles) and never move tiles themselves, so the hand and the mouse cannot disagree about a drop.
|
||||
|
||||
### Theme skins
|
||||
|
||||
|
||||
@@ -133,6 +133,13 @@ its CSP for WebAssembly). Deploy = copy the bundle into Codeman's
|
||||
drop point. Pinch the panel again to move it anywhere. It stays inside the
|
||||
camera-owning page, so the hand keeps control (the old `window.app.detachSession`
|
||||
`window.open` was a one-way trip). A small twitch-and-release cancels.
|
||||
- **Tiles** (tile grid open): pinch anywhere on a tile and carry it onto
|
||||
another tile (the two trade places) or an empty cell (it moves there). A
|
||||
session tab carried onto a tile or an empty cell joins the grid there. With
|
||||
**Detach Tiles** on (App Settings, per device, off by default) a tile let go
|
||||
outside the grid opens as a window of its own, which needs the site's pop-ups
|
||||
allowed (a hand is no click). The moves are tile-grid.js's own
|
||||
(`tileDropTargetAt` / `dropOnTileTarget` / `detachTileAtPoint`).
|
||||
- **Run / Run Shell** — pinch over the **Run** (`#runBtn`) or **Run Shell**
|
||||
(`.btn-shell`) toolbar button and release in place to fire it; drifting too
|
||||
far first cancels the tap. The button list is `CLICK_SELECTOR` in `entry.ts`.
|
||||
|
||||
@@ -27,6 +27,14 @@
|
||||
// • Button "tap" — pinch over a toolbar button (Run / Run Shell) and release
|
||||
// in place → fires the button's real click handler. Drift too far first and
|
||||
// it's treated as a stray move, not a tap.
|
||||
// • Tile "grab-to-move": with the tile grid open, pinch anywhere on a tile
|
||||
// and carry it onto another tile (the two trade places) or an empty cell
|
||||
// (it moves there). With Detach Tiles on (App Settings, per device, off by
|
||||
// default) a tile let go OUTSIDE the grid opens as a window of its own.
|
||||
// A session TAB carried onto a tile or an empty cell joins the grid there,
|
||||
// as a mouse-dragged tab does. Every move goes through app.js's own paths
|
||||
// (tile-grid.js tileDropTargetAt / dropOnTileTarget / detachTileAtPoint), so
|
||||
// the hand and the mouse can never disagree about what a drop does.
|
||||
//
|
||||
// Why in-page floats, not OS-window detach (decided 2026-06-08, see
|
||||
// docs/MULTIMONITOR_DESIGN.md): the hand only exists in the one page that owns
|
||||
@@ -52,11 +60,22 @@ declare global {
|
||||
saveSubagentWindowStates?: () => void;
|
||||
subagentWindowZIndex?: number;
|
||||
ultracodeWindowZIndex?: number;
|
||||
// The tile grid (tile-grid.js). All optional: an older dashboard without
|
||||
// them simply has no tile verbs for the hand.
|
||||
tileAtPoint?: (x: number, y: number) => string | null;
|
||||
canGrabTile?: (id: string) => boolean;
|
||||
tileDropTargetAt?: (x: number, y: number, draggedId: string) => TileTarget | null;
|
||||
dropOnTileTarget?: (draggedId: string, target: TileTarget) => boolean;
|
||||
isOverTileGrid?: (x: number, y: number) => boolean;
|
||||
tileDetachEnabled?: () => boolean;
|
||||
detachTileAtPoint?: (id: string, x: number, y: number, at?: { offsetX: number; offsetY: number }) => boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const TAB_SELECTOR = '.session-tab';
|
||||
/** A tile of the open tile grid (tile-grid.js), keyed by `data-session-id`. */
|
||||
const TILE_SELECTOR = '#tileGrid .tile';
|
||||
/** An in-page floating session panel this layer spawned — re-grabbable to move. */
|
||||
const PANEL_SELECTOR = '.cg-float';
|
||||
/** The dashboard's own floating agent windows (subagent runs + ultracode run and
|
||||
@@ -81,11 +100,20 @@ const FLOAT_H = 420;
|
||||
const DETACH_PULL_PX = 70;
|
||||
/** If a button-pinch drifts more than this, it's a stray move, not a tap. */
|
||||
const TAP_CANCEL_PX = 45;
|
||||
/** How far (px) a grabbed tile must travel before it targets anything: a pinch
|
||||
* that barely moves is a stray one, and the tile stays where it is. */
|
||||
const TILE_MOVE_PX = 40;
|
||||
/** The width (px) of the small copy of a grabbed tile that follows the hand. */
|
||||
const TILE_GHOST_W = 280;
|
||||
|
||||
/** First letter colours: cyan left, violet right; green while pinching. */
|
||||
const handColor = (handedness: string, pinching: boolean): string =>
|
||||
pinching ? '#4ade80' : handedness === 'Right' ? '#a78bfa' : '#38bdf8';
|
||||
|
||||
/** Where a session carried by the hand would land in the tile grid: another
|
||||
* tile, or an empty cell (tile-grid.js tileDropTargetAt). */
|
||||
type TileTarget = { kind: 'tile'; id: string; el: HTMLElement } | { kind: 'cell'; cell: number; el: HTMLElement };
|
||||
|
||||
/** A floating in-page session panel (an iframe of `/session/:id`) the hand can
|
||||
* place and re-grab. Stays in this page's DOM, so it never leaves hand reach. */
|
||||
interface FloatingPanel {
|
||||
@@ -107,6 +135,29 @@ type Grab =
|
||||
oy: number;
|
||||
/** Pulled past the float-out threshold at least once. */
|
||||
armed: boolean;
|
||||
/** With the tile grid open: the tile or empty cell it would join (highlighted). */
|
||||
target: TileTarget | null;
|
||||
}
|
||||
| {
|
||||
/** A tile of the open grid, carried to another cell or (Detach Tiles)
|
||||
* out of the grid. The tile itself never moves until the drop: it dims
|
||||
* (`tile--dragging`, the mouse drag's own class) and a small copy of
|
||||
* it follows the hand. */
|
||||
kind: 'tile';
|
||||
id: string;
|
||||
el: HTMLElement;
|
||||
ghost: HTMLElement;
|
||||
/** Where in the tile the hand closed, so a pop-out window lands under it. */
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
ox: number;
|
||||
oy: number;
|
||||
/** Travelled past TILE_MOVE_PX at least once. */
|
||||
armed: boolean;
|
||||
/** The tile or empty cell it would drop onto (highlighted). */
|
||||
target: TileTarget | null;
|
||||
/** Outside the grid with Detach Tiles on: letting go opens a window. */
|
||||
out: boolean;
|
||||
}
|
||||
| {
|
||||
kind: 'panel';
|
||||
@@ -224,7 +275,7 @@ class GestureBridge {
|
||||
await this.gc.start();
|
||||
this.running = true;
|
||||
this.button.classList.add('on');
|
||||
this.status.textContent = 'on — pinch a tab, window, or button';
|
||||
this.status.textContent = 'on: pinch a tab, tile, window, or button';
|
||||
} catch (err) {
|
||||
// Surface the *real* cause: MediaPipe/Emscripten can throw a non-Error
|
||||
// (number/string), so `(err as Error).message` was logging "undefined".
|
||||
@@ -295,6 +346,35 @@ class GestureBridge {
|
||||
return;
|
||||
}
|
||||
|
||||
// A tile of the open tile grid → carry it to another cell, or (Detach
|
||||
// Tiles) out of the grid. Anywhere on the tile: the hand is choosing the
|
||||
// whole tile, as with the agent windows. A tile that can do neither (alone
|
||||
// or zoomed, Detach Tiles off) is left alone, so the pinch falls through.
|
||||
const tileId = window.app?.tileAtPoint?.(x, y) ?? null;
|
||||
const tileEl = tileId ? this.hitClosest(x, y, 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(x - rect.left, rect.width)),
|
||||
offsetY: Math.max(0, Math.min(y - rect.top, rect.height)),
|
||||
ox: x,
|
||||
oy: y,
|
||||
armed: false,
|
||||
target: null,
|
||||
out: false,
|
||||
});
|
||||
this.positionGhost(ghost, x, y);
|
||||
this.status.textContent = 'moving tile';
|
||||
return;
|
||||
}
|
||||
|
||||
// A session tab → grab-and-pull-out into a floating panel (ghost follows).
|
||||
const tab = this.hitClosest(x, y, TAB_SELECTOR);
|
||||
const id = tab?.dataset.id;
|
||||
@@ -308,7 +388,7 @@ class GestureBridge {
|
||||
document.body.append(ghost);
|
||||
|
||||
tab.classList.add('cg-grabbed');
|
||||
this.grabs.set(hand, { kind: 'tab', id, tab, ghost, ox: x, oy: y, armed: false });
|
||||
this.grabs.set(hand, { kind: 'tab', id, tab, ghost, ox: x, oy: y, armed: false, target: null });
|
||||
this.positionGhost(ghost, x, y);
|
||||
return;
|
||||
}
|
||||
@@ -325,13 +405,42 @@ class GestureBridge {
|
||||
|
||||
private onDrag(hand: string, x: number, y: number): void {
|
||||
const grab = this.grabs.get(hand);
|
||||
if (grab?.kind === 'tile') {
|
||||
this.positionGhost(grab.ghost, x, y);
|
||||
if (!grab.armed && Math.hypot(x - grab.ox, y - grab.oy) >= TILE_MOVE_PX) grab.armed = true;
|
||||
if (!grab.armed) return;
|
||||
const app = window.app;
|
||||
const target = app?.tileDropTargetAt?.(x, y, grab.id) ?? null;
|
||||
this.setTileTarget(grab, target);
|
||||
const out = !target && !!app?.tileDetachEnabled?.() && !app?.isOverTileGrid?.(x, y);
|
||||
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, x, y);
|
||||
const pulled = Math.hypot(x - grab.ox, y - grab.oy) >= DETACH_PULL_PX;
|
||||
if (pulled !== grab.armed) {
|
||||
// The tile grid open: a tile or an empty cell under the hand takes the
|
||||
// tab there, as a mouse-dragged tab does.
|
||||
const target = pulled ? (window.app?.tileDropTargetAt?.(x, y, 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 — pinch a tab';
|
||||
this.status.textContent = target
|
||||
? 'release to tile it here'
|
||||
: pulled
|
||||
? 'release to float out'
|
||||
: 'on — pinch a tab';
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -354,17 +463,44 @@ class GestureBridge {
|
||||
if (tap && Math.hypot(x - tap.ox, y - tap.oy) > TAP_CANCEL_PX) {
|
||||
tap.el.classList.remove('cg-tap-armed');
|
||||
this.taps.delete(hand);
|
||||
this.status.textContent = 'on — pinch a tab, window, or button';
|
||||
this.status.textContent = 'on: pinch a tab, tile, window, or button';
|
||||
}
|
||||
}
|
||||
|
||||
private onDrop(hand: string, x: number, y: number): void {
|
||||
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;
|
||||
}
|
||||
// Asked again at the drop: the grid may have changed since the last frame.
|
||||
const target = app?.tileDropTargetAt?.(x, y, grab.id) ?? null;
|
||||
if (target) {
|
||||
this.flash(app?.dropOnTileTarget?.(grab.id, target) ? 'moved tile' : 'cancelled');
|
||||
} else if (grab.out && !app?.isOverTileGrid?.(x, y)) {
|
||||
// A window opened with no click behind it is what popup blockers stop:
|
||||
// detachSession then says so in a toast and the tile stays.
|
||||
const opened = app?.detachTileAtPoint?.(grab.id, x, y, { 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, x, y);
|
||||
this.setTileTarget(grab, null);
|
||||
const target = grab.armed ? (window.app?.tileDropTargetAt?.(x, y, grab.id) ?? null) : null;
|
||||
if (target) this.flash(window.app?.dropOnTileTarget?.(grab.id, target) ? 'tiled' : 'cancelled');
|
||||
else if (grab.armed) this.floatPanel(grab.id, x, y);
|
||||
else this.flash('cancelled');
|
||||
return;
|
||||
}
|
||||
@@ -505,6 +641,33 @@ class GestureBridge {
|
||||
ghost.style.top = `${y}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. */
|
||||
private tileGhost(tileEl: HTMLElement, rect: DOMRect): HTMLElement {
|
||||
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) as HTMLElement;
|
||||
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. */
|
||||
private setTileTarget(grab: { target: TileTarget | null }, target: TileTarget | null): void {
|
||||
if (grab.target?.el !== target?.el) {
|
||||
grab.target?.el.classList.remove('tile--drop-target');
|
||||
target?.el.classList.add('tile--drop-target');
|
||||
}
|
||||
grab.target = target;
|
||||
}
|
||||
|
||||
private cancelAllGrabs(): void {
|
||||
// Floats themselves persist (they're placed windows) — only release any
|
||||
// in-progress grab cleanly, restoring a moved panel's interactivity.
|
||||
@@ -512,6 +675,11 @@ class GestureBridge {
|
||||
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');
|
||||
@@ -525,6 +693,7 @@ class GestureBridge {
|
||||
document
|
||||
.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed, .cg-win-grabbed`)
|
||||
.forEach((t) => t.classList.remove('cg-grabbed', 'cg-tap-armed', 'cg-win-grabbed'));
|
||||
document.querySelectorAll(`${TILE_SELECTOR}.tile--dragging`).forEach((t) => t.classList.remove('tile--dragging'));
|
||||
}
|
||||
|
||||
private onStatus(fps: number, hands: HandState[]): void {
|
||||
@@ -607,6 +776,13 @@ function injectStyles(): void {
|
||||
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: ${Z + 3};
|
||||
display: flex; align-items: center; gap: 8px; font: 12px/1 system-ui, sans-serif;
|
||||
|
||||
+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,
|
||||
|
||||
@@ -213,6 +213,8 @@ export const bySelector = new Map<string, FakeEl>();
|
||||
export const body = new FakeEl();
|
||||
/** The context's `fetch`; a test sets what it answers. */
|
||||
export const fetchSpy = vi.fn(async (..._args: unknown[]) => ({ ok: true, json: async () => ({}) }));
|
||||
/** What `document.elementFromPoint` answers (the gesture overlay's hit test); a test sets it. */
|
||||
export const pointHits: { at: ((x: number, y: number) => FakeEl | null) | null } = { at: null };
|
||||
/** `document.addEventListener`, so a test can find a listener the app installed. */
|
||||
export const documentAddEventListener = vi.fn();
|
||||
export const documentRemoveEventListener = vi.fn();
|
||||
@@ -280,6 +282,7 @@ const context = vm.createContext({
|
||||
return focusedEl;
|
||||
},
|
||||
getElementById: (id: string) => (id === 'tileGrid' ? section : (bySelector.get(`#${id}`) ?? null)),
|
||||
elementFromPoint: (x: number, y: number) => pointHits.at?.(x, y) ?? null,
|
||||
body,
|
||||
querySelector: (sel: string) =>
|
||||
bySelector.get(sel) ?? (sel === '.main' ? main : sel === '.terminal-wrap' ? wrap : null),
|
||||
@@ -389,4 +392,5 @@ export function resetGridHarness() {
|
||||
main.appendChild(section);
|
||||
bySelector.clear();
|
||||
body.children = [];
|
||||
pointHits.at = null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
/**
|
||||
* @fileoverview 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.
|
||||
*
|
||||
* - OFF (the default) changes nothing: a lone tile's header does not drag, a
|
||||
* header let go outside the window opens nothing, and a tile dragged from
|
||||
* another window is not taken.
|
||||
* - A header let go OUTSIDE the window (dragend with no drop, its point off the
|
||||
* viewport, or the page's own record of the pointer leaving when the
|
||||
* browser reports no point) opens the session as a pop-out after a short
|
||||
* settle, the tile's size, its header under the drop point. Let go inside
|
||||
* on nothing, or taken by a drop target (`dropEffect: 'move'`), nothing
|
||||
* opens. A lone or zoomed tile drags too (out only).
|
||||
* - Another window docking the tile ('tile-adopted' on the window channel)
|
||||
* takes it out of THIS grid only when this window dragged it (still
|
||||
* settling, or ended a moment ago); the keyboard stays put, and the last
|
||||
* tile leaves for the welcome screen, never for that session.
|
||||
* - A tile from another window dropped on a tile or an empty cell docks there
|
||||
* through the target's own drop and says so; a popped-out session is taken
|
||||
* back first and its window asked to close; its stale 'detached' answers
|
||||
* are ignored for a moment. Unknown ids dock nowhere.
|
||||
* - The pop-out window: its features carry the placement (the default stays
|
||||
* 960x680, unplaced); a released pop-out stops answering roll-calls.
|
||||
* - The gesture overlay's helpers ask the same rules: what is under a point,
|
||||
* what a drop there would do, and the pop-out at a hand's point.
|
||||
* - A tile's ⋯ menu offers "Open in a new window" with Detach Tiles on (the
|
||||
* keyboard's way to the drag), placed over the tile.
|
||||
* - The setting is per-device: a display key, stripped from the settings PUT,
|
||||
* absent from the .strict() schema, OFF unless switched on, its row shown on
|
||||
* desktop only.
|
||||
*
|
||||
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
FakeEl,
|
||||
FakeTile,
|
||||
documentAddEventListener,
|
||||
documentRemoveEventListener,
|
||||
makeGridApp,
|
||||
pointHits,
|
||||
resetGridHarness,
|
||||
section,
|
||||
type GridApp,
|
||||
tileEl,
|
||||
windowStub,
|
||||
} from './mocks/tile-grid-vm.js';
|
||||
|
||||
const TILE_TYPE = 'application/x-codeman-tile';
|
||||
const FOUR = ['s-a', 's-b', 's-c', 's-d'];
|
||||
const THREE = ['s-a', 's-b', 's-c'];
|
||||
// Five tiles lay out 3x2 here: one empty cell, the last.
|
||||
const FIVE = ['s-a', 's-b', 's-c', 's-d', 's-e'];
|
||||
|
||||
const headerOf = (id: string) => tileEl(id).children[0];
|
||||
const slots = () => section.children.filter((el) => el.className.split(' ').includes('tile-slot'));
|
||||
const settle = () => new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
function dragEvent(extra: Record<string, unknown> = {}, transfer: Record<string, unknown> = {}) {
|
||||
return {
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
clientX: 100,
|
||||
clientY: 10,
|
||||
...extra,
|
||||
dataTransfer: { effectAllowed: '', dropEffect: 'none', setData: vi.fn(), ...transfer },
|
||||
};
|
||||
}
|
||||
|
||||
/** A drag from another window: none of this page's own, the tile type carried, its id readable on drop. */
|
||||
function foreign(id: string) {
|
||||
return dragEvent({}, { types: [TILE_TYPE], getData: (t: string) => (t === TILE_TYPE ? id : '') });
|
||||
}
|
||||
|
||||
function openGrid(ids: string[], { detach = true, focusedId = ids[0] } = {}): GridApp {
|
||||
const app = makeGridApp(ids);
|
||||
app.loadAppSettingsFromStorage = () => ({ tileDetachEnabled: detach });
|
||||
app._postWindowMessage = vi.fn();
|
||||
app._wsTabNonce = 'c-here';
|
||||
app.openTileGrid(ids, { focusedId });
|
||||
return app;
|
||||
}
|
||||
|
||||
/** Press the header and start its native drag (a press at (40, 10) inside the tile). */
|
||||
function startDrag(id: string) {
|
||||
headerOf(id).dispatch('pointerdown', { target: headerOf(id), button: 0 });
|
||||
const start = dragEvent({ clientX: 40, clientY: 10 });
|
||||
headerOf(id).dispatch('dragstart', start);
|
||||
return start;
|
||||
}
|
||||
const endDrag = (id: string, extra: Record<string, unknown>, effect = 'none') =>
|
||||
headerOf(id).dispatch('dragend', dragEvent(extra, { dropEffect: effect }));
|
||||
|
||||
/** The page-level drag listeners the tear-off installed (capture phase). */
|
||||
const docListener = (type: string) =>
|
||||
documentAddEventListener.mock.calls.filter((c) => c[0] === type).at(-1)?.[1] as (e: unknown) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
resetGridHarness();
|
||||
documentAddEventListener.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
delete windowStub.screenX;
|
||||
delete windowStub.screenY;
|
||||
delete windowStub.outerHeight;
|
||||
delete windowStub.screen;
|
||||
delete windowStub.open;
|
||||
});
|
||||
|
||||
describe('Detach Tiles OFF (the default) changes nothing', () => {
|
||||
it('a lone tile does not drag, and a header let go outside the window opens nothing', async () => {
|
||||
const app = openGrid(['s-a'], { detach: false });
|
||||
app.detachSession = vi.fn();
|
||||
expect(headerOf('s-a').getAttribute('draggable')).toBe('false');
|
||||
expect(startDrag('s-a').preventDefault).toHaveBeenCalled();
|
||||
|
||||
const four = openGrid(FOUR, { detach: false });
|
||||
four.detachSession = vi.fn();
|
||||
startDrag('s-b');
|
||||
endDrag('s-b', { clientX: -50, clientY: 300, screenX: 20, screenY: 400 });
|
||||
await settle();
|
||||
expect(four.detachSession).not.toHaveBeenCalled();
|
||||
expect(four._tileGrid.ids).toEqual(FOUR);
|
||||
});
|
||||
|
||||
it('a tile dragged from another window is not taken', () => {
|
||||
const app = openGrid(THREE, { detach: false });
|
||||
const over = foreign('s-other');
|
||||
tileEl('s-b').dispatch('dragover', over);
|
||||
expect(over.preventDefault).not.toHaveBeenCalled();
|
||||
tileEl('s-b').dispatch('drop', foreign('s-other'));
|
||||
expect(app._tileGrid.ids).toEqual(THREE);
|
||||
expect(app._postWindowMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('the header tooltip keeps its old wording', () => {
|
||||
openGrid(THREE, { detach: false });
|
||||
expect(headerOf('s-a').title).toContain('Drag to move the tile');
|
||||
expect(headerOf('s-a').title).not.toContain('window');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dragging a header out of the window', () => {
|
||||
it('opens the session as a pop-out after a short settle, the tile size, its header under the drop point', async () => {
|
||||
const app = openGrid(FOUR);
|
||||
app.detachSession = vi.fn();
|
||||
startDrag('s-b');
|
||||
endDrag('s-b', { clientX: -50, clientY: 300, screenX: 900, screenY: 500 });
|
||||
expect(app.detachSession).not.toHaveBeenCalled();
|
||||
await settle();
|
||||
expect(app.detachSession).toHaveBeenCalledTimes(1);
|
||||
const [id, placement] = app.detachSession.mock.calls[0];
|
||||
expect(id).toBe('s-b');
|
||||
// The harness's tiles measure 2400x1200; the press was at (40, 10).
|
||||
expect(placement).toEqual({ width: 2400, height: 1200, left: 860, top: 430 });
|
||||
});
|
||||
|
||||
it('a lone tile, or a zoomed one, drags out too (no target inside takes it)', async () => {
|
||||
const app = openGrid(['s-a']);
|
||||
app.detachSession = vi.fn();
|
||||
expect(headerOf('s-a').getAttribute('draggable')).toBe('true');
|
||||
expect(headerOf('s-a').title).toContain('Drag out of the window to open the tile on its own');
|
||||
expect(startDrag('s-a').preventDefault).not.toHaveBeenCalled();
|
||||
endDrag('s-a', { clientX: 5000, clientY: 300, screenX: 5000, screenY: 300 });
|
||||
await settle();
|
||||
expect(app.detachSession).toHaveBeenCalledWith('s-a', expect.any(Object));
|
||||
|
||||
const zoomed = openGrid(FOUR);
|
||||
zoomed.zoomTile('s-c');
|
||||
expect(headerOf('s-c').getAttribute('draggable')).toBe('true');
|
||||
});
|
||||
|
||||
it('let go inside the window on nothing, or taken by a drop target: nothing opens', async () => {
|
||||
const app = openGrid(FOUR);
|
||||
app.detachSession = vi.fn();
|
||||
startDrag('s-b');
|
||||
endDrag('s-b', { clientX: 300, clientY: 300, screenX: 300, screenY: 400 });
|
||||
startDrag('s-c');
|
||||
endDrag('s-c', { clientX: -50, clientY: 300, screenX: 20, screenY: 400 }, 'move');
|
||||
await settle();
|
||||
expect(app.detachSession).not.toHaveBeenCalled();
|
||||
expect(app._tileGrid.ids).toEqual(FOUR);
|
||||
});
|
||||
|
||||
it('no point reported: the page record of the pointer leaving at the edge decides, a dragover inside undoes it', async () => {
|
||||
const app = openGrid(FOUR);
|
||||
app.detachSession = vi.fn();
|
||||
startDrag('s-b');
|
||||
docListener('dragleave')({ relatedTarget: null, clientX: 0, clientY: 500 });
|
||||
endDrag('s-b', { clientX: 0, clientY: 0, screenX: 0, screenY: 0 });
|
||||
await settle();
|
||||
expect(app.detachSession).toHaveBeenCalledTimes(1);
|
||||
// No screen point: placed over the tile instead (no window position known here: size only).
|
||||
expect(app.detachSession.mock.calls[0][1]).toEqual({ width: 2400, height: 1200 });
|
||||
|
||||
app.detachSession.mockClear();
|
||||
startDrag('s-c');
|
||||
docListener('dragleave')({ relatedTarget: null, clientX: 0, clientY: 500 });
|
||||
docListener('dragover')({});
|
||||
endDrag('s-c', { clientX: 0, clientY: 0, screenX: 0, screenY: 0 });
|
||||
await settle();
|
||||
expect(app.detachSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('the page drag listeners go when the drag ends, and when the grid closes mid-drag', () => {
|
||||
const app = openGrid(FOUR);
|
||||
startDrag('s-b');
|
||||
const over = docListener('dragover');
|
||||
const leave = docListener('dragleave');
|
||||
documentRemoveEventListener.mockClear();
|
||||
endDrag('s-b', { clientX: 300, clientY: 300 });
|
||||
expect(documentRemoveEventListener).toHaveBeenCalledWith('dragover', over, true);
|
||||
expect(documentRemoveEventListener).toHaveBeenCalledWith('dragleave', leave, true);
|
||||
|
||||
startDrag('s-c');
|
||||
const over2 = docListener('dragover');
|
||||
documentRemoveEventListener.mockClear();
|
||||
app.closeTileGrid({ reselect: false });
|
||||
expect(documentRemoveEventListener).toHaveBeenCalledWith('dragover', over2, true);
|
||||
expect(app._tileTearOff).toBeNull();
|
||||
});
|
||||
|
||||
it('the real pop-out takes the tile out of the grid (as the tab pop-out does)', async () => {
|
||||
const app = openGrid(FOUR);
|
||||
const win = { closed: false, focus: vi.fn() };
|
||||
windowStub.open = vi.fn(() => win);
|
||||
app.detachedWindows = new Map();
|
||||
app._detachWatchTimers = new Map();
|
||||
app.hasHostWindows = () => false;
|
||||
// No tab strip in this harness.
|
||||
app.$ = () => null;
|
||||
startDrag('s-b');
|
||||
endDrag('s-b', { clientX: -50, clientY: 300, screenX: 900, screenY: 500 });
|
||||
await settle();
|
||||
expect(windowStub.open).toHaveBeenCalledWith(
|
||||
'/session/s-b',
|
||||
'codeman-session-s-b',
|
||||
'width=2400,height=1200,left=860,top=430,menubar=no,toolbar=no,location=no,status=no'
|
||||
);
|
||||
expect(app.detachedSessions.has('s-b')).toBe(true);
|
||||
expect(app._tileGrid.ids).toEqual(['s-a', 's-c', 's-d']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('another window docking the tile this one dragged', () => {
|
||||
it('a drop target in another window took it: the tile goes, the keyboard stays put', () => {
|
||||
const app = openGrid(FOUR, { focusedId: 's-b' });
|
||||
startDrag('s-b');
|
||||
endDrag('s-b', { clientX: -50, clientY: 300 }, 'move');
|
||||
const focusBefore = FakeTile.all.map((t) => t.terminal.focus.mock.calls.length);
|
||||
app._onWindowMessage({ type: 'tile-adopted', id: 's-b', by: 'c-there' });
|
||||
expect(app._tileGrid.ids).toEqual(['s-a', 's-c', 's-d']);
|
||||
expect(app._tileGrid.focusedId).not.toBe('s-b');
|
||||
expect(FakeTile.all.map((t) => t.terminal.focus.mock.calls.length)).toEqual(focusBefore);
|
||||
});
|
||||
|
||||
it('a claim that arrives while the pop-out is settling cancels the pop-out', async () => {
|
||||
const app = openGrid(FOUR);
|
||||
app.detachSession = vi.fn();
|
||||
startDrag('s-c');
|
||||
endDrag('s-c', { clientX: -50, clientY: 300, screenX: 20, screenY: 400 });
|
||||
app._onWindowMessage({ type: 'tile-adopted', id: 's-c', by: 'c-there' });
|
||||
await settle();
|
||||
expect(app.detachSession).not.toHaveBeenCalled();
|
||||
expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-d']);
|
||||
});
|
||||
|
||||
it('the last tile leaves for the welcome screen, never for that session', () => {
|
||||
const app = openGrid(['s-a']);
|
||||
startDrag('s-a');
|
||||
endDrag('s-a', { clientX: -50, clientY: 300 }, 'move');
|
||||
app._onWindowMessage({ type: 'tile-adopted', id: 's-a', by: 'c-there' });
|
||||
expect(app._tileGrid.open).toBe(false);
|
||||
expect(app.activeSessionId).toBeNull();
|
||||
expect(app.showWelcome).toHaveBeenCalled();
|
||||
expect(app.terminal.clear).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a claim for a tile this window did not drag, or one it dragged long ago, is ignored', () => {
|
||||
const app = openGrid(FOUR);
|
||||
app._onWindowMessage({ type: 'tile-adopted', id: 's-b', by: 'c-there' });
|
||||
expect(app._tileGrid.ids).toEqual(FOUR);
|
||||
startDrag('s-b');
|
||||
endDrag('s-b', { clientX: 300, clientY: 300 }, 'move');
|
||||
app._lastTileDrag.at -= 60_000;
|
||||
app._onWindowMessage({ type: 'tile-adopted', id: 's-b', by: 'c-there' });
|
||||
expect(app._tileGrid.ids).toEqual(FOUR);
|
||||
});
|
||||
|
||||
it("this window's own claim (same nonce) is never acted on", () => {
|
||||
const app = openGrid(FOUR);
|
||||
startDrag('s-b');
|
||||
endDrag('s-b', { clientX: 300, clientY: 300 }, 'move');
|
||||
app._onWindowMessage({ type: 'tile-adopted', id: 's-b', by: 'c-here' });
|
||||
expect(app._tileGrid.ids).toEqual(FOUR);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a tile from another window dropped here', () => {
|
||||
it('on a tile: it takes that tile’s place, focused, and every window hears it was docked', () => {
|
||||
const app = openGrid(THREE);
|
||||
const over = foreign('s-other');
|
||||
tileEl('s-b').dispatch('dragover', over);
|
||||
expect(over.preventDefault).toHaveBeenCalled();
|
||||
expect(over.stopPropagation).toHaveBeenCalled();
|
||||
expect(over.dataTransfer.dropEffect).toBe('move');
|
||||
expect(tileEl('s-b').classList.contains('tile--drop-target')).toBe(true);
|
||||
|
||||
tileEl('s-b').dispatch('drop', foreign('s-other'));
|
||||
expect(app._tileGrid.ids).toEqual(['s-a', 's-other', 's-c']);
|
||||
expect(app._tileGrid.focusedId).toBe('s-other');
|
||||
expect(app._postWindowMessage).toHaveBeenCalledWith({ type: 'tile-adopted', id: 's-other', by: 'c-here' });
|
||||
expect(tileEl('s-other').classList.contains('tile--drop-target')).toBe(false);
|
||||
});
|
||||
|
||||
it('on an empty cell: it joins there', () => {
|
||||
const app = openGrid(FIVE);
|
||||
expect(slots()).toHaveLength(1);
|
||||
slots()[0].dispatch('drop', foreign('s-other'));
|
||||
expect(app._tileGrid.cells).toEqual([...FIVE, 's-other']);
|
||||
expect(app._postWindowMessage).toHaveBeenCalledWith({ type: 'tile-adopted', id: 's-other', by: 'c-here' });
|
||||
});
|
||||
|
||||
it('a popped-out session is taken back first and its window asked to close; its stale answers are ignored', () => {
|
||||
const app = openGrid(THREE);
|
||||
app.detachedSessions.add('s-other');
|
||||
app._redock = vi.fn((id: string) => app.detachedSessions.delete(id));
|
||||
app._markDetached = vi.fn();
|
||||
tileEl('s-c').dispatch('drop', foreign('s-other'));
|
||||
expect(app._redock).toHaveBeenCalledWith('s-other');
|
||||
expect(app._tileGrid.ids).toContain('s-other');
|
||||
expect(app._postWindowMessage).toHaveBeenCalledWith({ type: 'close-request', id: 's-other' });
|
||||
expect(app._postWindowMessage).toHaveBeenCalledWith({ type: 'tile-adopted', id: 's-other', by: 'c-here' });
|
||||
// The closing pop-out answering a roll-call on its way out.
|
||||
app._onWindowMessage({ type: 'detached', id: 's-other' });
|
||||
expect(app._markDetached).not.toHaveBeenCalled();
|
||||
expect(app._tileGrid.ids).toContain('s-other');
|
||||
});
|
||||
|
||||
it('an id this page does not know docks nowhere', () => {
|
||||
const app = openGrid(THREE);
|
||||
tileEl('s-b').dispatch('drop', foreign('s-gone'));
|
||||
tileEl('s-b').dispatch('drop', foreign(''));
|
||||
expect(app._tileGrid.ids).toEqual(THREE);
|
||||
expect(app._postWindowMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a drag of this page's own is never read as a foreign one", () => {
|
||||
const app = openGrid(FOUR);
|
||||
startDrag('s-a');
|
||||
const over = foreign('s-other');
|
||||
over.dataTransfer.types = [TILE_TYPE];
|
||||
tileEl('s-d').dispatch('drop', over);
|
||||
// The own drag wins: s-a and s-d trade places, nothing docks.
|
||||
expect(app._tileGrid.ids).toEqual(['s-d', 's-b', 's-c', 's-a']);
|
||||
expect(app._postWindowMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the pop-out window', () => {
|
||||
it('features: the placement when given, the old default otherwise', () => {
|
||||
const app = makeGridApp();
|
||||
expect(app._detachWindowFeatures(null)).toBe('width=960,height=680,menubar=no,toolbar=no,location=no,status=no');
|
||||
expect(app._detachWindowFeatures({ width: 700.4, height: 500, left: -1200, top: 40 })).toBe(
|
||||
'width=700,height=500,left=-1200,top=40,menubar=no,toolbar=no,location=no,status=no'
|
||||
);
|
||||
// A position needs both coordinates; non-numbers never reach the string.
|
||||
expect(app._detachWindowFeatures({ left: 10, top: Number.NaN, width: 'x' })).toBe(
|
||||
'width=960,height=680,menubar=no,toolbar=no,location=no,status=no'
|
||||
);
|
||||
});
|
||||
|
||||
it('from the ⋯ menu (no point): over the tile, at least the minimum size, within the screen', () => {
|
||||
const app = openGrid(FOUR);
|
||||
app.detachSession = vi.fn();
|
||||
windowStub.screenX = 100;
|
||||
windowStub.screenY = 50;
|
||||
windowStub.outerHeight = 1300;
|
||||
windowStub.screen = { availWidth: 1920, availHeight: 1080 };
|
||||
app.detachTile('s-b');
|
||||
expect(app.detachSession).toHaveBeenCalledWith('s-b', { width: 1920, height: 1080, left: 100, top: 90 });
|
||||
});
|
||||
|
||||
it('never with Detach Tiles off, nor for a session that is not a tile', () => {
|
||||
const off = openGrid(FOUR, { detach: false });
|
||||
off.detachSession = vi.fn();
|
||||
expect(off.detachTile('s-b')).toBe(false);
|
||||
const on = openGrid(FOUR);
|
||||
on.detachSession = vi.fn();
|
||||
expect(on.detachTile('s-other')).toBe(false);
|
||||
expect(off.detachSession).not.toHaveBeenCalled();
|
||||
expect(on.detachSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a released pop-out says so, then stops answering roll-calls', () => {
|
||||
const app = makeGridApp();
|
||||
app.isSoloWindow = true;
|
||||
app.soloSessionId = 's-a';
|
||||
app._postWindowMessage = vi.fn();
|
||||
app._closeSoloWindow = vi.fn();
|
||||
app._onWindowMessage({ type: 'roll-call' });
|
||||
expect(app._postWindowMessage).toHaveBeenLastCalledWith({ type: 'detached', id: 's-a' });
|
||||
app._onWindowMessage({ type: 'close-request', id: 's-a' });
|
||||
expect(app._postWindowMessage).toHaveBeenLastCalledWith({ type: 'redocked', id: 's-a' });
|
||||
expect(app._closeSoloWindow).toHaveBeenCalled();
|
||||
app._postWindowMessage.mockClear();
|
||||
app._onWindowMessage({ type: 'roll-call' });
|
||||
expect(app._postWindowMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the gesture overlay helpers', () => {
|
||||
it('what is under a point: the tile, and whether a hand may pick it up', () => {
|
||||
const app = openGrid(FOUR);
|
||||
const inner = new FakeEl();
|
||||
tileEl('s-c').children[1].appendChild(inner);
|
||||
pointHits.at = () => inner;
|
||||
expect(app.tileAtPoint(10, 10)).toBe('s-c');
|
||||
expect(app.canGrabTile('s-c')).toBe(true);
|
||||
pointHits.at = () => null;
|
||||
expect(app.tileAtPoint(10, 10)).toBeNull();
|
||||
|
||||
// Alone with Detach Tiles off it can neither move nor leave.
|
||||
const lone = openGrid(['s-a'], { detach: false });
|
||||
expect(lone.canGrabTile('s-a')).toBe(false);
|
||||
const loneOn = openGrid(['s-a']);
|
||||
expect(loneOn.canGrabTile('s-a')).toBe(true);
|
||||
});
|
||||
|
||||
it("what a drop there would do, by the mouse drag's rules, and doing it", () => {
|
||||
const app = openGrid(FIVE);
|
||||
pointHits.at = () => tileEl('s-c');
|
||||
expect(app.tileDropTargetAt(1, 1, 's-a')).toEqual({ kind: 'tile', id: 's-c', el: tileEl('s-c') });
|
||||
// Its own tile is no target; a popped-out or unknown session has none.
|
||||
expect(app.tileDropTargetAt(1, 1, 's-c')).toBeNull();
|
||||
app.detachedSessions.add('s-other');
|
||||
expect(app.tileDropTargetAt(1, 1, 's-other')).toBeNull();
|
||||
app.detachedSessions.delete('s-other');
|
||||
expect(app.tileDropTargetAt(1, 1, 's-nope')).toBeNull();
|
||||
|
||||
expect(app.dropOnTileTarget('s-a', app.tileDropTargetAt(1, 1, 's-a'))).toBe(true);
|
||||
expect(app._tileGrid.ids).toEqual(['s-c', 's-b', 's-a', 's-d', 's-e']);
|
||||
|
||||
pointHits.at = () => slots()[0];
|
||||
const cell = app.tileDropTargetAt(1, 1, 's-other');
|
||||
expect(cell).toEqual({ kind: 'cell', cell: 5, el: slots()[0] });
|
||||
expect(app.dropOnTileTarget('s-other', cell)).toBe(true);
|
||||
expect(app._tileGrid.cells).toEqual(['s-c', 's-b', 's-a', 's-d', 's-e', 's-other']);
|
||||
});
|
||||
|
||||
it('a tiled session has no target while a tile is zoomed (moving is off)', () => {
|
||||
const app = openGrid(FOUR);
|
||||
app.zoomTile('s-a');
|
||||
pointHits.at = () => tileEl('s-a');
|
||||
expect(app.tileDropTargetAt(1, 1, 's-b')).toBeNull();
|
||||
});
|
||||
|
||||
it('a hand let go outside the grid: the pop-out at that point, the page chrome counted', () => {
|
||||
const app = openGrid(FOUR);
|
||||
app.detachSession = vi.fn();
|
||||
windowStub.screenX = 0;
|
||||
windowStub.screenY = 0;
|
||||
windowStub.outerHeight = 1300;
|
||||
app.detachTileAtPoint('s-d', 500, 20, { offsetX: 100, offsetY: 10 });
|
||||
expect(app.detachSession).toHaveBeenCalledWith('s-d', { width: 2400, height: 1200, left: 400, top: 50 });
|
||||
});
|
||||
|
||||
it('whether a point is over the grid', () => {
|
||||
const app = openGrid(FOUR);
|
||||
expect(app.isOverTileGrid(10, 10)).toBe(true);
|
||||
expect(app.isOverTileGrid(10, 5000)).toBe(false);
|
||||
app.closeTileGrid({ reselect: false });
|
||||
expect(app.isOverTileGrid(10, 10)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
const read = (f: string) => readFileSync(resolve(PUBLIC, f), 'utf8');
|
||||
|
||||
describe("a tile's ⋯ menu", () => {
|
||||
function menuFor(sessionId: string, { tiled = true, detach = true, tabPopOut = false } = {}) {
|
||||
const dom = new JSDOM('<!doctype html><html><body><button id="t">⋯</button></body></html>', {
|
||||
runScripts: 'outside-only',
|
||||
url: 'http://localhost/',
|
||||
});
|
||||
const ctx = dom.getInternalVMContext();
|
||||
vm.runInContext('globalThis.CodemanApp = class CodemanApp {};', ctx);
|
||||
vm.runInContext(read('tab-rail-resize.js'), ctx, { filename: 'tab-rail-resize.js' });
|
||||
const App = (dom.window as unknown as { CodemanApp: { prototype: object } }).CodemanApp;
|
||||
const app = Object.assign(Object.create(App.prototype), {
|
||||
loadAppSettingsFromStorage: () => ({ showTabDetachButton: tabPopOut }),
|
||||
tabDetachButtonEnabled: (s: { showTabDetachButton?: boolean }) => s.showTabDetachButton === true,
|
||||
detachedSessions: new Set(),
|
||||
_tileGrid: { has: () => tiled },
|
||||
_tileDetachAllowed: () => detach,
|
||||
detachTile: vi.fn(),
|
||||
detachSession: vi.fn(),
|
||||
openSessionOptions: vi.fn(),
|
||||
requestCloseSession: vi.fn(),
|
||||
}) as Record<string, ReturnType<typeof vi.fn> & ((...a: unknown[]) => unknown)>;
|
||||
const doc = dom.window.document;
|
||||
app.openTabRailActionMenu(
|
||||
{ preventDefault() {}, stopPropagation() {}, currentTarget: doc.getElementById('t') },
|
||||
sessionId
|
||||
);
|
||||
const items = [...doc.querySelectorAll('.tab-rail-action-menu [role="menuitem"]')] as HTMLButtonElement[];
|
||||
return {
|
||||
app,
|
||||
labels: items.map((b) => b.textContent),
|
||||
click: (label: string) => items.find((b) => b.textContent === label)!.click(),
|
||||
};
|
||||
}
|
||||
|
||||
it('with Detach Tiles on: "Open in a new window" pops the tile out, placed over it', () => {
|
||||
const { app, labels, click } = menuFor('s-b');
|
||||
expect(labels).toEqual(['Session options', 'Open in a new window', 'Close session']);
|
||||
click('Open in a new window');
|
||||
expect(app.detachTile).toHaveBeenCalledWith('s-b');
|
||||
expect(app.detachSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('with it off: the menu is what it was (the tab pop-out setting still adds its own entry)', () => {
|
||||
expect(menuFor('s-b', { detach: false }).labels).toEqual(['Session options', 'Close session']);
|
||||
const tabbed = menuFor('s-b', { detach: false, tabPopOut: true });
|
||||
expect(tabbed.labels).toEqual(['Session options', 'Open in a new window', 'Close session']);
|
||||
tabbed.click('Open in a new window');
|
||||
expect(tabbed.app.detachSession).toHaveBeenCalledWith('s-b');
|
||||
// A tab (not a tile) with Detach Tiles on: only the tab setting decides.
|
||||
expect(menuFor('s-x', { tiled: false }).labels).toEqual(['Session options', 'Close session']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tileDetachEnabled stays per-device and OFF by default', () => {
|
||||
const settingsUi = read('settings-ui.js');
|
||||
const schemas = readFileSync(resolve(import.meta.dirname, '../src/web/schemas.ts'), 'utf8');
|
||||
const index = read('index.html');
|
||||
|
||||
it('is a display key, stripped from the settings PUT, and absent from the .strict() schema', () => {
|
||||
const start = settingsUi.indexOf('const displayKeys = new Set([');
|
||||
expect(settingsUi.slice(start, settingsUi.indexOf('])', start))).toContain("'tileDetachEnabled'");
|
||||
expect(settingsUi).toContain('tileDetachEnabled: _tde,');
|
||||
expect(schemas).not.toContain('tileDetachEnabled');
|
||||
});
|
||||
|
||||
it('loads OFF unless switched on, saves from its switch, and its row shows on desktop only', () => {
|
||||
expect(settingsUi).toContain(
|
||||
"document.getElementById('appSettingsTileDetach').checked = settings.tileDetachEnabled === true;"
|
||||
);
|
||||
expect(settingsUi).toContain("tileDetachEnabled: document.getElementById('appSettingsTileDetach').checked,");
|
||||
expect(settingsUi).toMatch(/tileDetachItem\.style\.display = MobileDetection\.getDeviceType\(\) === 'desktop'/);
|
||||
expect(index).toMatch(/id="appSettingsTileDetachItem"[\s\S]*?<input type="checkbox" id="appSettingsTileDetach">/);
|
||||
// Not `checked` in the markup: OFF is the default.
|
||||
expect(index).not.toMatch(/id="appSettingsTileDetach" checked/);
|
||||
});
|
||||
|
||||
it('a saved change repaints the tile handles (draggable, tooltip)', () => {
|
||||
expect(settingsUi).toContain('this._renderTileChrome?.();');
|
||||
});
|
||||
});
|
||||
@@ -217,6 +217,18 @@ async function exercise() {
|
||||
}
|
||||
pill = 'idle';
|
||||
|
||||
// Detach Tiles on: the drag hint says a tile also leaves the window, and
|
||||
// a tile that cannot move (zoomed) says only that.
|
||||
const settings = app.loadAppSettingsFromStorage;
|
||||
app.loadAppSettingsFromStorage = () => ({ tileDetachEnabled: true });
|
||||
app._renderTileChrome();
|
||||
harvestAll(app, 'detach tiles');
|
||||
app.zoomTile('s-1');
|
||||
harvestAll(app, 'detach tiles, zoomed');
|
||||
app.zoomTile('s-1');
|
||||
app.loadAppSettingsFromStorage = settings;
|
||||
app._renderTileChrome();
|
||||
|
||||
// Zoom and back.
|
||||
app.zoomTile('s-1');
|
||||
harvestAll(app, 'zoomed');
|
||||
@@ -320,6 +332,8 @@ describe('every tile grid string the code puts on screen translates to zh-CN', (
|
||||
'idle 3m\nDrag to move the tile',
|
||||
'needs you 3m\nDrag to move the tile',
|
||||
'exited 3m\nDrag to move the tile',
|
||||
'idle 3m\nDrag to move the tile, or out of the window to open it on its own',
|
||||
'idle 3m\nDrag out of the window to open the tile on its own',
|
||||
'Not attached',
|
||||
'Attach',
|
||||
'Attaching…',
|
||||
@@ -390,6 +404,30 @@ describe('every tile grid string the code puts on screen translates to zh-CN', (
|
||||
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');
|
||||
// Detach Tiles' two hints ride the same pattern.
|
||||
expect(zh.api.t('idle\nDrag out of the window to open the tile on its own')).toBe(
|
||||
'空闲\n拖出窗口可在独立窗口中打开此窗格'
|
||||
);
|
||||
expect(zh.api.t('working <1m\nDrag to move the tile, or out of the window to open it on its own')).toBe(
|
||||
'工作中 <1m\n拖动可移动窗格,拖出窗口可在独立窗口中打开'
|
||||
);
|
||||
});
|
||||
|
||||
it("Detach Tiles' pop-out strings: its title as a handle, and a pop-out that could not close", () => {
|
||||
const strings = [
|
||||
"Drag onto a Codeman window's tiles to dock this session there",
|
||||
'Session moved',
|
||||
'This session is back in a Codeman window. This one can be closed.',
|
||||
'Close window',
|
||||
];
|
||||
// Not vacuous: each is a string the code really shows.
|
||||
const code = read('app.js') + read('tile-grid.js');
|
||||
expect(strings.filter((source) => !code.includes(source))).toEqual([]);
|
||||
const bad = strings.filter((source) => {
|
||||
const text = zh.api.t(source);
|
||||
return text === source || leftover(text.replace(/Codeman/g, '')).length > 0 || en.api.t(source) !== source;
|
||||
});
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
|
||||
it('no UI label sits inside a skipped subtree (where the translator cannot reach it)', () => {
|
||||
@@ -458,6 +496,17 @@ describe('the static markup through the real translator (JSDOM, zh-CN)', () => {
|
||||
expect(tiles!.textContent).toContain('向左 / 右 / 上 / 下移动窗格');
|
||||
expect(tiles!.textContent).toContain('拖动');
|
||||
expect(tiles!.textContent).toContain('窗格的标题栏');
|
||||
// Detach Tiles: a header dragged out of the window.
|
||||
expect(tiles!.textContent).toContain('窗格的标题栏到窗口之外');
|
||||
expect(tiles!.textContent).toContain('在独立窗口中打开窗格(分离窗格)');
|
||||
});
|
||||
|
||||
it('the Detach Tiles row in App Settings: its name and what it does', () => {
|
||||
const row = doc.getElementById('appSettingsTileDetachItem')!;
|
||||
expect(row.querySelector('.set-row-label')!.textContent).toContain('分离窗格');
|
||||
const desc = row.querySelector('.set-row-desc')!.textContent!;
|
||||
expect(desc).toContain('独立窗口');
|
||||
expect(leftover(desc.replace('Codeman', ''))).toEqual([]);
|
||||
});
|
||||
|
||||
it('user text stays as typed: a session name and a group name that are also UI words', () => {
|
||||
|
||||
Reference in New Issue
Block a user