Merge remote-tracking branch 'gh/main' into worktree-agent-a97453f7fa65d51b9
This commit is contained in:
+42
-8
@@ -7,6 +7,7 @@ import {
|
||||
HANDSHAKE_TIMEOUT_MS,
|
||||
ROW_PX,
|
||||
createShell,
|
||||
documentTitle,
|
||||
extentOf,
|
||||
frameCostOf,
|
||||
gridPlan,
|
||||
@@ -35,6 +36,7 @@ const el = {
|
||||
help: document.getElementById("help"),
|
||||
status: document.getElementById("status"),
|
||||
viewName: document.getElementById("view-name"),
|
||||
docName: document.getElementById("doc-name"),
|
||||
};
|
||||
|
||||
await init();
|
||||
@@ -60,8 +62,8 @@ function sendViewport() {
|
||||
const box = el.grid.getBoundingClientRect();
|
||||
const metrics = { rowPx: ROW_PX, colPx: COL_PX };
|
||||
const { rows, cols } = extentOf(box, frameCost, metrics);
|
||||
// `client.set_viewport`, NOT the associated `Client.viewport`: the same
|
||||
// frame, but it also RECORDS the window. Why that matters: clients.md.
|
||||
// `set_viewport` RECORDS the window as well as framing it; the frame-only
|
||||
// twin beside it is deleted, so there is nothing to call by mistake (seik).
|
||||
transport.send(client.set_viewport(rows, cols));
|
||||
}
|
||||
|
||||
@@ -162,6 +164,13 @@ function repaint() {
|
||||
paintMinibuffer(model);
|
||||
shell.setModel(model.status);
|
||||
el.viewName.textContent = model.view_name;
|
||||
// The title bar, off the cache `src/draw.rs::draw_title` folds — one
|
||||
// derivation, both front ends (improvise-z754, improvise-5jna).
|
||||
el.docName.textContent = documentTitle(
|
||||
model.doc_name,
|
||||
model.doc_dirty,
|
||||
model.doc_stored_as,
|
||||
);
|
||||
el.grid.replaceChildren(gridTable(model));
|
||||
// The flag is the client's answer, not the page's: `C` toggles it and the
|
||||
// TUI draws the panel on exactly the same condition (improvise-wsaw).
|
||||
@@ -173,6 +182,13 @@ function repaint() {
|
||||
client.click_formula(i),
|
||||
);
|
||||
|
||||
// The selection must be on screen. `#grid` scrolls on its own — ROW_PX and
|
||||
// COL_PX under-estimate and group rows are uncharged — so a selection the
|
||||
// projection did send can still sit below the fold (improvise-jq8l).
|
||||
el.grid
|
||||
.querySelector("td.selected")
|
||||
?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
|
||||
// What the frame costs, so the NEXT viewport announcement is honest.
|
||||
// Inline group ROWS are deliberately NOT counted — context/code/clients.md.
|
||||
const next = frameCostOf(model);
|
||||
@@ -299,7 +315,12 @@ function gridTable(model) {
|
||||
// Every cell moves the selection; an editable one also opens its editor,
|
||||
// which is the pointer's `i`. Not repainted in that branch: `edit` has
|
||||
// set `editing`, and a repaint would take the <input> away.
|
||||
td.addEventListener("click", () => {
|
||||
// On MOUSEDOWN, and defaulted away: from an editing state the input's
|
||||
// blur repainted first and this <td> was gone before `click` could fire
|
||||
// (improvise-0x9u). See context/code/clients.md.
|
||||
td.addEventListener("mousedown", (event) => {
|
||||
event.preventDefault();
|
||||
if (editing) editing.close(false);
|
||||
const frame = client.click_cell(cell.row, cell.col);
|
||||
if (frame) transport.send(frame);
|
||||
if (cell.editable) edit(td, cell.row, cell.col, cell.editText);
|
||||
@@ -314,10 +335,12 @@ function gridTable(model) {
|
||||
* 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.
|
||||
const original = seed;
|
||||
// What was on the screen, kept so closing can put it back: `close` no longer
|
||||
// always repaints, so restoring `seed` would leave `1200` where `1,200` was.
|
||||
const drawn = td.textContent;
|
||||
const input = document.createElement("input");
|
||||
input.value = original;
|
||||
td.replaceChildren(input);
|
||||
@@ -327,6 +350,8 @@ function edit(td, row, col, seed) {
|
||||
// Enter removes the focused <input>, which fires `blur`, which calls
|
||||
// `close` again. Harmless before, but only by accident.
|
||||
let closed = false;
|
||||
// `close` does NOT repaint: a caller about to open another editor on a live
|
||||
// <td> must not have the table rebuilt under it (improvise-0x9u).
|
||||
const close = (commit) => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
@@ -337,19 +362,28 @@ function edit(td, row, col, seed) {
|
||||
// follows restores whatever the server now says is there.
|
||||
if (frame) transport.send(frame);
|
||||
}
|
||||
td.replaceChildren(original);
|
||||
td.replaceChildren(drawn);
|
||||
painted = -1;
|
||||
};
|
||||
const dismiss = (commit) => {
|
||||
close(commit);
|
||||
repaint();
|
||||
};
|
||||
editing = { row, col, close };
|
||||
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") close(true);
|
||||
else if (e.key === "Escape") close(false);
|
||||
if (e.key === "Enter") dismiss(true);
|
||||
else if (e.key === "Escape") dismiss(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));
|
||||
// Removing the focused <input> fires `blur` SYNCHRONOUSLY, so a caller that
|
||||
// already closed this editor must not have the table rebuilt from inside its
|
||||
// own `close` — that is what detached the <td> in improvise-0x9u's repair.
|
||||
input.addEventListener("blur", () => {
|
||||
if (!closed) dismiss(false);
|
||||
});
|
||||
}
|
||||
|
||||
/** A side panel. An empty row list is what says it is closed — the page asks
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
-->
|
||||
<header>
|
||||
<h1>improvise</h1>
|
||||
<span id="doc-name"></span>
|
||||
<span id="view-name"></span>
|
||||
<span id="mode"></span>
|
||||
<span id="hint"></span>
|
||||
|
||||
@@ -16,6 +16,12 @@ export function composeStatus(shell, model) {
|
||||
return [shell, model].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
/** The title bar's one line, in the order and spelling `src/draw.rs::draw_title`
|
||||
* uses. The three facts are the host's; only the joining is here (clients.md). */
|
||||
export function documentTitle(name, dirty, storedAs) {
|
||||
return `${name}${storedAs ? ` (${storedAs})` : ""}${dirty ? " [+]" : ""}`;
|
||||
}
|
||||
|
||||
/** The collapse glyph. `collapsed` is the fact, decided by the view and
|
||||
* carried on the frame; only this spelling is the page's (gotcha 26). */
|
||||
export function indicator(collapsed) {
|
||||
|
||||
@@ -49,6 +49,10 @@ header h1 {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
#doc-name {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
#mode {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@@ -86,6 +86,31 @@ test("the page still reaches the client through a handful of calls", () => {
|
||||
);
|
||||
});
|
||||
|
||||
/// Methods on `Client` the page does not call, each with the reason it is
|
||||
/// still exported. A door with no caller and no entry here is improvise-seik:
|
||||
/// the dangerous kind, because calling the wrong one of a pair is silent.
|
||||
// What this CANNOT see, exactly as UNPAINTED cannot: `callsOn` is a source
|
||||
// scan, so a `client.foo()` inside a function nothing calls reads as called.
|
||||
const UNCALLED = {
|
||||
free: "wasm-bindgen's own destructor, not ours to remove",
|
||||
initSync: "wasm-bindgen's own module loader, beside the default export",
|
||||
revision: "on_frame returns it; a page that stores it can skip a repaint",
|
||||
};
|
||||
|
||||
test("the bundle exports no client door the page has no reason to hold", () => {
|
||||
const declared = declarations("pkg/improvise_wasm_client.d.ts");
|
||||
const called = new Set(callsOn("app.js", "client").map((c) => c.name));
|
||||
// `constructor` is `new Client()`, which `callsOn` cannot see as a call.
|
||||
const spare = [...declared.keys()].filter(
|
||||
(name) => name !== "constructor" && !called.has(name),
|
||||
);
|
||||
assert.deepEqual(
|
||||
spare.sort(),
|
||||
Object.keys(UNCALLED).sort(),
|
||||
"an exported method nothing calls — delete it, or add it to UNCALLED with the reason",
|
||||
);
|
||||
});
|
||||
|
||||
test("every worker call web/worker.js makes is declared, with that arity", () => {
|
||||
const declared = declarations("worker-pkg/improvise_worker_server.d.ts");
|
||||
const calls = callsOn("worker.js", "server");
|
||||
|
||||
@@ -17,14 +17,10 @@ const code = (rel) =>
|
||||
|
||||
/// Fields the page deliberately does not read, each with the reason. A field
|
||||
/// belongs here only with an argument; the list is the debt, written down.
|
||||
// improvise-z754 put the title bar's facts in one shared cache and taught
|
||||
// `src/draw.rs::draw_title` to fold it; this page never learned to. That is
|
||||
// improvise-5jna, open, and this list is what stops it being forgotten.
|
||||
const UNPAINTED = {
|
||||
doc_name: "improvise-5jna: the title bar is drawn by the TUI and not here",
|
||||
doc_dirty: "improvise-5jna",
|
||||
doc_stored_as: "improvise-5jna",
|
||||
};
|
||||
// Empty since improvise-5jna painted the title bar. Note what this check CANNOT
|
||||
// see, and why the harness asserts the painted text too: it is a substring
|
||||
// search, so a field named by a function nothing calls reads as painted.
|
||||
const UNPAINTED = {};
|
||||
|
||||
/// The `pub` fields of `RenderModel`, read off the Rust that defines it.
|
||||
function renderModelFields() {
|
||||
|
||||
@@ -7,6 +7,7 @@ import assert from "node:assert/strict";
|
||||
import {
|
||||
composeStatus,
|
||||
createShell,
|
||||
documentTitle,
|
||||
extentOf,
|
||||
frameCostOf,
|
||||
indicator,
|
||||
@@ -160,3 +161,17 @@ test("the collapse glyph points right when collapsed", () => {
|
||||
assert.equal(indicator(true), "▶");
|
||||
assert.equal(indicator(false), "▼");
|
||||
});
|
||||
|
||||
test("the title bar spells the document the way the TUI's own title does", () => {
|
||||
// `src/draw.rs::draw_title`: name, then what it is stored as in parens, then
|
||||
// the dirty marker. The harness cannot pin `[+]` — the standalone deployment
|
||||
// persists on every accepted batch, so the marker is transient there.
|
||||
assert.equal(
|
||||
documentTitle("Corner Coffee", true, "demo.improv"),
|
||||
"Corner Coffee (demo.improv) [+]",
|
||||
);
|
||||
assert.equal(documentTitle("Corner Coffee", false, "demo.improv"), "Corner Coffee (demo.improv)");
|
||||
// A host with nowhere to write it: `stored_as` is `None` over the wire, which
|
||||
// arrives as `null`, and an empty pair of parens would be a lie about storage.
|
||||
assert.equal(documentTitle("Untitled", true, null), "Untitled [+]");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user