diff --git a/src/features/terminal/embedded.ts b/src/features/terminal/embedded.ts index 59ad09c94..27c45e046 100644 --- a/src/features/terminal/embedded.ts +++ b/src/features/terminal/embedded.ts @@ -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(); diff --git a/src/features/terminal/embeddedFit.test.ts b/src/features/terminal/embeddedFit.test.ts new file mode 100644 index 000000000..2812164d2 --- /dev/null +++ b/src/features/terminal/embeddedFit.test.ts @@ -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(); +}); diff --git a/src/features/terminal/fit.ts b/src/features/terminal/fit.ts index 8ae9a77b7..e3e074af1 100644 --- a/src/features/terminal/fit.ts +++ b/src/features/terminal/fit.ts @@ -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 | 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); diff --git a/src/features/terminal/session.test.ts b/src/features/terminal/session.test.ts index d8305bc8e..5daae0408 100644 --- a/src/features/terminal/session.test.ts +++ b/src/features/terminal/session.test.ts @@ -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(); diff --git a/src/features/terminal/session.ts b/src/features/terminal/session.ts index 8cfe922c9..7c65ce958 100644 --- a/src/features/terminal/session.ts +++ b/src/features/terminal/session.ts @@ -459,6 +459,7 @@ export class TerminalSession { } private scheduleFit() { + this.ptyResize.cancel(); this.fitter.schedule(); } diff --git a/src/features/terminal/terminal.test.ts b/src/features/terminal/terminal.test.ts index bfb75e56a..57239e6f3 100644 --- a/src/features/terminal/terminal.test.ts +++ b/src/features/terminal/terminal.test.ts @@ -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();