Fix terminal resize bug
Fix resize handling in TerminalView component and update related tests
This commit is contained in:
@ -246,6 +246,13 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () =
|
|||||||
globalThis.ResizeObserver = savedResizeObserver;
|
globalThis.ResizeObserver = savedResizeObserver;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function setTerminalBoxSize(width: number, height: number) {
|
||||||
|
const container = screen.getByTestId("terminal-xterm-container");
|
||||||
|
Object.defineProperty(container, "clientWidth", { value: width, configurable: true });
|
||||||
|
Object.defineProperty(container, "clientHeight", { value: height, configurable: true });
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
it("sanity: with the polyfills xterm mounts and the opener runs", async () => {
|
it("sanity: with the polyfills xterm mounts and the opener runs", async () => {
|
||||||
// Guards the premise of the tests below: if this fails, the opener never
|
// Guards the premise of the tests below: if this fails, the opener never
|
||||||
// fired and the error assertions would be vacuous.
|
// fired and the error assertions would be vacuous.
|
||||||
@ -292,6 +299,48 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () =
|
|||||||
expect(screen.queryByTestId("terminal-error")).toBeNull();
|
expect(screen.queryByTestId("terminal-error")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a boot placeholder while the terminal box has no usable size", async () => {
|
||||||
|
const handle = makeHandle({ sessionId: "boot-zero-1" });
|
||||||
|
const open = vi.fn(async () => handle);
|
||||||
|
|
||||||
|
renderView(new MockTerminalGateway(), "/cwd", { open, refitSignal: 1 });
|
||||||
|
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||||
|
|
||||||
|
expect(screen.getByTestId("terminal-boot-placeholder")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("terminal-xterm-container").style.visibility).toBe(
|
||||||
|
"hidden",
|
||||||
|
);
|
||||||
|
expect(handle.resize).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reveals xterm and resizes the handle after the first useful fit", async () => {
|
||||||
|
const handle = makeHandle({ sessionId: "boot-ready-1" });
|
||||||
|
const open = vi.fn(async () => handle);
|
||||||
|
|
||||||
|
const { rerender } = renderView(new MockTerminalGateway(), "/cwd", {
|
||||||
|
open,
|
||||||
|
refitSignal: 1,
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
|
||||||
|
expect(screen.getByTestId("terminal-boot-placeholder")).toBeTruthy();
|
||||||
|
|
||||||
|
setTerminalBoxSize(480, 240);
|
||||||
|
rerender(
|
||||||
|
<DIProvider gateways={{ terminal: new MockTerminalGateway() } as unknown as Gateways}>
|
||||||
|
<TerminalView cwd="/cwd" open={open} refitSignal={2} />
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByTestId("terminal-boot-placeholder")).toBeNull(),
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("terminal-xterm-container").style.visibility).toBe(
|
||||||
|
"visible",
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(handle.resize).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
describe("refitSignal (ticket #61 — refit after split/merge)", () => {
|
describe("refitSignal (ticket #61 — refit after split/merge)", () => {
|
||||||
it("refits WITHOUT reopening the terminal when refitSignal changes", async () => {
|
it("refits WITHOUT reopening the terminal when refitSignal changes", async () => {
|
||||||
// Simulates LayoutGrid bumping `useLayout`'s layout version after a
|
// Simulates LayoutGrid bumping `useLayout`'s layout version after a
|
||||||
@ -305,18 +354,14 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () =
|
|||||||
refitSignal: 1,
|
refitSignal: 1,
|
||||||
});
|
});
|
||||||
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
|
||||||
|
fitSpy.mockClear();
|
||||||
const fitCallsAtMount = fitSpy.mock.calls.length;
|
|
||||||
expect(fitCallsAtMount).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// jsdom reports a zero-size layout box, which the coalesced refit
|
// jsdom reports a zero-size layout box, which the coalesced refit
|
||||||
// deliberately skips (the same guard that protects the resize-observer
|
// deliberately skips (the same guard that protects the resize-observer
|
||||||
// path from fitting to a transient zero size). Stub a real size on the
|
// path from fitting to a transient zero size). Stub a real size on the
|
||||||
// inner xterm container so the refit triggered below actually reaches
|
// inner xterm container so the refit triggered below actually reaches
|
||||||
// `fit.fit()` instead of bailing on the zero-size guard.
|
// `fit.fit()` instead of bailing on the zero-size guard.
|
||||||
const container = screen.getByTestId("terminal-view").firstElementChild as HTMLElement;
|
setTerminalBoxSize(400, 200);
|
||||||
Object.defineProperty(container, "clientWidth", { value: 400, configurable: true });
|
|
||||||
Object.defineProperty(container, "clientHeight", { value: 200, configurable: true });
|
|
||||||
|
|
||||||
rerender(
|
rerender(
|
||||||
<DIProvider gateways={{ terminal: new MockTerminalGateway() } as unknown as Gateways}>
|
<DIProvider gateways={{ terminal: new MockTerminalGateway() } as unknown as Gateways}>
|
||||||
@ -324,9 +369,7 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () =
|
|||||||
</DIProvider>,
|
</DIProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() => expect(fitSpy).toHaveBeenCalled());
|
||||||
expect(fitSpy.mock.calls.length).toBeGreaterThan(fitCallsAtMount),
|
|
||||||
);
|
|
||||||
// The structural-mutation refit must never reopen the PTY.
|
// The structural-mutation refit must never reopen the PTY.
|
||||||
expect(open).toHaveBeenCalledTimes(1);
|
expect(open).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
@ -348,7 +391,7 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () =
|
|||||||
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
|
||||||
fitSpy.mockClear();
|
fitSpy.mockClear();
|
||||||
|
|
||||||
const container = screen.getByTestId("terminal-view").firstElementChild as HTMLElement;
|
const container = screen.getByTestId("terminal-xterm-container");
|
||||||
// jsdom's default layout box is 0x0 — exactly the transient-zero case:
|
// jsdom's default layout box is 0x0 — exactly the transient-zero case:
|
||||||
// left as-is, the container "hasn't settled" yet.
|
// left as-is, the container "hasn't settled" yet.
|
||||||
|
|
||||||
|
|||||||
@ -153,6 +153,7 @@ export function TerminalView({
|
|||||||
// buffer (which is invisible to assistive tech and absent when xterm can't
|
// buffer (which is invisible to assistive tech and absent when xterm can't
|
||||||
// mount). `null` ⇒ no error. The cell stays mounted and IdeA stays usable.
|
// mount). `null` ⇒ no error. The cell stays mounted and IdeA stays usable.
|
||||||
const [openError, setOpenError] = useState<string | null>(null);
|
const [openError, setOpenError] = useState<string | null>(null);
|
||||||
|
const [terminalReady, setTerminalReady] = useState(false);
|
||||||
|
|
||||||
// The opener (`open` or the terminal gateway) is read through a ref so the
|
// The opener (`open` or the terminal gateway) is read through a ref so the
|
||||||
// effect does NOT depend on its identity. Otherwise every parent re-render
|
// effect does NOT depend on its identity. Otherwise every parent re-render
|
||||||
@ -191,6 +192,7 @@ export function TerminalView({
|
|||||||
|
|
||||||
// Fresh (re)mount: clear any prior failure banner before we try to open.
|
// Fresh (re)mount: clear any prior failure banner before we try to open.
|
||||||
setOpenError(null);
|
setOpenError(null);
|
||||||
|
setTerminalReady(false);
|
||||||
|
|
||||||
const term = new Terminal({
|
const term = new Terminal({
|
||||||
convertEol: false,
|
convertEol: false,
|
||||||
@ -209,15 +211,13 @@ export function TerminalView({
|
|||||||
term.dispose();
|
term.dispose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
fit.fit();
|
|
||||||
} catch {
|
|
||||||
/* container not laid out yet; a resize will retry */
|
|
||||||
}
|
|
||||||
|
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let handle: TerminalHandle | null = null;
|
let handle: TerminalHandle | null = null;
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
|
let rafId = 0;
|
||||||
|
let lastRows = term.rows;
|
||||||
|
let lastCols = term.cols;
|
||||||
|
let hasUsefulFit = false;
|
||||||
|
|
||||||
// Keystroke → PTY path. The agent cell is a **native terminal**
|
// Keystroke → PTY path. The agent cell is a **native terminal**
|
||||||
// (ARCHITECTURE §20): keystrokes reach the PTY exactly like a plain shell.
|
// (ARCHITECTURE §20): keystrokes reach the PTY exactly like a plain shell.
|
||||||
@ -259,6 +259,11 @@ export function TerminalView({
|
|||||||
// Adopt a freshly-established handle: flush buffered keystrokes. If the view
|
// Adopt a freshly-established handle: flush buffered keystrokes. If the view
|
||||||
// was disposed before the promise resolved, just detach (NEVER close — the
|
// was disposed before the promise resolved, just detach (NEVER close — the
|
||||||
// PTY must survive a transient mount/unmount).
|
// PTY must survive a transient mount/unmount).
|
||||||
|
const resizeHandleToCurrentGeometry = () => {
|
||||||
|
if (!handle) return;
|
||||||
|
if (term.rows <= 0 || term.cols <= 0) return;
|
||||||
|
void handle.resize(term.rows, term.cols);
|
||||||
|
};
|
||||||
const adopt = (h: TerminalHandle) => {
|
const adopt = (h: TerminalHandle) => {
|
||||||
if (disposed) {
|
if (disposed) {
|
||||||
h.detach();
|
h.detach();
|
||||||
@ -272,6 +277,7 @@ export function TerminalView({
|
|||||||
void h.write(encoder.encode(pending));
|
void h.write(encoder.encode(pending));
|
||||||
pending = "";
|
pending = "";
|
||||||
}
|
}
|
||||||
|
if (hasUsefulFit) resizeHandleToCurrentGeometry();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onOpenError = (e: unknown) => {
|
const onOpenError = (e: unknown) => {
|
||||||
@ -335,9 +341,6 @@ export function TerminalView({
|
|||||||
// into a single `requestAnimationFrame` that runs after layout settles,
|
// into a single `requestAnimationFrame` that runs after layout settles,
|
||||||
// (2) skip fitting while the container has no real size, and (3) push a PTY
|
// (2) skip fitting while the container has no real size, and (3) push a PTY
|
||||||
// resize only when rows/cols actually change (avoids redundant reflows).
|
// resize only when rows/cols actually change (avoids redundant reflows).
|
||||||
let rafId = 0;
|
|
||||||
let lastRows = term.rows;
|
|
||||||
let lastCols = term.cols;
|
|
||||||
// A refit can land on a transient 0x0 container (mount, or a structural
|
// A refit can land on a transient 0x0 container (mount, or a structural
|
||||||
// layout mutation, before the box has actually settled). Previously this
|
// layout mutation, before the box has actually settled). Previously this
|
||||||
// just gave up — fine on desktop, where a later window resize always
|
// just gave up — fine on desktop, where a later window resize always
|
||||||
@ -364,10 +367,20 @@ export function TerminalView({
|
|||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (handle && (term.rows !== lastRows || term.cols !== lastCols)) {
|
if (term.rows <= 0 || term.cols <= 0) return;
|
||||||
|
|
||||||
|
const isFirstUsefulFit = !hasUsefulFit;
|
||||||
|
if (isFirstUsefulFit) {
|
||||||
|
hasUsefulFit = true;
|
||||||
|
setTerminalReady(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (term.rows !== lastRows || term.cols !== lastCols) {
|
||||||
lastRows = term.rows;
|
lastRows = term.rows;
|
||||||
lastCols = term.cols;
|
lastCols = term.cols;
|
||||||
void handle.resize(term.rows, term.cols);
|
resizeHandleToCurrentGeometry();
|
||||||
|
} else if (isFirstUsefulFit) {
|
||||||
|
resizeHandleToCurrentGeometry();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const scheduleRefit = () => {
|
const scheduleRefit = () => {
|
||||||
@ -376,6 +389,7 @@ export function TerminalView({
|
|||||||
};
|
};
|
||||||
const ro = new ResizeObserver(scheduleRefit);
|
const ro = new ResizeObserver(scheduleRefit);
|
||||||
ro.observe(container);
|
ro.observe(container);
|
||||||
|
scheduleRefit();
|
||||||
// Let the `refitSignal` effect below trigger the SAME coalesced refit after
|
// Let the `refitSignal` effect below trigger the SAME coalesced refit after
|
||||||
// a structural layout mutation (split/merge, ticket #61) — surviving cells
|
// a structural layout mutation (split/merge, ticket #61) — surviving cells
|
||||||
// don't always get a timely useful ResizeObserver event from a sibling
|
// don't always get a timely useful ResizeObserver event from a sibling
|
||||||
@ -426,7 +440,38 @@ export function TerminalView({
|
|||||||
>
|
>
|
||||||
{/* xterm mounts into this inner node; the error banner is a sibling so
|
{/* xterm mounts into this inner node; the error banner is a sibling so
|
||||||
React never fights xterm over the same subtree. */}
|
React never fights xterm over the same subtree. */}
|
||||||
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
data-testid="terminal-xterm-container"
|
||||||
|
aria-hidden={!terminalReady}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
visibility: terminalReady ? "visible" : "hidden",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!terminalReady && !openError && (
|
||||||
|
<div
|
||||||
|
data-testid="terminal-boot-placeholder"
|
||||||
|
aria-live="polite"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "1rem",
|
||||||
|
background: "#101214",
|
||||||
|
color: "#9ca3af",
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily:
|
||||||
|
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Préparation du terminal…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{openError && (
|
{openError && (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
|
|||||||
@ -111,16 +111,14 @@ describe("WebAgentCell — refitSignal (ticket #61 web regression)", () => {
|
|||||||
|
|
||||||
const { rerender } = renderCell(agent, seeded.id, { refitSignal: 1 });
|
const { rerender } = renderCell(agent, seeded.id, { refitSignal: 1 });
|
||||||
await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1));
|
||||||
|
fitSpy.mockClear();
|
||||||
const fitCallsAtMount = fitSpy.mock.calls.length;
|
|
||||||
expect(fitCallsAtMount).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// jsdom reports a zero-size layout box, which the coalesced refit
|
// jsdom reports a zero-size layout box, which the coalesced refit
|
||||||
// deliberately skips — give the inner xterm container a real size so the
|
// deliberately skips — give the inner xterm container a real size so the
|
||||||
// refit below actually reaches `fit.fit()`.
|
// refit below actually reaches `fit.fit()`.
|
||||||
const container = screen.getByTestId("web-agent-cell").querySelector(
|
const container = screen.getByTestId("web-agent-cell").querySelector(
|
||||||
'[data-testid="terminal-view"]',
|
'[data-testid="terminal-xterm-container"]',
|
||||||
)!.firstElementChild as HTMLElement;
|
) as HTMLElement;
|
||||||
Object.defineProperty(container, "clientWidth", { value: 400, configurable: true });
|
Object.defineProperty(container, "clientWidth", { value: 400, configurable: true });
|
||||||
Object.defineProperty(container, "clientHeight", { value: 200, configurable: true });
|
Object.defineProperty(container, "clientHeight", { value: 200, configurable: true });
|
||||||
|
|
||||||
@ -130,7 +128,7 @@ describe("WebAgentCell — refitSignal (ticket #61 web regression)", () => {
|
|||||||
</DIProvider>,
|
</DIProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => expect(fitSpy.mock.calls.length).toBeGreaterThan(fitCallsAtMount));
|
await waitFor(() => expect(fitSpy).toHaveBeenCalled());
|
||||||
// The whole point: refit must never relaunch the agent's PTY.
|
// The whole point: refit must never relaunch the agent's PTY.
|
||||||
expect(launchSpy).toHaveBeenCalledTimes(1);
|
expect(launchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
|||||||
@ -384,8 +384,6 @@ describe("LiveProjectPanel — cellLayoutVersion (ticket #61 web regression)", (
|
|||||||
const archiRow = screen.getByText("Archi").closest("li")!;
|
const archiRow = screen.getByText("Archi").closest("li")!;
|
||||||
fireEvent.click(within(archiRow).getByRole("button", { name: "Ouvrir" }));
|
fireEvent.click(within(archiRow).getByRole("button", { name: "Ouvrir" }));
|
||||||
await screen.findByTestId("web-agent-cell");
|
await screen.findByTestId("web-agent-cell");
|
||||||
// First cell mounted: at least the one guaranteed fit-on-mount happened.
|
|
||||||
expect(fitSpy.mock.calls.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// Switch to a different agent's cell — the panel shows one cell at a time,
|
// Switch to a different agent's cell — the panel shows one cell at a time,
|
||||||
// so this replaces (unmounts A, mounts B) rather than adding a second.
|
// so this replaces (unmounts A, mounts B) rather than adding a second.
|
||||||
@ -398,8 +396,9 @@ describe("LiveProjectPanel — cellLayoutVersion (ticket #61 web regression)", (
|
|||||||
// jsdom reports a zero-size layout box, which the refit deliberately skips
|
// jsdom reports a zero-size layout box, which the refit deliberately skips
|
||||||
// — give the freshly-mounted cell's inner xterm container a real size so
|
// — give the freshly-mounted cell's inner xterm container a real size so
|
||||||
// the refitSignal-driven refit actually reaches `fit.fit()`.
|
// the refitSignal-driven refit actually reaches `fit.fit()`.
|
||||||
const containerB = cellB.querySelector('[data-testid="terminal-view"]')!
|
const containerB = cellB.querySelector(
|
||||||
.firstElementChild as HTMLElement;
|
'[data-testid="terminal-xterm-container"]',
|
||||||
|
) as HTMLElement;
|
||||||
Object.defineProperty(containerB, "clientWidth", { value: 400, configurable: true });
|
Object.defineProperty(containerB, "clientWidth", { value: 400, configurable: true });
|
||||||
Object.defineProperty(containerB, "clientHeight", { value: 200, configurable: true });
|
Object.defineProperty(containerB, "clientHeight", { value: 200, configurable: true });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user