fix(terminal): refit once a tool window drag settles (#2178)

Dragging a tool window border with a terminal open could freeze the UI: the fit scheduler refit xterm up to every 100 ms, and each fit reflows the whole scrollback. A terminal that keeps resizing now fits once, 120 ms after the requests stop, and the pane clips until then; an idle terminal still fits on the next frame. The process only receives the final size, in terminal sessions and embedded Run terminals alike.

Co-authored-by: brunobelloni <[email protected]>
Цей коміт міститься в:
Bruno Belloni authored and GitHub committed 2026-11-09 02:34:10 +08:00
1 джерело 62a39dadb7
коміт 004da36bed
6 files changed
+199 -24

No files matched your search

+11 -3
Переглянути файл
@@ -69,11 +69,15 @@ export function createEmbeddedTerminal(host: HTMLElement, opts: EmbeddedTerminal
const fit = new FitAddon();
term.loadAddon(fit);
live.add(term);
const processResize = settledResize((cols, rows) => opts.onResize(cols, rows));
let lastSize = { cols: term.cols, rows: term.rows };
const processResize = settledResize((cols, rows) => {
if (cols === lastSize.cols && rows === lastSize.rows) return;
lastSize = { cols, rows };
opts.onResize(cols, rows);
});
const disposables: IDisposable[] = [
term.onData((d) => opts.onData(d)),
term.onBinary((d) => opts.onData(d)),
term.onResize(({ cols, rows }) => processResize.resized(cols, rows)),
term.registerLinkProvider({
provideLinks(y, callback) {
const line = term.buffer.active.getLine(y - 1);
@@ -108,8 +112,12 @@ export function createEmbeddedTerminal(host: HTMLElement, opts: EmbeddedTerminal
const dims = fit.proposeDimensions();
if (!dims || !Number.isFinite(dims.cols) || !Number.isFinite(dims.rows)) return;
if (dims.cols !== term.cols || dims.rows !== term.rows) term.resize(dims.cols, dims.rows);
processResize.resized(dims.cols, dims.rows);
});
const scheduleFit = () => fitter.schedule();
const scheduleFit = () => {
processResize.cancel();
fitter.schedule();
};
const observer = new ResizeObserver(scheduleFit);
observer.observe(host);
scheduleFit();
+110
Переглянути файл
@@ -0,0 +1,110 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { createEmbeddedTerminal } from "./embedded";
import { FIT_SETTLE_MS, PROCESS_RESIZE_DELAY_MS } from "./fit";
const layout = vi.hoisted(() => ({ cols: 80, rows: 24, resize: () => {} }));
vi.mock("@xterm/xterm", () => ({
Terminal: class {
cols = 80;
rows = 24;
resized = (_size: { cols: number; rows: number }) => {};
loadAddon() {}
attachCustomKeyEventHandler() {}
open() {}
resize(cols: number, rows: number) {
this.cols = cols;
this.rows = rows;
this.resized({ cols, rows });
}
onData() {
return { dispose() {} };
}
onBinary() {
return { dispose() {} };
}
onResize(callback: typeof this.resized) {
this.resized = callback;
return { dispose() {} };
}
registerLinkProvider() {
return { dispose() {} };
}
dispose() {}
},
}));
vi.mock("@xterm/addon-fit", () => ({
FitAddon: class {
proposeDimensions() {
return { cols: layout.cols, rows: layout.rows };
}
},
}));
vi.mock("./session", () => ({ fontFor: () => ({}), LINK_HANDLER: {}, openExternal: vi.fn(), openFileLink: vi.fn() }));
vi.mock("./theme", () => ({ readTerminalTheme: () => ({}), remeasureWhenFontsLoad: vi.fn(), MIN_CONTRAST_RATIO: 1 }));
beforeEach(() => {
vi.useFakeTimers();
layout.cols = 80;
layout.rows = 24;
vi.stubGlobal(
"ResizeObserver",
class {
constructor(callback: () => void) {
layout.resize = callback;
}
observe() {}
disconnect() {}
},
);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
document.body.replaceChildren();
});
function terminal() {
const host = document.createElement("div");
Object.defineProperty(host, "offsetWidth", { value: 600 });
Object.defineProperty(host, "offsetHeight", { value: 400 });
document.body.append(host);
const onResize = vi.fn();
const term = createEmbeddedTerminal(host, { onData: vi.fn(), onResize });
vi.advanceTimersByTime(FIT_SETTLE_MS + PROCESS_RESIZE_DELAY_MS);
onResize.mockClear();
return { term, onResize };
}
it.each([false, true])("reports only the final size after a drag, including unchanged final cells: %s", (sameCells) => {
const { term, onResize } = terminal();
try {
layout.cols = 79;
layout.resize();
vi.advanceTimersToNextFrame();
for (let i = 0; i < 40; i++) {
if (!sameCells) layout.cols--;
layout.resize();
vi.advanceTimersByTime(16);
}
expect(onResize).not.toHaveBeenCalled();
vi.advanceTimersByTime(FIT_SETTLE_MS + PROCESS_RESIZE_DELAY_MS);
expect(onResize).toHaveBeenCalledExactlyOnceWith(layout.cols, layout.rows);
layout.resize();
vi.advanceTimersByTime(FIT_SETTLE_MS + PROCESS_RESIZE_DELAY_MS);
expect(onResize).toHaveBeenCalledOnce();
} finally {
term.dispose();
}
});
it.each([false, true])("disposes with no pending fit or process resize timer: %s", (deferred) => {
const { term, onResize } = terminal();
layout.cols = 79;
layout.resize();
vi.advanceTimersToNextFrame();
if (deferred) layout.resize();
term.dispose();
expect(vi.getTimerCount()).toBe(0);
vi.advanceTimersByTime(FIT_SETTLE_MS + PROCESS_RESIZE_DELAY_MS);
expect(onResize).not.toHaveBeenCalled();
});
+15 -14
Переглянути файл
@@ -1,5 +1,8 @@
/** Minimum time between two fits while the host keeps resizing (a tool window drag). */
export const FIT_INTERVAL_MS = 100;
/**
* How long requests must stop before a terminal that keeps resizing (a tool window drag)
* refits. A fit reflows the whole scrollback, so during the drag the pane only clips.
*/
export const FIT_SETTLE_MS = 120;
/** How long the size must stay put before the process behind the terminal is told. */
export const PROCESS_RESIZE_DELAY_MS = 150;
@@ -11,36 +14,34 @@ export interface FitScheduler {
}
/**
* Coalesces fit requests. An idle terminal fits on the next frame; while its host keeps
* resizing it refits at most every FIT_INTERVAL_MS, and once more when the resizing
* stops. Re-rendering xterm on every pointer move of a drag costs a frame each time.
* Coalesces fit requests. An idle terminal fits on the next frame; while requests keep
* coming (its host is being resized) it fits once, FIT_SETTLE_MS after the last one.
*/
export function fitScheduler(fit: () => void): FitScheduler {
let raf = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
let pending = false;
let last = -Infinity;
let lastRequest = -Infinity;
const cancel = () => {
cancelAnimationFrame(raf);
clearTimeout(timer);
raf = 0;
timer = undefined;
pending = false;
};
const run = () => {
cancel();
last = performance.now();
fit();
};
return {
schedule() {
if (pending) return;
pending = true;
const wait = last + FIT_INTERVAL_MS - performance.now();
if (wait > 0) {
timer = setTimeout(run, wait);
const now = performance.now();
const resizing = now - lastRequest < FIT_SETTLE_MS;
lastRequest = now;
if (resizing) {
cancel();
timer = setTimeout(run, FIT_SETTLE_MS);
return;
}
if (timer !== undefined) return;
raf = requestAnimationFrame(run);
// WebKit pauses rAF in background windows; don't leave the terminal unfitted until it is shown.
timer = setTimeout(run, 100);
+2 -2
Переглянути файл
@@ -2,7 +2,7 @@ import { useProject } from "@/platform/project";
import { workflowSettings } from "@/platform/workflowSettings";
import { getSession, newTab, splitTab, syncTerminalProject, unbindTerminals, useTerminals } from "./store";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { FIT_INTERVAL_MS } from "./fit";
import { FIT_SETTLE_MS } from "./fit";
import { findTerminalLinks, type TerminalLink } from "./links";
import { openFileLink, resolveStartCwd, TerminalSession } from "./session";
@@ -208,7 +208,7 @@ describe("terminal open", () => {
width = 400;
height = 300;
vi.advanceTimersByTime(FIT_INTERVAL_MS);
vi.advanceTimersByTime(FIT_SETTLE_MS);
expect(open).toHaveBeenCalledTimes(1);
session.dispose();
container.remove();
+1
Переглянути файл
@@ -459,6 +459,7 @@ export class TerminalSession {
}
private scheduleFit() {
this.ptyResize.cancel();
this.fitter.schedule();
}
+60 -5
Переглянути файл
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { FIT_INTERVAL_MS, PROCESS_RESIZE_DELAY_MS, fitScheduler, settledResize } from "./fit";
import { FIT_SETTLE_MS, PROCESS_RESIZE_DELAY_MS, fitScheduler, settledResize } from "./fit";
import { isAllowedIdeShortcut, terminalKeyAction } from "./keys";
import { matchCounter } from "./FindBar";
import { buildSearchDecorations } from "./theme";
@@ -438,26 +438,81 @@ describe("terminal tab titles", () => {
describe("fit scheduling", () => {
afterEach(() => vi.useRealTimers());
it("refits at most once per interval while resizing, and once after it stops", () => {
it("fits an idle terminal on the next frame, and a resizing one once it settles", () => {
vi.useFakeTimers();
const fit = vi.fn();
const f = fitScheduler(fit);
f.schedule();
vi.advanceTimersToNextFrame();
expect(fit).toHaveBeenCalledTimes(1);
for (let i = 0; i < 6; i++) {
for (let i = 0; i < 30; i++) {
f.schedule();
vi.advanceTimersByTime(16);
}
expect(fit).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(FIT_INTERVAL_MS);
vi.advanceTimersByTime(FIT_SETTLE_MS);
expect(fit).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(FIT_SETTLE_MS);
f.schedule();
f.cancel();
vi.advanceTimersByTime(FIT_INTERVAL_MS * 2);
vi.advanceTimersByTime(FIT_SETTLE_MS * 2);
expect(fit).toHaveBeenCalledTimes(2);
});
it("fits a lone second request after it settles without needing another request", () => {
vi.useFakeTimers();
const fit = vi.fn();
const f = fitScheduler(fit);
f.schedule();
vi.advanceTimersToNextFrame();
vi.advanceTimersByTime(40);
f.schedule();
vi.advanceTimersByTime(FIT_SETTLE_MS - 1);
expect(fit).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
expect(fit).toHaveBeenCalledTimes(2);
expect(vi.getTimerCount()).toBe(0);
});
it("fits without animation frames in a background window", () => {
vi.useFakeTimers();
const frame = vi.spyOn(window, "requestAnimationFrame").mockReturnValue(0);
const fit = vi.fn();
try {
fitScheduler(fit).schedule();
vi.advanceTimersByTime(100);
expect(fit).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
} finally {
frame.mockRestore();
}
});
it.each([false, true])("cancels all pending work after repeated requests: %s", (repeated) => {
vi.useFakeTimers();
const fit = vi.fn();
const f = fitScheduler(fit);
f.schedule();
if (repeated) f.schedule();
f.cancel();
expect(vi.getTimerCount()).toBe(0);
vi.advanceTimersByTime(FIT_SETTLE_MS * 2);
expect(fit).not.toHaveBeenCalled();
});
it("flushes a deferred fit immediately and leaves no delayed duplicate", () => {
vi.useFakeTimers();
const fit = vi.fn();
const f = fitScheduler(fit);
f.schedule();
f.schedule();
f.flush();
expect(fit).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
vi.advanceTimersByTime(FIT_SETTLE_MS * 2);
expect(fit).toHaveBeenCalledOnce();
});
it("reports only the size the terminal settles on", () => {
vi.useFakeTimers();
const report = vi.fn();