f589c333
feat: web wall management — reconcile by id, add tile, remove tile
a73x 2026-08-18 18:00
Commit message
web/index.html
| Old | New | ||
|---|---|---|---|
| @@ -59,6 +59,24 @@ | |||
| 59 | display: none; position: fixed; inset: 0; z-index: 10; background: #000a; | 59 | display: none; position: fixed; inset: 0; z-index: 10; background: #000a; |
| 60 | } | 60 | } |
| 61 | #shade.on { display: block; } | 61 | #shade.on { display: block; } |
| 62 | /* The add tile: the wall's one standing control. A tile-shaped door, | ||
| 63 | not a toolbar — the wall stays a wall. */ | ||
| 64 | .tile.add { | ||
| 65 | min-height: 80px; align-items: center; justify-content: center; | ||
| 66 | cursor: text; border-style: dashed; | ||
| 67 | } | ||
| 68 | .tile.add input { | ||
| 69 | width: 90%; background: transparent; border: 0; outline: none; | ||
| 70 | color: var(--fg); font: inherit; text-align: center; | ||
| 71 | } | ||
| 72 | .tile.add .err { color: #e07a7a; font-size: 11px; } | ||
| 73 | .tile header .close { | ||
| 74 | padding: 0 6px; border: 0; background: transparent; color: var(--dim); | ||
| 75 | font: inherit; cursor: pointer; | ||
| 76 | } | ||
| 77 | .tile header .close:hover { color: #e07a7a; } | ||
| 78 | /* Zoomed = the terminal owns the header; wall management waits. */ | ||
| 79 | .tile.zoomed header .close { display: none; } | ||
| 62 | /* IME target: focusable, invisible, never display:none (that kills IME). */ | 80 | /* IME target: focusable, invisible, never display:none (that kills IME). */ |
| 63 | #ime { | 81 | #ime { |
| 64 | position: fixed; left: -9999px; top: 0; width: 1px; height: 1px; | 82 | position: fixed; left: -9999px; top: 0; width: 1px; height: 1px; |
web/mux.js
| Old | New | ||
|---|---|---|---|
| @@ -117,9 +117,13 @@ const METRICS = (() => { | |||
| 117 | let compiledCore = null; // one compile, one instance per tile | 117 | let compiledCore = null; // one compile, one instance per tile |
| 118 | 118 | ||
| 119 | class Tile { | 119 | class Tile { |
| 120 | constructor(idx, label, wallEl, session) { | 120 | constructor(id, label, wallEl, session) { |
| 121 | this.idx = idx; | 121 | // The hub's tile id, stable for the run and NOT a position: the wall is |
| 122 | // reorderable, so an index would name a different tile after one drag. | ||
| 123 | this.id = id; | ||
| 122 | this.label = label; | 124 | this.label = label; |
| 125 | this.dead = false; // shutdown ran: this tile reconnects no more | ||
| 126 | this.reconnectTimer = null; | ||
| 123 | // The daemon session this tile attaches to, '' for the default. It is | 127 | // The daemon session this tile attaches to, '' for the default. It is |
| 124 | // the hub's `TARGET#NAME` suffix, handed over by /tiles rather than dug | 128 | // the hub's `TARGET#NAME` suffix, handed over by /tiles rather than dug |
| 125 | // back out of the label — see sendAttach for what happens to it. | 129 | // back out of the label — see sendAttach for what happens to it. |
| @@ -160,9 +164,21 @@ class Tile { | |||
| 160 | 164 | ||
| 161 | this.el = document.createElement('div'); | 165 | this.el = document.createElement('div'); |
| 162 | this.el.className = 'tile'; | 166 | this.el.className = 'tile'; |
| 167 | // The id lives on the node too: reorder reads the wall's order back out | ||
| 168 | // of the DOM, and the node is what a drag moves. | ||
| 169 | this.el.dataset.tileId = String(id); | ||
| 163 | this.el.innerHTML = | 170 | this.el.innerHTML = |
| 164 | `<header><span class="label"></span><button class="copy-request" type="button">Copy</button><span class="badge connecting">connecting</span></header>`; | 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>`; |
| 165 | this.el.querySelector('.label').textContent = `${idx}: ${label}`; | 172 | // The label only: the wall's order is mutable, so a baked-in number |
| 173 | // would lie the moment anything moved. | ||
| 174 | this.el.querySelector('.label').textContent = label; | ||
| 175 | this.el.querySelector('.close').addEventListener('click', (ev) => { | ||
| 176 | ev.stopPropagation(); // a close is not a zoom | ||
| 177 | removeTile(this.id); | ||
| 178 | // Same reason the copy control does it: clicking a button takes focus, | ||
| 179 | // and the hidden IME is what a zoomed tile's keys come from. | ||
| 180 | ime.focus(); | ||
| 181 | }); | ||
| 166 | this.copyButton = this.el.querySelector('.copy-request'); | 182 | this.copyButton = this.el.querySelector('.copy-request'); |
| 167 | this.copyButton.addEventListener('click', (ev) => { | 183 | this.copyButton.addEventListener('click', (ev) => { |
| 168 | ev.stopPropagation(); | 184 | ev.stopPropagation(); |
| @@ -216,7 +232,7 @@ class Tile { | |||
| 216 | // re-attach on `up` still quotes have_seq/have_epoch and resumes. | 232 | // re-attach on `up` still quotes have_seq/have_epoch and resumes. |
| 217 | connect() { | 233 | connect() { |
| 218 | this.wsOpened = false; | 234 | this.wsOpened = false; |
| 219 | this.ws = new WebSocket(`ws://${location.host}/ws/${this.idx}`); | 235 | this.ws = new WebSocket(`ws://${location.host}/ws/${this.id}`); |
| 220 | this.ws.binaryType = 'arraybuffer'; | 236 | this.ws.binaryType = 'arraybuffer'; |
| 221 | this.ws.onopen = () => { | 237 | this.ws.onopen = () => { |
| 222 | this.cancelScrollRequest(); | 238 | this.cancelScrollRequest(); |
| @@ -236,6 +252,10 @@ class Tile { | |||
| 236 | // onerror always precedes onclose; the badge is decided in one place. | 252 | // onerror always precedes onclose; the badge is decided in one place. |
| 237 | this.ws.onerror = () => {}; | 253 | this.ws.onerror = () => {}; |
| 238 | this.ws.onclose = () => { | 254 | this.ws.onclose = () => { |
| 255 | // FIRST: a shut-down tile's socket closes BECAUSE it was shut down. | ||
| 256 | // Reconnecting it would resurrect a tile the wall no longer has, and | ||
| 257 | // a status change would paint a node already out of the document. | ||
| 258 | if (this.dead) return; | ||
| 239 | this.cancelScrollRequest(); | 259 | this.cancelScrollRequest(); |
| 240 | this.clearSelection(); | 260 | this.clearSelection(); |
| 241 | this.wsFailures = this.wsOpened ? 0 : this.wsFailures + 1; | 261 | this.wsFailures = this.wsOpened ? 0 : this.wsFailures + 1; |
| @@ -243,10 +263,28 @@ class Tile { | |||
| 243 | this.setStatus(dead ? 'gone' : 'reconnecting', dead ? 'gone' : 'reconnecting'); | 263 | this.setStatus(dead ? 'gone' : 'reconnecting', dead ? 'gone' : 'reconnecting'); |
| 244 | const wait = this.wsBackoffMs; | 264 | const wait = this.wsBackoffMs; |
| 245 | this.wsBackoffMs = nextBackoffMs(this.wsBackoffMs); | 265 | this.wsBackoffMs = nextBackoffMs(this.wsBackoffMs); |
| 246 | setTimeout(() => this.connect(), wait); | 266 | this.reconnectTimer = setTimeout(() => { |
| 267 | this.reconnectTimer = null; | ||
| 268 | this.connect(); | ||
| 269 | }, wait); | ||
| 247 | }; | 270 | }; |
| 248 | } | 271 | } |
| 249 | 272 | ||
| 273 | // The tile is gone from the wall (the hub said so). Everything this tile | ||
| 274 | // owns that outlives its DOM node — the socket, the pending reconnect — | ||
| 275 | // is cut here, because a timer that fires later would dial a `/ws/<id>` | ||
| 276 | // the hub answers with a plain 404 and narrate it onto a detached node. | ||
| 277 | shutdown() { | ||
| 278 | this.dead = true; | ||
| 279 | if (zoomedTile === this) unzoom(); // never leave the shade over nothing | ||
| 280 | if (this.reconnectTimer !== null) clearTimeout(this.reconnectTimer); | ||
| 281 | this.reconnectTimer = null; | ||
| 282 | this.cancelScrollRequest(); | ||
| 283 | this.clearSelection(false); | ||
| 284 | this.ws?.close(); | ||
| 285 | this.el.remove(); | ||
| 286 | } | ||
| 287 | |||
| 250 | // Every recovery from a bad frame answers with a re-attach, and the | 288 | // Every recovery from a bad frame answers with a re-attach, and the |
| 251 | // daemon answers THAT with the same snapshot that just failed — so | 289 | // daemon answers THAT with the same snapshot that just failed — so |
| 252 | // "recover and retry" is a flood unless it is bounded. Bounded on the | 290 | // "recover and retry" is a flood unless it is bounded. Bounded on the |
| @@ -260,7 +298,7 @@ class Tile { | |||
| 260 | replayFailed(why, terminal) { | 298 | replayFailed(why, terminal) { |
| 261 | if (this.replayDead) return; | 299 | if (this.replayDead) return; |
| 262 | this.replayFailures++; | 300 | this.replayFailures++; |
| 263 | console.warn(`mux tile ${this.idx}: ${why} (replay failure ${this.replayFailures})`); | 301 | console.warn(`mux tile ${this.id}: ${why} (replay failure ${this.replayFailures})`); |
| 264 | if (this.replayFailures >= GONE_AFTER_FAILURES) { | 302 | if (this.replayFailures >= GONE_AFTER_FAILURES) { |
| 265 | this.replayDead = true; // sendAttach is gated on this | 303 | this.replayDead = true; // sendAttach is gated on this |
| 266 | this.setStatus('stuck', terminal); | 304 | this.setStatus('stuck', terminal); |
| @@ -333,6 +371,9 @@ class Tile { | |||
| 333 | // ONE gate for every attach, wherever it comes from — the `up` | 371 | // ONE gate for every attach, wherever it comes from — the `up` |
| 334 | // control message included. A tile that gave up on replaying must | 372 | // control message included. A tile that gave up on replaying must |
| 335 | // not be talked back into asking for the same frame again. | 373 | // not be talked back into asking for the same frame again. |
| 374 | // A shut-down tile has no wall to talk for: replayFailed's retry may | ||
| 375 | // already be scheduled, and would otherwise repaint a detached node. | ||
| 376 | if (this.dead) return; | ||
| 336 | this.cancelScrollRequest(); | 377 | this.cancelScrollRequest(); |
| 337 | this.clearSelection(); | 378 | this.clearSelection(); |
| 338 | if (this.replayDead) return; | 379 | if (this.replayDead) return; |
| @@ -1275,6 +1316,9 @@ shade.addEventListener('click', unzoom); | |||
| 1275 | 1316 | ||
| 1276 | // Keys go ONLY to the zoomed tile — no zoom, no bytes (spec). | 1317 | // Keys go ONLY to the zoomed tile — no zoom, no bytes (spec). |
| 1277 | document.addEventListener('keydown', (ev) => { | 1318 | document.addEventListener('keydown', (ev) => { |
| 1319 | // The add input is a real text field: while it has focus its keys are the | ||
| 1320 | // user spelling a target, not input for any terminal. | ||
| 1321 | if (addInput && document.activeElement === addInput) return; | ||
| 1278 | const t = zoomedTile; | 1322 | const t = zoomedTile; |
| 1279 | if (!t) return; | 1323 | if (!t) return; |
| 1280 | if (ev.isComposing) return; // IME owns it; compositionend delivers | 1324 | if (ev.isComposing) return; // IME owns it; compositionend delivers |
| @@ -1353,32 +1397,132 @@ ime.addEventListener('compositionend', (ev) => { | |||
| 1353 | if (zoomedTile && ev.data) zoomedTile.sendText(ev.data); | 1397 | if (zoomedTile && ev.data) zoomedTile.sendText(ev.data); |
| 1354 | ime.value = ''; | 1398 | ime.value = ''; |
| 1355 | }); | 1399 | }); |
| 1356 | const tiles = []; | 1400 | // --- the wall --- |
| 1401 | // Keyed by the hub's tile id, never by position: /tiles is reorderable and | ||
| 1402 | // two entries can share a label. The DOM under #wall carries the order. | ||
| 1403 | const tilesById = new Map(); | ||
| 1404 | 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) | ||
| 1406 | |||
| 1357 | window.addEventListener('resize', () => { | 1407 | window.addEventListener('resize', () => { |
| 1358 | // The zoomed tile may claim a new grid; every wall tile just re-fits to | 1408 | // The zoomed tile may claim a new grid; every wall tile just re-fits to |
| 1359 | // the width the reflowed grid gave it. | 1409 | // the width the reflowed grid gave it. |
| 1360 | zoomedTile?.sendResizeIfDiffers(); | 1410 | zoomedTile?.sendResizeIfDiffers(); |
| 1361 | for (const t of tiles) if (t !== zoomedTile) t.reflow(); | 1411 | for (const t of tilesById.values()) if (t !== zoomedTile) t.reflow(); |
| 1362 | zoomedTile?.reflow(); | 1412 | zoomedTile?.reflow(); |
| 1363 | }); | 1413 | }); |
| 1364 | 1414 | ||
| 1415 | // `GET /tiles` is the truth about the wall; this makes the page match it. | ||
| 1416 | // Every mutation (add, remove, reorder) goes to the hub and then comes back | ||
| 1417 | // through here, so there is one shape of "what the wall is" and no local | ||
| 1418 | // guess to drift from it. | ||
| 1419 | // | ||
| 1420 | // Two refetches can be in flight at once (Enter twice quickly, a reorder | ||
| 1421 | // overlapping an add) and nothing orders their responses. The last one | ||
| 1422 | // STARTED is the only one allowed to reconcile: an older cfg landing last | ||
| 1423 | // would shut down a tile the newer cfg still contains, and the tile would | ||
| 1424 | // vanish from the wall until something else refetched. One counter, checked | ||
| 1425 | // after every await that precedes a side effect. | ||
| 1426 | let refetchGen = 0; | ||
| 1427 | async function refetchWall() { | ||
| 1428 | const gen = ++refetchGen; | ||
| 1429 | const res = await fetch('/tiles'); | ||
| 1430 | if (gen !== refetchGen) return; | ||
| 1431 | // A 500 answers in plain text, not JSON: parsing it would reject and take | ||
| 1432 | // the caller's await with it. The wall simply keeps what it has. | ||
| 1433 | if (!res.ok) { console.error(`mux wall: GET /tiles said ${res.status}`); return; } | ||
| 1434 | const cfg = await res.json(); | ||
| 1435 | if (gen !== refetchGen) return; | ||
| 1436 | const wallEl = document.getElementById('wall'); | ||
| 1437 | const liveIds = new Set(cfg.map((t) => t.id)); | ||
| 1438 | for (const [id, tile] of tilesById) { | ||
| 1439 | if (!liveIds.has(id)) { tilesById.delete(id); tile.shutdown(); } | ||
| 1440 | } | ||
| 1441 | for (const t of cfg) { | ||
| 1442 | let tile = tilesById.get(t.id); | ||
| 1443 | if (!tile) { | ||
| 1444 | // Each entry is {id, label, session}: the label is what the tile calls | ||
| 1445 | // itself, the session is what it attaches to. Two entries can name the | ||
| 1446 | // same host and differ only in the session — that is the whole point. | ||
| 1447 | tile = new Tile(t.id, t.label, wallEl, t.session); | ||
| 1448 | tilesById.set(t.id, tile); | ||
| 1449 | // start() is async: instantiate or mux_init can fail, and an unhandled | ||
| 1450 | // rejection left the tile stuck on 'connecting' with the reason only | ||
| 1451 | // in the console's rejection noise. | ||
| 1452 | tile.start().catch((err) => { | ||
| 1453 | tile.setStatus('gone', 'gone'); | ||
| 1454 | console.error(`mux tile ${t.id} (${t.label}): start failed`, err); | ||
| 1455 | }); | ||
| 1456 | } | ||
| 1457 | // appendChild MOVES an attached node: walking cfg in order lays the | ||
| 1458 | // wall out in wall order, existing sockets undisturbed. | ||
| 1459 | wallEl.appendChild(tile.el); | ||
| 1460 | } | ||
| 1461 | if (addTileEl) wallEl.appendChild(addTileEl); // the door stays last | ||
| 1462 | } | ||
| 1463 | |||
| 1464 | async function removeTile(id) { | ||
| 1465 | try { | ||
| 1466 | const res = await fetch(`/tiles/${id}`, { method: 'DELETE' }); | ||
| 1467 | // 404 is not worth shouting about — the wall already lost it, and the | ||
| 1468 | // refetch below is what agrees the page with that. | ||
| 1469 | if (!res.ok && res.status !== 404) | ||
| 1470 | console.error(`mux remove tile ${id}: hub said ${res.status}`); | ||
| 1471 | } catch (err) { | ||
| 1472 | console.error(`mux remove tile ${id}: request failed`, err); | ||
| 1473 | } | ||
| 1474 | // The close button calls this and drops the promise: a rejection here | ||
| 1475 | // would be an unhandled one, reported as a page error with no owner. | ||
| 1476 | await refetchWall().catch((err) => { | ||
| 1477 | console.error('mux wall: refetch failed', err); | ||
| 1478 | }); | ||
| 1479 | } | ||
| 1480 | |||
| 1481 | // The add tile: a tile-shaped door at the end of the wall. It is NOT a Tile | ||
| 1482 | // — no core, no socket, never zooms — so the zoom click path (bound per | ||
| 1483 | // Tile) cannot reach it. | ||
| 1484 | function buildAddTile() { | ||
| 1485 | const el = document.createElement('div'); | ||
| 1486 | el.className = 'tile add'; | ||
| 1487 | el.innerHTML = | ||
| 1488 | `<input type="text" name="target" aria-label="add a tile"` + | ||
| 1489 | ` autocomplete="off" autocapitalize="off" spellcheck="false"` + | ||
| 1490 | ` placeholder="HOST[#SESSION] | quic://HOST:PORT | --sock PATH"><span class="err"></span>`; | ||
| 1491 | const input = el.querySelector('input'); | ||
| 1492 | const err = el.querySelector('.err'); | ||
| 1493 | // 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). | ||
| 1495 | el.addEventListener('click', (ev) => { ev.stopPropagation(); input.focus(); }); | ||
| 1496 | input.addEventListener('keydown', async (ev) => { | ||
| 1497 | if (ev.key !== 'Enter') return; | ||
| 1498 | ev.preventDefault(); | ||
| 1499 | const target = input.value.trim(); | ||
| 1500 | if (!target) return; | ||
| 1501 | err.textContent = ''; | ||
| 1502 | try { | ||
| 1503 | const res = await fetch('/tiles', { method: 'POST', body: target }); | ||
| 1504 | if (!res.ok) { | ||
| 1505 | // The hub's own words for why, kept next to the spelling that | ||
| 1506 | // earned them — the value stays so it can be edited, not retyped. | ||
| 1507 | err.textContent = (await res.text()).trim() || `hub said ${res.status}`; | ||
| 1508 | return; | ||
| 1509 | } | ||
| 1510 | input.value = ''; | ||
| 1511 | await refetchWall(); | ||
| 1512 | } catch (e) { | ||
| 1513 | // 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. | ||
| 1515 | err.textContent = 'request failed'; | ||
| 1516 | console.error('mux add tile: POST /tiles failed', e); | ||
| 1517 | } | ||
| 1518 | }); | ||
| 1519 | return el; | ||
| 1520 | } | ||
| 1521 | |||
| 1365 | // --- boot --- | 1522 | // --- boot --- |
| 1366 | (async function boot() { | 1523 | (async function boot() { |
| 1367 | compiledCore = await WebAssembly.compileStreaming(fetch('/mux_core.wasm')); | 1524 | compiledCore = await WebAssembly.compileStreaming(fetch('/mux_core.wasm')); |
| 1368 | // Each entry is {label, session}: the label is what the tile calls | 1525 | addTileEl = buildAddTile(); |
| 1369 | // itself, the session is what it attaches to. Two entries can name the | 1526 | addInput = addTileEl.querySelector('input'); |
| 1370 | // same host and differ only in the session — that is the whole point. | 1527 | await refetchWall(); |
| 1371 | const tilesCfg = await (await fetch('/tiles')).json(); | ||
| 1372 | const wall = document.getElementById('wall'); | ||
| 1373 | for (let i = 0; i < tilesCfg.length; i++) { | ||
| 1374 | const tile = new Tile(i, tilesCfg[i].label, wall, tilesCfg[i].session); | ||
| 1375 | tiles.push(tile); | ||
| 1376 | // start() is async: instantiate or mux_init can fail, and an unhandled | ||
| 1377 | // rejection left the tile stuck on 'connecting' with the reason only | ||
| 1378 | // in the console's rejection noise. | ||
| 1379 | tile.start().catch((err) => { | ||
| 1380 | tile.setStatus('gone', 'gone'); | ||
| 1381 | console.error(`mux tile ${i} (${tilesCfg[i].label}): start failed`, err); | ||
| 1382 | }); | ||
| 1383 | } | ||
| 1384 | })(); | 1528 | })(); |