d3fe4aa041
The `close` listener wrote "disconnected" unconditionally, so a connection that failed loudly -- the `error` listener having already written "cannot reach <url> -- is improvise-ws-server running?" -- was immediately followed by a close event replacing it with the vaguer of the two facts. The user saw the least informative thing that happened. This is the watchdog bug's family and deliberately NOT its fix. The watchdog is for silence only, so it is disarmed; suppressing `close` outright would hide a genuine mid-session disconnect, which is the one thing that listener is really for. So the two cases are split rather than one being guarded. A CORRECTION TO THE TICKET'S SUGGESTED MECHANISM: it proposed reading `readyState` at close time, and that cannot work. `readyState` is already CLOSED by the time a close listener runs, whichever happened, and the CloseEvent cannot separate them either -- a refused connection and a mid-session network drop are both code 1006 with `wasClean: false`. The only discriminator is whether `open` ever fired, which the page must remember for itself. Hence the `opened` flag. Both arms go through `setShellFailure`, because a drop before the first frame leaves the watchdog armed and "disconnected" is more precise than the guess it would fire 8s later. The header comment stating the `#status` vocabulary is updated in the same commit: that fact is stated twice, and gotcha 48 says changing it is a grep. VERIFICATION IS READING, and that is the whole budget. There is no browser and no headless DOM here, so this is `node --check` (proved discriminating by appending an unbalanced paren and watching it fail) plus confirmation that every call app.js makes is in the generated .d.ts at the right arity: on_frame/1, on_key_event/5, render_model/0, set_viewport/2, typed_cell/3. Both wasm bundles still build. The listener itself has never been executed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
414 lines
18 KiB
JavaScript
414 lines
18 KiB
JavaScript
// The browser half of improvise-cr3, kept as thin as it can be made, now
|
|
// talking to either of two peers (improvise-djm).
|
|
//
|
|
// The boundary is JSON strings in and out, so this file holds no protocol
|
|
// knowledge at all: it never inspects a Command, never builds a CellId, never
|
|
// decides whether a cell is editable or whether a typed string is a number.
|
|
// Five calls are the whole harness —
|
|
//
|
|
// client.set_viewport(rows, cols) -> the handshake frame, and every resize;
|
|
// also records the window so keys resolve here
|
|
// client.on_frame(json) -> fold one frame, in arrival order
|
|
// client.render_model() -> everything to paint, already decided
|
|
// client.typed_cell(r, c, s) -> the frame for "the user typed s here"
|
|
// client.on_key_event(k,c,a,s,m) -> the frame for a keypress, folded locally
|
|
// first where this client may answer it
|
|
//
|
|
// — and the two rules this file must not break are ordering and framing:
|
|
// frames are folded one at a time in the order they arrive (a cell update
|
|
// that overtakes its labels is dropped, not replayed), and the *first* frame
|
|
// out must be the viewport, because the server builds the snapshot at it.
|
|
//
|
|
// # Two transports, one client
|
|
//
|
|
// The wasm bundle is the SAME artifact either way (improvise-xks6: one
|
|
// envelope pair, two deployments). Only where the strings come from differs,
|
|
// so the difference is confined to `openTransport` and nothing below it knows
|
|
// which peer it has:
|
|
//
|
|
// default an in-page Web Worker hosting the whole App over OPFS
|
|
// (improvise-9x6). No server process; this is what makes a
|
|
// GitHub Pages copy of this directory a working application.
|
|
// ?ws=host:port
|
|
// a websocket to `improvise-ws-server`. That process is how a
|
|
// document opened in the TUI is shared, so the path is kept; the
|
|
// presence of the parameter is the whole selection mechanism,
|
|
// because a second knob naming the same fact is a second thing to
|
|
// get wrong. The scheme defaults to `wss://` — see `socketUrl`.
|
|
//
|
|
// Neither is reachable silently. Every transport writes what it is doing to
|
|
// `#status` — "starting", "connected", "cannot reach …", "disconnected" — so
|
|
// a peer that never arrives reads differently from one that went away.
|
|
// "disconnected" means exactly the second: a socket that opened and then
|
|
// closed. A socket that never opened keeps saying "cannot reach …", because
|
|
// the close event there is the tail of the failure and not news.
|
|
|
|
import init, { Client } from "./pkg/improvise_wasm_client.js";
|
|
|
|
const params = new URLSearchParams(location.search);
|
|
// present => socket, absent => in-page worker. The value is a host:port, and
|
|
// the scheme is NOT the caller's to get wrong: see `socketUrl`.
|
|
const WS_PEER = params.get("ws");
|
|
|
|
/**
|
|
* The websocket URL for `?ws=`, defaulting to TLS.
|
|
*
|
|
* `wss://` is the default and a bare `host:port` gets it, because the address
|
|
* that works from where this page is actually served is the one it should be
|
|
* easiest to write. This page ships to GitHub Pages, which is https, and an
|
|
* https origin refuses a `ws://` address as mixed content — so a `ws://`
|
|
* default would hand the browser a URL that cannot work and report it as a
|
|
* connection failure, which reads like "the server is not running".
|
|
*
|
|
* An explicit `ws://` is honoured only where it can function: a page served
|
|
* over plain http, which is the local `python3 -m http.server` case that pairs
|
|
* with `improvise-ws-server` (which speaks plain `ws://` only — it has no TLS,
|
|
* so reaching it from https means terminating TLS in front of it). From an
|
|
* https page an explicit `ws://` is upgraded rather than passed through, since
|
|
* passing it through can only produce a failure the user cannot act on.
|
|
*/
|
|
function socketUrl(peer) {
|
|
const secure = location.protocol === "https:";
|
|
if (/^wss:\/\//i.test(peer)) return peer;
|
|
if (/^ws:\/\//i.test(peer)) return secure ? peer.replace(/^ws:/i, "wss:") : peer;
|
|
return `${secure ? "wss" : "ws"}://${peer}`;
|
|
}
|
|
|
|
const WS_URL = WS_PEER ? socketUrl(WS_PEER) : null;
|
|
const DOC = params.get("doc") ?? "demo.improv";
|
|
const SEED = params.get("seed") ?? "demo.improv";
|
|
|
|
// Rough character metrics, used only to turn the window size into an extent.
|
|
// Being wrong here costs a row of over- or under-fetch, nothing more.
|
|
const ROW_PX = 26;
|
|
const COL_PX = 110;
|
|
|
|
const el = {
|
|
grid: document.getElementById("grid"),
|
|
categories: document.getElementById("categories"),
|
|
mode: document.getElementById("mode"),
|
|
hint: document.getElementById("hint"),
|
|
status: document.getElementById("status"),
|
|
viewName: document.getElementById("view-name"),
|
|
};
|
|
|
|
await init();
|
|
const client = new Client();
|
|
|
|
// `#status` carries two independent facts — what the transport is doing, and
|
|
// what the document last said — so it composes them rather than letting a
|
|
// repaint quietly erase a connection error.
|
|
let shellStatus = "";
|
|
let modelStatus = "";
|
|
function paintStatus() {
|
|
el.status.textContent = [shellStatus, modelStatus].filter(Boolean).join(" · ");
|
|
}
|
|
function setShellStatus(text) {
|
|
shellStatus = text;
|
|
paintStatus();
|
|
}
|
|
|
|
/** The extent this window can show, in whole cells. */
|
|
function extent() {
|
|
const box = el.grid.getBoundingClientRect();
|
|
return {
|
|
rows: Math.max(1, Math.floor(box.height / ROW_PX) - 1),
|
|
cols: Math.max(1, Math.floor(box.width / COL_PX) - 1),
|
|
};
|
|
}
|
|
|
|
function sendViewport() {
|
|
const { rows, cols } = extent();
|
|
// `client.set_viewport`, NOT the associated `Client.viewport`: the same
|
|
// frame, but it also RECORDS the window. A client learns its window only
|
|
// from what it announced -- `row_labels.len()` is the window clipped to the
|
|
// grid, so the two differ exactly at the bottom edge, which is where the
|
|
// scroll arithmetic matters. Without this the client cannot clamp, so every
|
|
// cursor key goes upstream into `Host::handle`'s empty vec and the arrow
|
|
// keys silently do nothing (improvise-bpq2).
|
|
transport.send(client.set_viewport(rows, cols));
|
|
}
|
|
|
|
// A peer that never answers the handshake. The socket has an `error` event
|
|
// for this; the worker has none that fires, because worker.js catches its own
|
|
// `start()` rejection — so a browser with no OPFS, or a worker bundle that
|
|
// failed to load, would leave the page saying "in-page worker" over an empty
|
|
// grid forever. The watchdog is deliberately about the *first frame* rather
|
|
// than about either peer's own failure modes: it is the one condition that
|
|
// means the same thing in both deployments.
|
|
const HANDSHAKE_TIMEOUT_MS = 8000;
|
|
let watchdog = null;
|
|
function armWatchdog(message) {
|
|
clearTimeout(watchdog);
|
|
watchdog = setTimeout(() => setShellStatus(message), HANDSHAKE_TIMEOUT_MS);
|
|
}
|
|
|
|
// A peer that failed *loudly* has already said something more precise than
|
|
// the watchdog's guess, so the watchdog must be disarmed and not merely
|
|
// pre-empted: it fires later and would overwrite the better message with
|
|
// "did not answer" (PR #13 review). The watchdog is for silence only.
|
|
function setShellFailure(text) {
|
|
clearTimeout(watchdog);
|
|
watchdog = null;
|
|
setShellStatus(text);
|
|
}
|
|
|
|
/** One frame, one fold, in arrival order. Never batch or reorder these. */
|
|
function onFrame(text) {
|
|
clearTimeout(watchdog);
|
|
try {
|
|
client.on_frame(text);
|
|
} catch (err) {
|
|
// `on_frame` returns a Result, which wasm-bindgen throws. Thrown in a bare
|
|
// listener it reached the devtools console and nothing else, and the grid
|
|
// simply stopped updating with nothing on screen saying why (improvise-hwpi).
|
|
setShellStatus("bad frame — the grid has stopped updating (see console)");
|
|
console.error("improvise: frame rejected", text, err);
|
|
return;
|
|
}
|
|
repaint();
|
|
}
|
|
|
|
// ── transports ───────────────────────────────────────────────────────────
|
|
|
|
/** The in-page worker: the standalone deployment, no server process. */
|
|
function workerTransport() {
|
|
const query = new URLSearchParams({ doc: DOC, seed: SEED });
|
|
// `type: "module"` is not optional: worker.js is an ES module (it `import`s
|
|
// the worker bundle), and a classic worker would fail to parse it.
|
|
const worker = new Worker(`./worker.js?${query}`, { type: "module" });
|
|
worker.addEventListener("message", (event) => onFrame(event.data));
|
|
worker.addEventListener("messageerror", () =>
|
|
setShellFailure("the worker sent a frame this page could not read"),
|
|
);
|
|
worker.addEventListener("error", (event) => {
|
|
setShellFailure(`worker failed to start: ${event.message ?? "see console"}`);
|
|
console.error("improvise: worker error", event);
|
|
});
|
|
setShellStatus(`in-page worker · ${DOC}`);
|
|
// Frames posted before the worker has finished `start()` are held by
|
|
// worker.js and replayed in order, so the handshake can go out immediately.
|
|
return { send: (text) => worker.postMessage(text), ready: Promise.resolve() };
|
|
}
|
|
|
|
/** A websocket to `improvise-ws-server`: how a TUI-hosted document is shared. */
|
|
function socketTransport() {
|
|
const socket = new WebSocket(WS_URL);
|
|
const unreachable = `cannot reach ${WS_URL} — is improvise-ws-server running?`;
|
|
// Did this socket ever open? The page has to record that itself, because
|
|
// nothing on the close event carries it: `readyState` is already CLOSED by
|
|
// the time a close listener runs, whichever happened, and a refused
|
|
// connection and a mid-session network drop are BOTH code 1006 with
|
|
// `wasClean: false`. So "never connected" and "was connected and went away"
|
|
// are distinguishable only by remembering.
|
|
let opened = false;
|
|
const ready = new Promise((resolve) => {
|
|
socket.addEventListener("open", () => {
|
|
opened = true;
|
|
setShellStatus(`connected · ${WS_URL}`);
|
|
resolve();
|
|
});
|
|
});
|
|
socket.addEventListener("message", (event) => onFrame(event.data));
|
|
// Without this, a connection that never opens left #status blank and
|
|
// "server not running" read exactly like "still connecting" (improvise-hwpi).
|
|
socket.addEventListener("error", () => setShellFailure(unreachable));
|
|
// A socket that never opened did not "disconnect" — it failed to connect,
|
|
// and the close event is the tail of that failure rather than a second,
|
|
// vaguer thing that happened (improvise-taxx). Writing "disconnected" here
|
|
// unconditionally replaced the `error` listener's precise message with the
|
|
// least informative of the two facts.
|
|
//
|
|
// Same family as the watchdog bug and NOT the same fix: the watchdog is
|
|
// disarmed because it is for silence only, whereas suppressing `close`
|
|
// outright would hide a genuine mid-session disconnect, which is the one
|
|
// thing this listener is really for. Hence the split rather than a guard.
|
|
// Both arms go through `setShellFailure`: a drop before the first frame
|
|
// leaves the watchdog armed, and "disconnected" is more precise than the
|
|
// guess it would fire 8s later.
|
|
socket.addEventListener("close", () =>
|
|
setShellFailure(opened ? "disconnected" : unreachable),
|
|
);
|
|
setShellStatus(`connecting to ${WS_URL}…`);
|
|
return {
|
|
send: (text) => {
|
|
if (socket.readyState === WebSocket.OPEN) socket.send(text);
|
|
},
|
|
ready,
|
|
};
|
|
}
|
|
|
|
const transport = WS_URL ? socketTransport() : workerTransport();
|
|
|
|
// ── painting ─────────────────────────────────────────────────────────────
|
|
|
|
let painted = -1;
|
|
let editing = null; // {row, col} while an input is open, so a repaint cannot steal it
|
|
|
|
function repaint() {
|
|
if (editing) return;
|
|
let model;
|
|
try {
|
|
model = JSON.parse(client.render_model());
|
|
} catch (err) {
|
|
setShellStatus("cannot render the current state (see console)");
|
|
console.error("improvise: render failed", err);
|
|
return;
|
|
}
|
|
if (model.revision === painted) return;
|
|
painted = model.revision;
|
|
|
|
el.mode.textContent = model.mode;
|
|
// The mode's own hint, off `AppMode::hint_text()`. The page spells no
|
|
// keys itself; see `RenderModel::hint`.
|
|
el.hint.textContent = model.hint;
|
|
modelStatus = model.status;
|
|
paintStatus();
|
|
el.viewName.textContent = model.view_name;
|
|
el.grid.replaceChildren(gridTable(model));
|
|
el.categories.replaceChildren(categoryList(model));
|
|
}
|
|
|
|
function gridTable(model) {
|
|
const table = document.createElement("table");
|
|
|
|
const head = table.createTHead().insertRow();
|
|
head.appendChild(document.createElement("th"));
|
|
for (const label of model.col_labels) {
|
|
const th = document.createElement("th");
|
|
th.textContent = label;
|
|
head.appendChild(th);
|
|
}
|
|
|
|
const body = table.createTBody();
|
|
model.rows.forEach((row, r) => {
|
|
const tr = body.insertRow();
|
|
const header = document.createElement("th");
|
|
header.scope = "row";
|
|
header.textContent = row.label;
|
|
tr.appendChild(header);
|
|
|
|
row.cells.forEach((cell, c) => {
|
|
const td = tr.insertCell();
|
|
// A keyboard edit is open on the selected cell when `model.editing` is
|
|
// a string; it holds what has been typed so far. Both facts are
|
|
// decided in Rust (`RenderModel::editing`) — the page does not know
|
|
// which modes are editing modes or which buffer they type into.
|
|
// Without this the mode indicator would say INSERT while every
|
|
// keystroke vanished into a buffer nobody drew.
|
|
const typing = cell.selected && typeof model.editing === "string";
|
|
td.textContent = typing ? model.editing + "\u258f" : cell.text;
|
|
td.classList.toggle("numeric", cell.numeric && !typing);
|
|
td.classList.toggle("error", cell.error);
|
|
td.classList.toggle("selected", cell.selected);
|
|
td.classList.toggle("typing", typing);
|
|
// `editable` is the server's answer relayed through Rust: a stored
|
|
// cell. An aggregate is a drill target and drill is not on the wire
|
|
// yet, so it is simply not offered here.
|
|
td.classList.toggle("editable", cell.editable);
|
|
if (cell.editable) td.addEventListener("click", () => edit(td, r, c, cell.edit_text));
|
|
});
|
|
});
|
|
return table;
|
|
}
|
|
|
|
/** Open an inline editor. Committing sends a frame; the value that comes
|
|
* back is the server's, never the string typed here. */
|
|
function edit(td, row, col, seed) {
|
|
if (editing) return;
|
|
editing = { row, col };
|
|
// `seed` is `RenderCell::edit_text` -- the STORED value, not the drawn
|
|
// one. `td.textContent` has been through the number format, so a stored
|
|
// 1200 reads "1,200" and committing that stores the string "1,200".
|
|
const original = seed;
|
|
const input = document.createElement("input");
|
|
input.value = original;
|
|
td.replaceChildren(input);
|
|
input.focus();
|
|
input.select();
|
|
|
|
// Enter removes the focused <input>, which fires `blur`, which calls
|
|
// `close` again. Harmless before, but only by accident (improvise-hwpi).
|
|
let closed = false;
|
|
const close = (commit) => {
|
|
if (closed) return;
|
|
closed = true;
|
|
editing = null;
|
|
if (commit) {
|
|
const frame = client.typed_cell(row, col, input.value);
|
|
// null only if the grid moved under the editor; the repaint that
|
|
// follows restores whatever the server now says is there.
|
|
if (frame) transport.send(frame);
|
|
}
|
|
td.replaceChildren(original);
|
|
painted = -1;
|
|
repaint();
|
|
};
|
|
|
|
input.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter") close(true);
|
|
else if (e.key === "Escape") close(false);
|
|
// The editor owns the keyboard while it is open; the page-level handler
|
|
// below must not also see these.
|
|
e.stopPropagation();
|
|
});
|
|
input.addEventListener("blur", () => close(false));
|
|
}
|
|
|
|
function categoryList(model) {
|
|
const list = document.createElement("ul");
|
|
for (const row of model.categories) {
|
|
const li = document.createElement("li");
|
|
li.className = row.is_category ? "category" : "item";
|
|
li.classList.toggle("selected", row.selected);
|
|
li.textContent = row.is_category
|
|
? `${row.expanded ? "▾" : "▸"} ${row.label} (${row.item_count}) ${row.axis}`
|
|
: row.label;
|
|
list.appendChild(li);
|
|
}
|
|
return list;
|
|
}
|
|
|
|
// ── the keyboard ─────────────────────────────────────────────────────────
|
|
//
|
|
// `Client::on_key_event` classifies the event and folds it locally where this
|
|
// client may answer it — text entry, and since improvise-bpq2 the grid, panel
|
|
// and tile cursors too, which is why `sendViewport` must call `set_viewport`
|
|
// and not `Client.viewport` (keys.rs carries the list, and what is still
|
|
// upstream). Anything it cannot answer becomes a `ClientFrame::Key`, which
|
|
// `Host::handle` answers with an empty vec: those keys are sent and do
|
|
// nothing. That is settled rather than unfinished — the host must not know
|
|
// the keymap — and closing one means teaching this client, not that arm.
|
|
|
|
window.addEventListener("keydown", (e) => {
|
|
// The inline editor owns the keyboard while it is open. It stops
|
|
// propagation, so this is belt and braces for a focus we did not predict.
|
|
if (editing) return;
|
|
// BrowserKey drops anything held with the platform meta key precisely so
|
|
// Cmd-R does not arrive as a bare `r`; do not claim it here either.
|
|
if (e.metaKey) return;
|
|
|
|
const frame = client.on_key_event(e.key, e.ctrlKey, e.altKey, e.shiftKey, e.metaKey);
|
|
if (frame) {
|
|
// A frame means the page claimed the key, so the browser must not also
|
|
// act on it — an arrow that both moves the cursor and scrolls the page is
|
|
// the worse of the two errors. Combinations under Ctrl or Alt are left
|
|
// alone regardless: those are the browser's own shortcuts, and this
|
|
// client resolves nothing under them anyway.
|
|
if (!e.ctrlKey && !e.altKey) e.preventDefault();
|
|
transport.send(frame);
|
|
}
|
|
repaint();
|
|
});
|
|
|
|
// ── start ────────────────────────────────────────────────────────────────
|
|
|
|
await transport.ready;
|
|
sendViewport(); // the handshake: the snapshot is built at this extent
|
|
armWatchdog(
|
|
WS_URL
|
|
? `${WS_URL} accepted the connection but sent no document`
|
|
: "the in-page worker did not answer — this browser may not allow it (see console)",
|
|
);
|
|
new ResizeObserver(sendViewport).observe(el.grid);
|