mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Merge max/tui-renderer into sami/tui-chords
Integration head f4379884 brings in the close, focus, and repaint lanes.
Only TerminalSubstrate.test.mjs conflicted: Dawn's repaint block and this
lane's tabFixture/chord block both append at the former EOF, with an empty
merge base between them. Resolved additively -- both blocks kept, repaint
first. Verified no top-level name collisions between the two blocks or
against the shared prefix, and the resolved file deletes nothing relative
to the integration parent.
Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz>
Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz>
This commit is contained in:
commit
612b5246da
@@ -277,12 +277,11 @@ impl Session {
|
||||
}
|
||||
|
||||
fn shutdown(mut self) {
|
||||
if let Ok(mut channel) = self.channel.lock() {
|
||||
*channel = None;
|
||||
}
|
||||
// Publication is detached before the reader enters close mode; the
|
||||
// lifecycle helper then abandons parser work and keeps raw-draining
|
||||
// through child termination and reap.
|
||||
// through child termination and reap. Keep the renderer channel alive
|
||||
// until the reader has reported Exit; close() removes the session from
|
||||
// the runtime before shutdown begins, so this is its final message.
|
||||
if let Ok(mut publisher) = self.publisher.lock() {
|
||||
publisher.close();
|
||||
}
|
||||
@@ -300,6 +299,9 @@ impl Session {
|
||||
reader.join();
|
||||
}
|
||||
}
|
||||
if let Ok(mut channel) = self.channel.lock() {
|
||||
*channel = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -319,6 +319,43 @@ test("opening a tab keeps terminal ownership while its attachment is pending", a
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
test("a successful close removes the tab even if the exit event is lost", async () => {
|
||||
const { createElement } = await import("react");
|
||||
const { fireEvent, render, waitFor } = await import("@testing-library/react");
|
||||
const { ThemeProvider } = await import("@/shared/theme/ThemeProvider");
|
||||
const { TerminalBootstrap } = await import("./TerminalBootstrap.tsx");
|
||||
|
||||
const view = render(
|
||||
createElement(
|
||||
ThemeProvider,
|
||||
null,
|
||||
createElement("div", {
|
||||
className: "buzz-huddle-app-surface",
|
||||
tabIndex: -1,
|
||||
}),
|
||||
createElement(TerminalBootstrap, {
|
||||
channelId: "channel-1",
|
||||
channelName: "general",
|
||||
npub: "npub1owner",
|
||||
relayUrl: "wss://relay.example",
|
||||
threadId: null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
assert.ok(calls.some(({ command }) => command === "terminal_attach")),
|
||||
);
|
||||
await waitFor(() => assert.ok(view.queryByRole("tab", { name: /SHELL/ })));
|
||||
|
||||
fireEvent.click(view.getByLabelText("Close SHELL"));
|
||||
|
||||
await waitFor(() =>
|
||||
assert.ok(calls.some(({ command }) => command === "terminal_close")),
|
||||
);
|
||||
await waitFor(() => assert.equal(view.queryByRole("tab"), null));
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
// The wheel-to-IPC path end to end. `TerminalSubstrate` already proves it
|
||||
// accumulates pixels into whole cells and `buzz-terminal` already proves which
|
||||
// way the engine goes; the seam between them is this file's business, and the
|
||||
|
||||
@@ -228,7 +228,10 @@ export function TerminalBootstrap({
|
||||
removeSession(key);
|
||||
return;
|
||||
}
|
||||
void connection.close().catch(fail);
|
||||
void connection
|
||||
.close()
|
||||
.then(() => removeSession(key))
|
||||
.catch(fail);
|
||||
}}
|
||||
onFrameConsumed={(frame) => {
|
||||
const delivery = sessionsRef.current.find(
|
||||
|
||||
@@ -65,6 +65,7 @@ after(() => dom.window.close());
|
||||
beforeEach(() => {
|
||||
cleanup?.();
|
||||
reducedMotion = false;
|
||||
paintLog.length = 0;
|
||||
dom.window.localStorage.clear();
|
||||
dom.window.HTMLCanvasElement.prototype.getBoundingClientRect = () => ({
|
||||
bottom: 782,
|
||||
@@ -77,18 +78,50 @@ beforeEach(() => {
|
||||
y: 0,
|
||||
toJSON() {},
|
||||
});
|
||||
dom.window.HTMLCanvasElement.prototype.getContext = () => ({
|
||||
dom.window.HTMLCanvasElement.prototype.getContext = function () {
|
||||
return paintRecorder(this);
|
||||
};
|
||||
});
|
||||
|
||||
// The default stub discards everything it is handed, so an assertion made
|
||||
// against it cannot distinguish "repainted" from "drew nothing". This records
|
||||
// every draw call against the canvas that received it, so the tests below are
|
||||
// about commands actually issued and can exclude the banner canvas.
|
||||
const paintLog = [];
|
||||
|
||||
function paintRecorder(canvas) {
|
||||
return {
|
||||
clearRect() {},
|
||||
fillRect() {},
|
||||
fillRect(x, y, width, height) {
|
||||
paintLog.push({
|
||||
canvas,
|
||||
height,
|
||||
kind: "fill",
|
||||
style: this.fillStyle,
|
||||
width,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
},
|
||||
fillStyle: "",
|
||||
fillText() {},
|
||||
fillText(text, x, y) {
|
||||
paintLog.push({ canvas, kind: "text", text, x, y });
|
||||
},
|
||||
font: "",
|
||||
restore() {},
|
||||
save() {},
|
||||
setTransform() {},
|
||||
textBaseline: "",
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** Draws issued to the grid canvas only, newest paint pass included. */
|
||||
function gridDraws(view) {
|
||||
const grid = view.container.querySelector(
|
||||
".buzz-terminal-viewport > canvas:not(.buzz-terminal-welcome)",
|
||||
);
|
||||
return paintLog.filter((entry) => entry.canvas === grid);
|
||||
}
|
||||
|
||||
function fixture(overrides = {}) {
|
||||
const calls = { input: [], scroll: [] };
|
||||
@@ -171,6 +204,30 @@ test("mounted IME paths neither toggle nor emit preedit text", async () => {
|
||||
assert.deepEqual(calls.input, ["か"]);
|
||||
});
|
||||
|
||||
test("tab actions restore terminal input focus", async () => {
|
||||
const { view } = fixture();
|
||||
await ready(view);
|
||||
toggleChord();
|
||||
const input = view.getByLabelText("Terminal input");
|
||||
await waitFor(() => assert.equal(document.activeElement, input));
|
||||
|
||||
const actions = [
|
||||
["select", view.getByRole("tab")],
|
||||
["close", view.getByLabelText("Close SHELL")],
|
||||
["new", view.getByLabelText("New Buzz Term tab")],
|
||||
];
|
||||
for (const [label, target] of actions) {
|
||||
target.focus();
|
||||
assert.equal(document.activeElement, target, `${label} takes focus`);
|
||||
fireEvent.click(target);
|
||||
assert.equal(
|
||||
document.activeElement,
|
||||
input,
|
||||
`${label} restores terminal focus`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const EMPTY_FRAME = {
|
||||
cursor: { column: 0, line: 0, visible: false },
|
||||
full: false,
|
||||
@@ -392,6 +449,177 @@ test("reduced motion keeps the terminal cursor solid", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
const TWO_SESSIONS = [
|
||||
{ active: true, closing: false, id: "one", title: "SHELL" },
|
||||
{ active: false, closing: false, id: "two", title: "LOG" },
|
||||
];
|
||||
|
||||
function frameWith(text, generation = 1) {
|
||||
return {
|
||||
cursor: { column: 0, line: 0, visible: false },
|
||||
full: false,
|
||||
rows: [
|
||||
{
|
||||
line: 0,
|
||||
spans: [
|
||||
{
|
||||
style: { fg: 0, bg: 0, flags: 0 },
|
||||
clusters: [{ column: 0, text, width: text.length }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
viewport: { columns: 112, generation, screenLines: 46 },
|
||||
};
|
||||
}
|
||||
|
||||
const SWAPPED_SESSIONS = [
|
||||
{ active: false, closing: false, id: "one", title: "SHELL" },
|
||||
{ active: true, closing: false, id: "two", title: "LOG" },
|
||||
];
|
||||
|
||||
test("switching back to an idle session repaints its retained rows", async () => {
|
||||
const subject = fixture({
|
||||
sessionFrames: [
|
||||
{ frame: frameWith("one"), sessionId: "one" },
|
||||
{ frame: frameWith("two"), sessionId: "two" },
|
||||
],
|
||||
sessions: TWO_SESSIONS,
|
||||
});
|
||||
await ready(subject.view);
|
||||
await waitFor(() =>
|
||||
assert.ok(gridDraws(subject.view).some((entry) => entry.text === "one")),
|
||||
);
|
||||
|
||||
// A round trip is required, not a single switch: `paint()` is what drains the
|
||||
// dirty set, so a grid that has never been painted still holds every line and
|
||||
// repaints on first switch by luck. Only a session that was painted and then
|
||||
// deactivated has an empty dirty set while holding rows.
|
||||
subject.rerender({ sessions: SWAPPED_SESSIONS });
|
||||
await waitFor(() =>
|
||||
assert.ok(gridDraws(subject.view).some((entry) => entry.text === "two")),
|
||||
);
|
||||
|
||||
paintLog.length = 0;
|
||||
subject.rerender({ sessions: TWO_SESSIONS });
|
||||
await waitFor(() =>
|
||||
assert.ok(
|
||||
gridDraws(subject.view).some((entry) => entry.text === "one"),
|
||||
"returning to an idle session must repaint its retained rows",
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
gridDraws(subject.view).some((entry) => entry.text === "two"),
|
||||
false,
|
||||
"the outgoing session's rows must not be repainted",
|
||||
);
|
||||
});
|
||||
|
||||
test("switching to a session with no frame yet clears the outgoing pixels", async () => {
|
||||
const subject = fixture({
|
||||
sessionFrames: [{ frame: frameWith("one"), sessionId: "one" }],
|
||||
sessions: TWO_SESSIONS,
|
||||
});
|
||||
await ready(subject.view);
|
||||
await waitFor(() =>
|
||||
assert.ok(gridDraws(subject.view).some((entry) => entry.text === "one")),
|
||||
);
|
||||
|
||||
// Session "two" has delivered no frame, so it has no grid: `markAllDirty()`
|
||||
// and `paint()` are both no-ops and only a background fill can erase "one".
|
||||
paintLog.length = 0;
|
||||
subject.rerender({ sessions: SWAPPED_SESSIONS });
|
||||
await waitFor(() =>
|
||||
assert.ok(
|
||||
gridDraws(subject.view).some(
|
||||
(entry) =>
|
||||
entry.kind === "fill" &&
|
||||
entry.x === 0 &&
|
||||
entry.y === 0 &&
|
||||
entry.width === 940.8 &&
|
||||
entry.height === 782,
|
||||
),
|
||||
"a full-viewport background fill must erase the outgoing session",
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
gridDraws(subject.view).some((entry) => entry.text === "one"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("a frame on an unchanged session does not force a full repaint", async () => {
|
||||
const subject = fixture({
|
||||
sessionFrames: [{ frame: frameWith("one"), sessionId: "one" }],
|
||||
sessions: TWO_SESSIONS,
|
||||
});
|
||||
await ready(subject.view);
|
||||
await waitFor(() =>
|
||||
assert.ok(gridDraws(subject.view).some((entry) => entry.text === "one")),
|
||||
);
|
||||
|
||||
// Guards the damage model, not the bug: dropping the `paintedSessionRef`
|
||||
// write leaves `sessionChanged` permanently true, which repaints the whole
|
||||
// viewport on every frame. That looks correct on screen and passes every
|
||||
// assertion above, so only a negative test can see it.
|
||||
paintLog.length = 0;
|
||||
subject.rerender({
|
||||
sessionFrames: [{ frame: frameWith("more", 1), sessionId: "one" }],
|
||||
});
|
||||
await waitFor(() =>
|
||||
assert.ok(gridDraws(subject.view).some((entry) => entry.text === "more")),
|
||||
);
|
||||
assert.equal(
|
||||
gridDraws(subject.view).some(
|
||||
(entry) =>
|
||||
entry.kind === "fill" && entry.width === 940.8 && entry.height === 782,
|
||||
),
|
||||
false,
|
||||
"an incremental frame must not fill the whole viewport",
|
||||
);
|
||||
});
|
||||
|
||||
test("a switch during a bailed paint pass is honoured once painting resumes", async () => {
|
||||
const subject = fixture({
|
||||
sessionFrames: [{ frame: frameWith("one"), sessionId: "one" }],
|
||||
sessions: TWO_SESSIONS,
|
||||
});
|
||||
await ready(subject.view);
|
||||
await waitFor(() =>
|
||||
assert.ok(gridDraws(subject.view).some((entry) => entry.text === "one")),
|
||||
);
|
||||
|
||||
// The paint effect returns early when there is no 2d context. Writing
|
||||
// `paintedSessionRef` before those returns would mark this switch as already
|
||||
// painted, and the fill that erases "one" would never run.
|
||||
dom.window.HTMLCanvasElement.prototype.getContext = () => null;
|
||||
subject.rerender({ sessions: SWAPPED_SESSIONS });
|
||||
paintLog.length = 0;
|
||||
|
||||
// Restoring the context is not enough to re-run the effect: its deps are the
|
||||
// session, cursor, frames and palette. A frame for the now-inactive session
|
||||
// re-triggers it without giving "two" a grid and without touching the palette,
|
||||
// either of which would force the fill for an unrelated reason.
|
||||
dom.window.HTMLCanvasElement.prototype.getContext = function () {
|
||||
return paintRecorder(this);
|
||||
};
|
||||
subject.rerender({
|
||||
sessionFrames: [{ frame: frameWith("one", 1), sessionId: "one" }],
|
||||
sessions: SWAPPED_SESSIONS,
|
||||
});
|
||||
await waitFor(() =>
|
||||
assert.ok(
|
||||
gridDraws(subject.view).some(
|
||||
(entry) =>
|
||||
entry.kind === "fill" &&
|
||||
entry.width === 940.8 &&
|
||||
entry.height === 782,
|
||||
),
|
||||
"the pending switch must still repaint after the bail",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
function tabFixture(overrides = {}) {
|
||||
const calls = { close: [], input: [], select: [], spawn: 0 };
|
||||
const subject = fixture({
|
||||
|
||||
@@ -102,6 +102,7 @@ export function TerminalSubstrate({
|
||||
const appliedFramesRef = React.useRef(new WeakSet<TerminalFrame>());
|
||||
const gridRef = React.useRef<TerminalGrid | null>(null);
|
||||
const paintedPaletteRef = React.useRef(terminalPalette);
|
||||
const paintedSessionRef = React.useRef<string | null>(null);
|
||||
const previousFocusRef = React.useRef<HTMLElement | null>(null);
|
||||
const reportedFocusRef = React.useRef<boolean | null>(null);
|
||||
const scrollBySessionRef = React.useRef(new Map<string, number>());
|
||||
@@ -408,15 +409,25 @@ export function TerminalSubstrate({
|
||||
canvas.width !== pixelWidth || canvas.height !== pixelHeight;
|
||||
const paletteChanged = paintedPaletteRef.current !== terminalPalette;
|
||||
paintedPaletteRef.current = terminalPalette;
|
||||
// A grid drains its dirty set in paint(), so a session that was painted and
|
||||
// then deactivated comes back holding rows with nothing marked dirty. Both
|
||||
// refs are written after the early returns above, so a pass that bails
|
||||
// keeps the switch pending instead of swallowing it.
|
||||
const sessionChanged = paintedSessionRef.current !== activeSessionId;
|
||||
paintedSessionRef.current = activeSessionId;
|
||||
const repaintAll = resized || paletteChanged || sessionChanged;
|
||||
if (resized) {
|
||||
canvas.width = pixelWidth;
|
||||
canvas.height = pixelHeight;
|
||||
}
|
||||
if (resized || paletteChanged) {
|
||||
if (repaintAll) {
|
||||
gridRef.current?.markAllDirty();
|
||||
}
|
||||
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
if (resized || paletteChanged) {
|
||||
if (repaintAll) {
|
||||
// Not grid-guarded: switching to a session that has delivered no frame
|
||||
// yet has no grid to mark or paint, so this fill is the only thing that
|
||||
// erases the outgoing session's pixels.
|
||||
context.fillStyle = terminalPalette.background;
|
||||
context.fillRect(0, 0, bounds.width, bounds.height);
|
||||
}
|
||||
@@ -424,6 +435,13 @@ export function TerminalSubstrate({
|
||||
gridRef.current?.paint(context, TERMINAL_CELL_METRICS, terminalPalette);
|
||||
}, [activeSessionId, cursorPainted, frames, terminalPalette]);
|
||||
|
||||
const runTabAction = (action: () => void) => {
|
||||
action();
|
||||
if (owner === "terminal") {
|
||||
textareaRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Buzz Term"
|
||||
@@ -464,7 +482,7 @@ export function TerminalSubstrate({
|
||||
aria-selected={session.active}
|
||||
className="buzz-terminal-tab-select"
|
||||
disabled={session.closing}
|
||||
onClick={() => onSelectSession(session.id)}
|
||||
onClick={() => runTabAction(() => onSelectSession(session.id))}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
@@ -477,7 +495,7 @@ export function TerminalSubstrate({
|
||||
aria-label={`Close ${session.title}`}
|
||||
className="buzz-terminal-close"
|
||||
disabled={session.closing}
|
||||
onClick={() => onCloseSession(session.id)}
|
||||
onClick={() => runTabAction(() => onCloseSession(session.id))}
|
||||
type="button"
|
||||
>
|
||||
×
|
||||
@@ -487,7 +505,7 @@ export function TerminalSubstrate({
|
||||
<button
|
||||
aria-label="New Buzz Term tab"
|
||||
className="buzz-terminal-new-tab"
|
||||
onClick={onNewSession}
|
||||
onClick={() => runTabAction(onNewSession)}
|
||||
type="button"
|
||||
>
|
||||
+
|
||||
|
||||
Reference in New Issue
Block a user