дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
62a39dadb7
коміт
004da36bed
6 files changed
+199
-24
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,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();
|
||||
|
||||
@@ -459,6 +459,7 @@ export class TerminalSession {
|
||||
}
|
||||
|
||||
private scheduleFit() {
|
||||
this.ptyResize.cancel();
|
||||
this.fitter.schedule();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Заблокувати користувача