c72832f542
The last blocker of improvise-1ey. Mostly assembly: `Client` already had the fold, the key classifier and `set_cell`, `CellDisplay::id` already said what a cell is (ADR 002), and `flatten_cat_tree` already owned the category panel's flattening. What was missing was the three joints between them. `render.rs` is the one that matters. The DOM half is JavaScript and JavaScript here is untested, so every question with a *silent* wrong answer is answered in Rust under test and crosses as decided data: `editable` is a fact about the `CellId` variant (Record only — an Aggregate is a drill target and `Drill` is not on the wire yet, and `None` is "no projected cell", not "aggregate"), the selection is placed by subtracting the scroll offset because `GridCache` is viewport-indexed while the cursor is not, and the category panel is `flatten_cat_tree` rather than a second walk of the cache. `socket.rs` frames what `improvise-ws-server` actually speaks, which is not what `wire.rs` speaks: it has `Viewport` (the handshake — a snapshot built at extent zero is an empty grid), it batches `Commands`, and it has no `Key` variant. The two enums are duplicated because a native crate cannot be a wasm dependency; `improvise-ws-server` is a dev-dependency so the compatibility is checked through its own types, and a companion test proves the check is discriminating on the variant names that would drift. `web/` is the shell, and it is thin on purpose: four string calls in and out, no protocol knowledge, no framework. Gates: 1047 passing (was 1031), clippy and fmt clean, and `cargo check -p improvise-wasm-client --target wasm32-unknown-unknown` clean. Verified end to end headlessly — the wasm-pack artifact driven from node over a real websocket to a real `improvise-ws-server`: 1,200 read, "98765" typed, "98,765" projected back. The DOM painting itself is unverified; there is no headless browser here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
168 lines
5.5 KiB
JavaScript
168 lines
5.5 KiB
JavaScript
// The browser half of improvise-cr3, kept as thin as it can be made.
|
|
//
|
|
// 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.
|
|
// Four calls are the whole harness —
|
|
//
|
|
// Client.viewport(rows, cols) -> the handshake frame, and every resize
|
|
// client.on_frame(json) -> fold one socket 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"
|
|
//
|
|
// — 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.
|
|
|
|
import init, { Client } from "./pkg/improvise_wasm_client.js";
|
|
|
|
const params = new URLSearchParams(location.search);
|
|
const WS_URL = params.get("ws") ?? `ws://${location.hostname || "127.0.0.1"}:8787`;
|
|
|
|
// 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"),
|
|
status: document.getElementById("status"),
|
|
viewName: document.getElementById("view-name"),
|
|
};
|
|
|
|
await init();
|
|
const client = new Client();
|
|
const socket = new WebSocket(WS_URL);
|
|
|
|
/** 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() {
|
|
if (socket.readyState !== WebSocket.OPEN) return;
|
|
const { rows, cols } = extent();
|
|
socket.send(Client.viewport(rows, cols));
|
|
}
|
|
|
|
let painted = -1;
|
|
let editing = null; // {row, col} while an input is open, so a repaint cannot steal it
|
|
|
|
function repaint() {
|
|
const model = JSON.parse(client.render_model());
|
|
if (model.revision === painted || editing) return;
|
|
painted = model.revision;
|
|
|
|
el.mode.textContent = model.mode;
|
|
el.status.textContent = model.status;
|
|
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();
|
|
td.textContent = cell.text;
|
|
td.classList.toggle("numeric", cell.numeric);
|
|
td.classList.toggle("error", cell.error);
|
|
td.classList.toggle("selected", cell.selected);
|
|
// `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));
|
|
});
|
|
});
|
|
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) {
|
|
if (editing) return;
|
|
editing = { row, col };
|
|
const original = td.textContent;
|
|
const input = document.createElement("input");
|
|
input.value = original;
|
|
td.replaceChildren(input);
|
|
input.focus();
|
|
input.select();
|
|
|
|
const close = (commit) => {
|
|
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) socket.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);
|
|
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;
|
|
}
|
|
|
|
socket.addEventListener("open", () => {
|
|
sendViewport(); // the handshake: the snapshot is built at this extent
|
|
el.status.textContent = "connected";
|
|
});
|
|
|
|
socket.addEventListener("message", (event) => {
|
|
// One frame, one fold, in arrival order. Never batch or reorder these.
|
|
client.on_frame(event.data);
|
|
repaint();
|
|
});
|
|
|
|
socket.addEventListener("close", () => {
|
|
el.status.textContent = "disconnected";
|
|
});
|
|
|
|
new ResizeObserver(sendViewport).observe(el.grid);
|