Fix terminal resize after splitting panes (#1249)
This commit is contained in:
@@ -14,6 +14,7 @@ vi.mock("@xterm/addon-webgl", () => ({
|
||||
interface TerminalSize {
|
||||
rows: number;
|
||||
cols: number;
|
||||
shouldClaim: boolean;
|
||||
}
|
||||
|
||||
interface TerminalKeyRecord {
|
||||
@@ -198,6 +199,15 @@ describe("terminal emulator runtime in a real browser", () => {
|
||||
expect(window.__paseoTerminal?.options.scrollback).toBe(42_000);
|
||||
});
|
||||
|
||||
it("does not claim PTY ownership from passive mount refits", async () => {
|
||||
await page.viewport(900, 600);
|
||||
const mounted = createTerminalHost({ width: 720, height: 360 });
|
||||
|
||||
await waitFor({ predicate: () => mounted.sizes.length > 0 });
|
||||
|
||||
expect(mounted.sizes[0]?.shouldClaim).toBe(false);
|
||||
});
|
||||
|
||||
it("reports a larger PTY size when the terminal container grows", async () => {
|
||||
await page.viewport(900, 600);
|
||||
const mounted = createTerminalHost({ width: 360, height: 180 });
|
||||
@@ -220,6 +230,7 @@ describe("terminal emulator runtime in a real browser", () => {
|
||||
const grownSize = latestSize(mounted.sizes);
|
||||
expect(grownSize.cols).toBeGreaterThan(initialSize.cols);
|
||||
expect(grownSize.rows).toBeGreaterThan(initialSize.rows);
|
||||
expect(grownSize.shouldClaim).toBe(true);
|
||||
});
|
||||
|
||||
it("refreshes visible rows on a forced same-size resize", async () => {
|
||||
|
||||
@@ -91,6 +91,10 @@ interface StubTerminal {
|
||||
cols?: number;
|
||||
}
|
||||
|
||||
interface RuntimeFitProbe {
|
||||
fitAndEmitResize: (input?: { force?: boolean; shouldClaim?: boolean }) => void;
|
||||
}
|
||||
|
||||
function createRuntimeWithTerminal(): {
|
||||
runtime: TerminalEmulatorRuntime;
|
||||
terminal: StubTerminal & {
|
||||
@@ -328,14 +332,13 @@ describe("terminal-emulator-runtime", () => {
|
||||
const runtime = new TerminalEmulatorRuntime();
|
||||
const fitAndEmitResize = vi.fn();
|
||||
|
||||
(runtime as unknown as { fitAndEmitResize: (force: boolean) => void }).fitAndEmitResize =
|
||||
fitAndEmitResize;
|
||||
(runtime as unknown as RuntimeFitProbe).fitAndEmitResize = fitAndEmitResize;
|
||||
|
||||
runtime.resize();
|
||||
runtime.resize({ force: true });
|
||||
|
||||
expect(fitAndEmitResize).toHaveBeenNthCalledWith(1, false);
|
||||
expect(fitAndEmitResize).toHaveBeenNthCalledWith(2, true);
|
||||
expect(fitAndEmitResize).toHaveBeenNthCalledWith(1, undefined);
|
||||
expect(fitAndEmitResize).toHaveBeenNthCalledWith(2, { force: true });
|
||||
});
|
||||
|
||||
it("updates terminal theme without remounting", () => {
|
||||
@@ -381,12 +384,11 @@ describe("terminal-emulator-runtime", () => {
|
||||
expect(refresh).toHaveBeenCalledWith(0, 11);
|
||||
});
|
||||
|
||||
it("forces a refit when the page becomes visible again", () => {
|
||||
it("passively refits when the page becomes visible again", () => {
|
||||
const runtime = new TerminalEmulatorRuntime();
|
||||
const fitAndEmitResize = vi.fn();
|
||||
|
||||
(runtime as unknown as { fitAndEmitResize: (force: boolean) => void }).fitAndEmitResize =
|
||||
fitAndEmitResize;
|
||||
(runtime as unknown as RuntimeFitProbe).fitAndEmitResize = fitAndEmitResize;
|
||||
(globalThis as { document?: { visibilityState?: string } }).document = {
|
||||
visibilityState: "visible",
|
||||
};
|
||||
@@ -397,15 +399,14 @@ describe("terminal-emulator-runtime", () => {
|
||||
}
|
||||
).handleVisibilityRestore();
|
||||
|
||||
expect(fitAndEmitResize).toHaveBeenCalledWith(true);
|
||||
expect(fitAndEmitResize).toHaveBeenCalledWith({ force: true, shouldClaim: false });
|
||||
});
|
||||
|
||||
it("does not refit while the page is still hidden", () => {
|
||||
const runtime = new TerminalEmulatorRuntime();
|
||||
const fitAndEmitResize = vi.fn();
|
||||
|
||||
(runtime as unknown as { fitAndEmitResize: (force: boolean) => void }).fitAndEmitResize =
|
||||
fitAndEmitResize;
|
||||
(runtime as unknown as RuntimeFitProbe).fitAndEmitResize = fitAndEmitResize;
|
||||
(globalThis as { document?: { visibilityState?: string } }).document = {
|
||||
visibilityState: "hidden",
|
||||
};
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface TerminalEmulatorRuntimeMountInput {
|
||||
|
||||
export interface TerminalEmulatorRuntimeCallbacks {
|
||||
onInput?: (data: string) => Promise<void> | void;
|
||||
onResize?: (input: { rows: number; cols: number }) => Promise<void> | void;
|
||||
onResize?: (input: { rows: number; cols: number; shouldClaim: boolean }) => Promise<void> | void;
|
||||
onTerminalKey?: (input: {
|
||||
key: string;
|
||||
ctrl: boolean;
|
||||
@@ -68,7 +68,6 @@ interface TerminalEmulatorRuntimeDisposables {
|
||||
removeWindowFocus: () => void;
|
||||
removeDocumentVisibilityChange: () => void;
|
||||
removeVisualViewportResize: () => void;
|
||||
clearFitInterval: () => void;
|
||||
clearFitTimeouts: () => void;
|
||||
removeFontListeners: () => void;
|
||||
removeTouchListeners: () => void;
|
||||
@@ -164,7 +163,8 @@ export class TerminalEmulatorRuntime {
|
||||
};
|
||||
private terminal: Terminal | null = null;
|
||||
private fitAddon: FitAddon | null = null;
|
||||
private fitAndEmitResize: ((force: boolean) => void) | null = null;
|
||||
private fitAndEmitResize: ((input?: { force?: boolean; shouldClaim?: boolean }) => void) | null =
|
||||
null;
|
||||
private lastSize: { rows: number; cols: number } | null = null;
|
||||
private cleanup: (() => void) | null = null;
|
||||
private outputOperations: TerminalOutputOperation[] = [];
|
||||
@@ -181,10 +181,10 @@ export class TerminalEmulatorRuntime {
|
||||
return;
|
||||
}
|
||||
|
||||
this.fitAndEmitResize?.(true);
|
||||
this.fitAndEmitResize?.({ force: true, shouldClaim: false });
|
||||
if (typeof window.requestAnimationFrame === "function") {
|
||||
window.requestAnimationFrame(() => {
|
||||
this.fitAndEmitResize?.(true);
|
||||
this.fitAndEmitResize?.({ force: true, shouldClaim: false });
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -281,7 +281,7 @@ export class TerminalEmulatorRuntime {
|
||||
webglAddon = null;
|
||||
disposeImageAddon();
|
||||
// WebGL and DOM renderers can have different cell dimensions.
|
||||
this.fitAndEmitResize?.(true);
|
||||
this.fitAndEmitResize?.({ force: true, shouldClaim: false });
|
||||
};
|
||||
|
||||
// Browser xterm is a renderer only; it never replies to terminal protocol queries.
|
||||
@@ -319,7 +319,7 @@ export class TerminalEmulatorRuntime {
|
||||
imageAddon = new ImageAddon();
|
||||
terminal.loadAddon(imageAddon);
|
||||
registerProtocolQuerySuppression();
|
||||
this.fitAndEmitResize?.(true);
|
||||
this.fitAndEmitResize?.({ force: true, shouldClaim: false });
|
||||
} catch {
|
||||
disposeWebglRenderer();
|
||||
}
|
||||
@@ -336,7 +336,9 @@ export class TerminalEmulatorRuntime {
|
||||
this.fitAddon = fitAddon;
|
||||
window.__paseoTerminal = terminal;
|
||||
|
||||
const fitAndEmitResize = (force: boolean): void => {
|
||||
const fitAndEmitResize = (resizeInput?: { force?: boolean; shouldClaim?: boolean }): void => {
|
||||
const force = resizeInput?.force ?? false;
|
||||
const shouldClaim = resizeInput?.shouldClaim ?? true;
|
||||
const currentTerminal = this.terminal;
|
||||
const currentFitAddon = this.fitAddon;
|
||||
if (!currentTerminal || !currentFitAddon) {
|
||||
@@ -365,11 +367,12 @@ export class TerminalEmulatorRuntime {
|
||||
this.callbacks.onResize?.({
|
||||
rows: nextRows,
|
||||
cols: nextCols,
|
||||
shouldClaim,
|
||||
});
|
||||
};
|
||||
this.fitAndEmitResize = fitAndEmitResize;
|
||||
|
||||
fitAndEmitResize(true);
|
||||
fitAndEmitResize({ force: true, shouldClaim: false });
|
||||
|
||||
const inputDisposable = terminal.onData((data) => {
|
||||
if (this.suppressInput) {
|
||||
@@ -454,12 +457,12 @@ export class TerminalEmulatorRuntime {
|
||||
terminal,
|
||||
});
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fitAndEmitResize(false);
|
||||
fitAndEmitResize({ shouldClaim: true });
|
||||
});
|
||||
resizeObserver.observe(input.root);
|
||||
resizeObserver.observe(input.host);
|
||||
|
||||
const windowResizeHandler = () => fitAndEmitResize(false);
|
||||
const windowResizeHandler = () => fitAndEmitResize({ shouldClaim: true });
|
||||
window.addEventListener("resize", windowResizeHandler);
|
||||
const windowFocusHandler = () => {
|
||||
this.handleVisibilityRestore();
|
||||
@@ -472,26 +475,23 @@ export class TerminalEmulatorRuntime {
|
||||
document.addEventListener("visibilitychange", documentVisibilityChangeHandler);
|
||||
|
||||
const visualViewport = window.visualViewport;
|
||||
const visualViewportResizeHandler = () => fitAndEmitResize(false);
|
||||
const visualViewportResizeHandler = () => fitAndEmitResize({ shouldClaim: true });
|
||||
visualViewport?.addEventListener("resize", visualViewportResizeHandler);
|
||||
|
||||
const fitInterval = window.setInterval(() => {
|
||||
fitAndEmitResize(false);
|
||||
}, 250);
|
||||
const fitTimeouts = FIT_TIMEOUT_DELAYS_MS.map((delayMs) =>
|
||||
window.setTimeout(() => {
|
||||
fitAndEmitResize(true);
|
||||
fitAndEmitResize({ force: true, shouldClaim: false });
|
||||
}, delayMs),
|
||||
);
|
||||
|
||||
const fontSet = document.fonts;
|
||||
const fontReadyHandler = () => {
|
||||
fitAndEmitResize(true);
|
||||
fitAndEmitResize({ force: true, shouldClaim: false });
|
||||
};
|
||||
fontSet?.addEventListener?.("loadingdone", fontReadyHandler);
|
||||
void fontSet?.ready
|
||||
.then(() => {
|
||||
fitAndEmitResize(true);
|
||||
fitAndEmitResize({ force: true, shouldClaim: false });
|
||||
return;
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -499,7 +499,7 @@ export class TerminalEmulatorRuntime {
|
||||
});
|
||||
|
||||
window.setTimeout(() => {
|
||||
fitAndEmitResize(true);
|
||||
fitAndEmitResize({ force: true, shouldClaim: false });
|
||||
}, 0);
|
||||
|
||||
if (input.initialSnapshot) {
|
||||
@@ -527,9 +527,6 @@ export class TerminalEmulatorRuntime {
|
||||
removeVisualViewportResize: () => {
|
||||
visualViewport?.removeEventListener("resize", visualViewportResizeHandler);
|
||||
},
|
||||
clearFitInterval: () => {
|
||||
window.clearInterval(fitInterval);
|
||||
},
|
||||
clearFitTimeouts: () => {
|
||||
for (const handle of fitTimeouts) {
|
||||
window.clearTimeout(handle);
|
||||
@@ -565,7 +562,6 @@ export class TerminalEmulatorRuntime {
|
||||
disposables.removeWindowFocus();
|
||||
disposables.removeDocumentVisibilityChange();
|
||||
disposables.removeVisualViewportResize();
|
||||
disposables.clearFitInterval();
|
||||
disposables.clearFitTimeouts();
|
||||
disposables.removeFontListeners();
|
||||
disposables.removeTouchListeners();
|
||||
@@ -635,8 +631,8 @@ export class TerminalEmulatorRuntime {
|
||||
this.processOutputQueue();
|
||||
}
|
||||
|
||||
resize(input?: { force?: boolean }): void {
|
||||
this.fitAndEmitResize?.(input?.force ?? false);
|
||||
resize(input?: { force?: boolean; shouldClaim?: boolean }): void {
|
||||
this.fitAndEmitResize?.(input);
|
||||
}
|
||||
|
||||
setTheme(input: { theme: ITheme }): void {
|
||||
|
||||
@@ -30,7 +30,7 @@ type InboundMessage =
|
||||
| { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
|
||||
| { type: "clear"; streamKey: string }
|
||||
| { type: "focus"; streamKey: string; forceRefocus?: boolean }
|
||||
| { type: "resize"; streamKey: string }
|
||||
| { type: "resize"; streamKey: string; shouldClaim?: boolean }
|
||||
| { type: "setTheme"; streamKey: string; theme: ITheme }
|
||||
| { type: "setScrollback"; streamKey: string; lines: number }
|
||||
| { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers }
|
||||
@@ -46,7 +46,7 @@ type OutboundMessage =
|
||||
| { type: "bridgeReady" }
|
||||
| { type: "rendererReady"; streamKey: string; isReady: boolean }
|
||||
| { type: "input"; streamKey: string; data: string }
|
||||
| { type: "resize"; streamKey: string; rows: number; cols: number }
|
||||
| { type: "resize"; streamKey: string; rows: number; cols: number; shouldClaim?: boolean }
|
||||
| {
|
||||
type: "terminalKey";
|
||||
streamKey: string;
|
||||
@@ -244,7 +244,7 @@ class TerminalWebViewBridge {
|
||||
this.runtime?.focus({ forceRefocus: message.forceRefocus });
|
||||
break;
|
||||
case "resize":
|
||||
this.runtime?.resize({ force: true });
|
||||
this.runtime?.resize({ force: true, shouldClaim: message.shouldClaim !== false });
|
||||
break;
|
||||
case "setTheme":
|
||||
this.applyThemeBackground(message.theme);
|
||||
@@ -273,8 +273,8 @@ class TerminalWebViewBridge {
|
||||
runtime.setCallbacks({
|
||||
callbacks: {
|
||||
onInput: (data) => sendToNative({ type: "input", streamKey: message.streamKey, data }),
|
||||
onResize: ({ rows, cols }) =>
|
||||
sendToNative({ type: "resize", streamKey: message.streamKey, rows, cols }),
|
||||
onResize: ({ rows, cols, shouldClaim }) =>
|
||||
sendToNative({ type: "resize", streamKey: message.streamKey, rows, cols, shouldClaim }),
|
||||
onTerminalKey: (input) =>
|
||||
sendToNative({ type: "terminalKey", streamKey: message.streamKey, ...input }),
|
||||
onPendingModifiersConsumed: () =>
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user