b687a421
feat: web wall — drag reorder, new-session-here
a73x 2026-08-18 18:00
Commit message
web/index.html
| Old | New | ||
|---|---|---|---|
| @@ -70,13 +70,21 @@ | |||
| 70 | color: var(--fg); font: inherit; text-align: center; | 70 | color: var(--fg); font: inherit; text-align: center; |
| 71 | } | 71 | } |
| 72 | .tile.add .err { color: #e07a7a; font-size: 11px; } | 72 | .tile.add .err { color: #e07a7a; font-size: 11px; } |
| 73 | .tile header .close { | 73 | .tile header .close, .tile header .spawn { |
| 74 | padding: 0 6px; border: 0; background: transparent; color: var(--dim); | 74 | padding: 0 6px; border: 0; background: transparent; color: var(--dim); |
| 75 | font: inherit; cursor: pointer; | 75 | font: inherit; cursor: pointer; |
| 76 | } | 76 | } |
| 77 | .tile header .close:hover { color: #e07a7a; } | 77 | .tile header .close:hover { color: #e07a7a; } |
| 78 | /* `+` = another session on this tile's host: an add, so it greens. */ | ||
| 79 | .tile header .spawn:hover { color: #6fce8a; } | ||
| 78 | /* Zoomed = the terminal owns the header; wall management waits. */ | 80 | /* Zoomed = the terminal owns the header; wall management waits. */ |
| 79 | .tile.zoomed header .close { display: none; } | 81 | .tile.zoomed header .close, .tile.zoomed header .spawn { display: none; } |
| 82 | /* Drag to reorder: the dragged tile fades, and the tile under the cursor | ||
| 83 | shows the edge the drop lands on. Both marks are transient — every way | ||
| 84 | out of a drag (dragleave, drop, dragend) clears them. */ | ||
| 85 | .tile.dragging { opacity: 0.5; } | ||
| 86 | .tile.drop-before { border-left: 2px solid #6ab0e0; } | ||
| 87 | .tile.drop-after { border-right: 2px solid #6ab0e0; } | ||
| 80 | /* IME target: focusable, invisible, never display:none (that kills IME). */ | 88 | /* IME target: focusable, invisible, never display:none (that kills IME). */ |
| 81 | #ime { | 89 | #ime { |
| 82 | position: fixed; left: -9999px; top: 0; width: 1px; height: 1px; | 90 | position: fixed; left: -9999px; top: 0; width: 1px; height: 1px; |
web/mux.js
| Old | New | ||
|---|---|---|---|
| @@ -168,7 +168,7 @@ class Tile { | |||
| 168 | // of the DOM, and the node is what a drag moves. | 168 | // of the DOM, and the node is what a drag moves. |
| 169 | this.el.dataset.tileId = String(id); | 169 | this.el.dataset.tileId = String(id); |
| 170 | this.el.innerHTML = | 170 | this.el.innerHTML = |
| 171 | `<header><span class="label"></span><button class="copy-request" type="button">Copy</button><button class="close" type="button">×</button><span class="badge connecting">connecting</span></header>`; | 171 | `<header><span class="label"></span><button class="copy-request" type="button">Copy</button><button class="spawn" type="button" title="new session here">+</button><button class="close" type="button">×</button><span class="badge connecting">connecting</span></header>`; |
| 172 | // The label only: the wall's order is mutable, so a baked-in number | 172 | // The label only: the wall's order is mutable, so a baked-in number |
| 173 | // would lie the moment anything moved. | 173 | // would lie the moment anything moved. |
| 174 | this.el.querySelector('.label').textContent = label; | 174 | this.el.querySelector('.label').textContent = label; |
| @@ -179,6 +179,22 @@ class Tile { | |||
| 179 | // and the hidden IME is what a zoomed tile's keys come from. | 179 | // and the hidden IME is what a zoomed tile's keys come from. |
| 180 | ime.focus(); | 180 | ime.focus(); |
| 181 | }); | 181 | }); |
| 182 | // "New session here": the wall's second door, opened from a tile that | ||
| 183 | // already names the host. It spells a target into the add tile's input | ||
| 184 | // and hands over — one add path, not two. | ||
| 185 | this.el.querySelector('.spawn').addEventListener('click', (ev) => { | ||
| 186 | ev.stopPropagation(); // a spawn is not a zoom | ||
| 187 | // The label is the spelling the user typed, so the host part is that | ||
| 188 | // label minus its trailing `#session` — and only when this tile HAS | ||
| 189 | // a session, since otherwise a '#' can only belong to the host part. | ||
| 190 | const cut = this.session ? this.label.lastIndexOf('#') : -1; | ||
| 191 | const host = cut > 0 ? this.label.slice(0, cut) : this.label; | ||
| 192 | addInput.value = host + '#'; | ||
| 193 | // No ime.focus() here, unlike close and copy: putting focus IN the | ||
| 194 | // add input is the entire point of this button, and it is only ever | ||
| 195 | // visible unzoomed (CSS), so no terminal is waiting on the IME. | ||
| 196 | addInput.focus(); | ||
| 197 | }); | ||
| 182 | this.copyButton = this.el.querySelector('.copy-request'); | 198 | this.copyButton = this.el.querySelector('.copy-request'); |
| 183 | this.copyButton.addEventListener('click', (ev) => { | 199 | this.copyButton.addEventListener('click', (ev) => { |
| 184 | ev.stopPropagation(); | 200 | ev.stopPropagation(); |
| @@ -198,6 +214,45 @@ class Tile { | |||
| 198 | if (this.zoomed) { ev.preventDefault(); this.onWheel(ev); } | 214 | if (this.zoomed) { ev.preventDefault(); this.onWheel(ev); } |
| 199 | // Wall tiles don't scroll (spec): the event falls through to the page. | 215 | // Wall tiles don't scroll (spec): the event falls through to the page. |
| 200 | }, { passive: false }); | 216 | }, { passive: false }); |
| 217 | // Drag to reorder. The whole tile is the handle: the header is a thin | ||
| 218 | // strip and a wall tile has no other inert surface to grab. | ||
| 219 | this.el.draggable = true; | ||
| 220 | this.el.addEventListener('dragstart', (ev) => { | ||
| 221 | // A zoomed tile is a terminal — a drag there would fight the pointer | ||
| 222 | // selection. The rest of the wall is under the shade while anything | ||
| 223 | // is zoomed, so nothing on this page is reorderable then. | ||
| 224 | if (zoomedTile) { ev.preventDefault(); return; } | ||
| 225 | draggingTile = this; | ||
| 226 | ev.dataTransfer.setData('text/plain', String(this.id)); | ||
| 227 | ev.dataTransfer.effectAllowed = 'move'; | ||
| 228 | this.el.classList.add('dragging'); | ||
| 229 | }); | ||
| 230 | this.el.addEventListener('dragend', () => { | ||
| 231 | draggingTile = null; | ||
| 232 | this.el.classList.remove('dragging'); | ||
| 233 | clearDropMarks(); // a drag that ended ANYWHERE leaves no borders | ||
| 234 | }); | ||
| 235 | this.el.addEventListener('dragover', (ev) => { | ||
| 236 | if (!draggingTile || draggingTile === this) return; // no drop on self | ||
| 237 | ev.preventDefault(); // preventDefault on dragover IS "yes, drop here" | ||
| 238 | ev.dataTransfer.dropEffect = 'move'; | ||
| 239 | // Not offsetX: that is relative to the event's target, which here is | ||
| 240 | // usually the canvas or the header. Measure the tile's own box. | ||
| 241 | this.markDropSide(ev); | ||
| 242 | }); | ||
| 243 | this.el.addEventListener('dragleave', () => { | ||
| 244 | this.el.classList.remove('drop-before', 'drop-after'); | ||
| 245 | }); | ||
| 246 | this.el.addEventListener('drop', (ev) => { | ||
| 247 | ev.preventDefault(); | ||
| 248 | this.el.classList.remove('drop-before', 'drop-after'); | ||
| 249 | const src = dragSource(ev); | ||
| 250 | if (!src || src === this.el) return; | ||
| 251 | const r = this.el.getBoundingClientRect(); | ||
| 252 | if (ev.clientX < r.left + r.width / 2) this.el.before(src); | ||
| 253 | else this.el.after(src); | ||
| 254 | putOrder(); // the DOM moved; the hub has not heard yet | ||
| 255 | }); | ||
| 201 | this.canvas.addEventListener('pointerdown', (ev) => this.beginSelection(ev)); | 256 | this.canvas.addEventListener('pointerdown', (ev) => this.beginSelection(ev)); |
| 202 | this.canvas.addEventListener('pointermove', (ev) => this.moveSelection(ev)); | 257 | this.canvas.addEventListener('pointermove', (ev) => this.moveSelection(ev)); |
| 203 | this.canvas.addEventListener('pointerup', (ev) => this.endSelection(ev)); | 258 | this.canvas.addEventListener('pointerup', (ev) => this.endSelection(ev)); |
| @@ -209,6 +264,15 @@ class Tile { | |||
| 209 | }); | 264 | }); |
| 210 | } | 265 | } |
| 211 | 266 | ||
| 267 | // Which half of this tile the cursor is over, as a class the CSS draws | ||
| 268 | // an edge for. Exactly one of the two is ever set: toggle, not add. | ||
| 269 | markDropSide(ev) { | ||
| 270 | const r = this.el.getBoundingClientRect(); | ||
| 271 | const before = ev.clientX < r.left + r.width / 2; | ||
| 272 | this.el.classList.toggle('drop-before', before); | ||
| 273 | this.el.classList.toggle('drop-after', !before); | ||
| 274 | } | ||
| 275 | |||
| 212 | async start() { | 276 | async start() { |
| 213 | // NOT `const { instance }`: WebAssembly.instantiate has two return | 277 | // NOT `const { instance }`: WebAssembly.instantiate has two return |
| 214 | // shapes, and which one you get depends on what you passed. Given | 278 | // shapes, and which one you get depends on what you passed. Given |
| @@ -1403,6 +1467,48 @@ ime.addEventListener('compositionend', (ev) => { | |||
| 1403 | const tilesById = new Map(); | 1467 | const tilesById = new Map(); |
| 1404 | let addTileEl = null; // the add tile, built once in boot, always last | 1468 | let addTileEl = null; // the add tile, built once in boot, always last |
| 1405 | let addInput = null; // its one <input> — a real focus target (see keydown) | 1469 | let addInput = null; // its one <input> — a real focus target (see keydown) |
| 1470 | // The tile whose drag is in flight. dataTransfer is write-only until the | ||
| 1471 | // drop (the spec hides the data from dragover to stop pages snooping what | ||
| 1472 | // is being dragged over them), so the side indicator needs its own handle | ||
| 1473 | // on the source to know it is not pointing at the dragged tile itself. | ||
| 1474 | let draggingTile = null; | ||
| 1475 | |||
| 1476 | function clearDropMarks() { | ||
| 1477 | for (const t of tilesById.values()) t.el.classList.remove('drop-before', 'drop-after'); | ||
| 1478 | addTileEl?.classList.remove('drop-before'); | ||
| 1479 | } | ||
| 1480 | |||
| 1481 | // The element a drop is moving, named by the id the drag carries. Resolved | ||
| 1482 | // through tilesById rather than from draggingTile so a drop whose source | ||
| 1483 | // the wall lost mid-drag (another browser deleted it) moves nothing at all | ||
| 1484 | // instead of moving a detached node. | ||
| 1485 | function dragSource(ev) { | ||
| 1486 | const tile = tilesById.get(Number(ev.dataTransfer.getData('text/plain'))); | ||
| 1487 | return tile ? tile.el : null; | ||
| 1488 | } | ||
| 1489 | |||
| 1490 | // The DOM under #wall IS the order: a drag moves a node, and this tells the | ||
| 1491 | // hub what the nodes now say. Reading the order back out of the page rather | ||
| 1492 | // than keeping an array means there is no second copy to drift from it. | ||
| 1493 | async function putOrder() { | ||
| 1494 | const wallEl = document.getElementById('wall'); | ||
| 1495 | const ids = [...wallEl.children] | ||
| 1496 | .filter((el) => el !== addTileEl) | ||
| 1497 | .map((el) => el.dataset.tileId) | ||
| 1498 | .filter((id) => id !== undefined); | ||
| 1499 | try { | ||
| 1500 | const res = await fetch('/tiles', { method: 'PUT', body: ids.join(',') }); | ||
| 1501 | // 409 = we dragged against a stale wall (another browser added or | ||
| 1502 | // removed a tile since this page's last refetch). The hub kept its own | ||
| 1503 | // order and answered with it; refetching is what agrees us with the | ||
| 1504 | // truth. 400 and 500 take the same road for the same reason: the page | ||
| 1505 | // has already moved a node the hub may not have accepted. | ||
| 1506 | if (res.status !== 204) await refetchWall(); | ||
| 1507 | } catch (err) { | ||
| 1508 | console.error('mux wall: reorder failed', err); | ||
| 1509 | await refetchWall().catch((e) => console.error('mux wall: refetch failed', e)); | ||
| 1510 | } | ||
| 1511 | } | ||
| 1406 | 1512 | ||
| 1407 | window.addEventListener('resize', () => { | 1513 | window.addEventListener('resize', () => { |
| 1408 | // The zoomed tile may claim a new grid; every wall tile just re-fits to | 1514 | // The zoomed tile may claim a new grid; every wall tile just re-fits to |
| @@ -1448,8 +1554,10 @@ async function refetchWall() { | |||
| 1448 | tilesById.set(t.id, tile); | 1554 | tilesById.set(t.id, tile); |
| 1449 | // start() is async: instantiate or mux_init can fail, and an unhandled | 1555 | // start() is async: instantiate or mux_init can fail, and an unhandled |
| 1450 | // rejection left the tile stuck on 'connecting' with the reason only | 1556 | // rejection left the tile stuck on 'connecting' with the reason only |
| 1451 | // in the console's rejection noise. | 1557 | // in the console's rejection noise. The settled promise is kept: |
| 1452 | tile.start().catch((err) => { | 1558 | // "new session here" zooms the tile it just added, and zoom() refuses |
| 1559 | // a tile whose core and socket start() has not installed yet. | ||
| 1560 | tile.startup = tile.start().catch((err) => { | ||
| 1453 | tile.setStatus('gone', 'gone'); | 1561 | tile.setStatus('gone', 'gone'); |
| 1454 | console.error(`mux tile ${t.id} (${t.label}): start failed`, err); | 1562 | console.error(`mux tile ${t.id} (${t.label}): start failed`, err); |
| 1455 | }); | 1563 | }); |
| @@ -1493,6 +1601,23 @@ function buildAddTile() { | |||
| 1493 | // The whole tile is the affordance; a click anywhere in it lands in the | 1601 | // The whole tile is the affordance; a click anywhere in it lands in the |
| 1494 | // input and goes no further (nothing above it should read this click). | 1602 | // input and goes no further (nothing above it should read this click). |
| 1495 | el.addEventListener('click', (ev) => { ev.stopPropagation(); input.focus(); }); | 1603 | el.addEventListener('click', (ev) => { ev.stopPropagation(); input.focus(); }); |
| 1604 | // The add tile never moves and is never dragged, but it is the wall's | ||
| 1605 | // last child — so dropping on it is the only way to say "put this at the | ||
| 1606 | // end", and it means exactly "insert before me". One side only: there is | ||
| 1607 | // nothing after it to land on. | ||
| 1608 | el.addEventListener('dragover', (ev) => { | ||
| 1609 | if (!draggingTile) return; | ||
| 1610 | ev.preventDefault(); | ||
| 1611 | ev.dataTransfer.dropEffect = 'move'; | ||
| 1612 | el.classList.add('drop-before'); | ||
| 1613 | }); | ||
| 1614 | el.addEventListener('dragleave', () => el.classList.remove('drop-before')); | ||
| 1615 | el.addEventListener('drop', (ev) => { | ||
| 1616 | ev.preventDefault(); | ||
| 1617 | el.classList.remove('drop-before'); | ||
| 1618 | const src = dragSource(ev); | ||
| 1619 | if (src) { el.before(src); putOrder(); } | ||
| 1620 | }); | ||
| 1496 | input.addEventListener('keydown', async (ev) => { | 1621 | input.addEventListener('keydown', async (ev) => { |
| 1497 | if (ev.key !== 'Enter') return; | 1622 | if (ev.key !== 'Enter') return; |
| 1498 | ev.preventDefault(); | 1623 | ev.preventDefault(); |
| @@ -1507,8 +1632,23 @@ function buildAddTile() { | |||
| 1507 | err.textContent = (await res.text()).trim() || `hub said ${res.status}`; | 1632 | err.textContent = (await res.text()).trim() || `hub said ${res.status}`; |
| 1508 | return; | 1633 | return; |
| 1509 | } | 1634 | } |
| 1635 | // The hub answers {"id":N}. Parsed defensively: the tile IS added, so | ||
| 1636 | // a body we cannot read must not be reported back as a failed add. | ||
| 1637 | const body = await res.json().catch(() => null); | ||
| 1510 | input.value = ''; | 1638 | input.value = ''; |
| 1511 | await refetchWall(); | 1639 | await refetchWall(); |
| 1640 | // A wall tile attaches 1x1 passive and the daemon refuses to CREATE a | ||
| 1641 | // session at that size, so a tile added for a session that does not | ||
| 1642 | // exist yet would sit on a badge that never resolves. Zoom attaches at | ||
| 1643 | // the real size, and that is what creates it — "new session here" has | ||
| 1644 | // to land the user in the shell they asked for. | ||
| 1645 | const fresh = body === null ? null : tilesById.get(body.id); | ||
| 1646 | if (fresh) { | ||
| 1647 | await fresh.startup; // refetchWall fires start(), it does not await it | ||
| 1648 | // Re-checked after the await: a concurrent refetch may have retired | ||
| 1649 | // this tile, and a zoom the user did meanwhile is theirs to keep. | ||
| 1650 | if (tilesById.get(body.id) === fresh && !zoomedTile) zoom(fresh); | ||
| 1651 | } | ||
| 1512 | } catch (e) { | 1652 | } catch (e) { |
| 1513 | // An over-long body is dropped without any reply (the hub's cap), so | 1653 | // An over-long body is dropped without any reply (the hub's cap), so |
| 1514 | // fetch rejects rather than resolving. Silence would read as success. | 1654 | // fetch rejects rather than resolving. Silence would read as success. |