mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
fix(workbench): Keep tool window resizing smooth over a terminal or long conversation (#137)
Dragging the bottom tool window edge over a terminal refit xterm on every pointer move and sent each intermediate size to the shell, which redrew its prompt every time. Terminals now refit at most every 100 ms while their host keeps resizing, plus once when it stops, and the process only learns the size once it has held for 150 ms. This covers the Terminal tool window and terminals embedded in Run consoles. Resizing the width of an agent conversation reflowed every entry, including the off-screen ones (14-16 ms per move in a long session). Entries now use content-visibility: auto, so only the visible ones are laid out.
This commit is contained in:
@@ -117,6 +117,11 @@
|
||||
.stream:focus {
|
||||
outline: none;
|
||||
}
|
||||
/* Off-screen entries skip layout, so resizing the pane doesn't reflow a long conversation. */
|
||||
.stream > * {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-block-size: auto 80px;
|
||||
}
|
||||
|
||||
/* Your message with its actions, shown on hover. */
|
||||
.message {
|
||||
|
||||
@@ -10,6 +10,7 @@ import "@xterm/xterm/css/xterm.css";
|
||||
import type { EmbeddedTerminal, EmbeddedTerminalOptions } from "@/workbench/contributions";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import { fitScheduler, settledResize } from "./fit";
|
||||
import { findTerminalLinks } from "./links";
|
||||
import { LINK_HANDLER, fontFor, openExternal, openFileLink } from "./session";
|
||||
import { readTerminalTheme, remeasureWhenFontsLoad } from "./theme";
|
||||
@@ -49,10 +50,11 @@ 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));
|
||||
const disposables: IDisposable[] = [
|
||||
term.onData((d) => opts.onData(d)),
|
||||
term.onBinary((d) => opts.onData(d)),
|
||||
term.onResize(({ cols, rows }) => opts.onResize(cols, rows)),
|
||||
term.onResize(({ cols, rows }) => processResize.resized(cols, rows)),
|
||||
term.registerLinkProvider({
|
||||
provideLinks(y, callback) {
|
||||
const line = term.buffer.active.getLine(y - 1);
|
||||
@@ -76,17 +78,13 @@ export function createEmbeddedTerminal(host: HTMLElement, opts: EmbeddedTerminal
|
||||
term.open(host);
|
||||
if (opts["aria-label"]) term.textarea?.setAttribute("aria-label", opts["aria-label"]);
|
||||
|
||||
let raf = 0;
|
||||
const doFit = () => {
|
||||
raf = 0;
|
||||
const fitter = fitScheduler(() => {
|
||||
if (!host.isConnected || host.offsetWidth === 0 || host.offsetHeight === 0) return;
|
||||
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);
|
||||
};
|
||||
const scheduleFit = () => {
|
||||
if (!raf) raf = requestAnimationFrame(doFit);
|
||||
};
|
||||
});
|
||||
const scheduleFit = () => fitter.schedule();
|
||||
const observer = new ResizeObserver(scheduleFit);
|
||||
observer.observe(host);
|
||||
scheduleFit();
|
||||
@@ -98,12 +96,10 @@ export function createEmbeddedTerminal(host: HTMLElement, opts: EmbeddedTerminal
|
||||
write: (data) => term.write(data),
|
||||
clear: () => term.reset(),
|
||||
focus: () => term.focus(),
|
||||
fit: () => {
|
||||
cancelAnimationFrame(raf);
|
||||
doFit();
|
||||
},
|
||||
fit: () => fitter.flush(),
|
||||
dispose: () => {
|
||||
cancelAnimationFrame(raf);
|
||||
fitter.cancel();
|
||||
processResize.cancel();
|
||||
observer.disconnect();
|
||||
for (const d of disposables) d.dispose();
|
||||
live.delete(term);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/** Minimum time between two fits while the host keeps resizing (a tool window drag). */
|
||||
export const FIT_INTERVAL_MS = 100;
|
||||
/** How long the size must stay put before the process behind the terminal is told. */
|
||||
export const PROCESS_RESIZE_DELAY_MS = 150;
|
||||
|
||||
export interface FitScheduler {
|
||||
schedule(): void;
|
||||
/** Fits now, dropping any scheduled fit. */
|
||||
flush(): void;
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function fitScheduler(fit: () => void): FitScheduler {
|
||||
let raf = 0;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let pending = false;
|
||||
let last = -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);
|
||||
return;
|
||||
}
|
||||
raf = requestAnimationFrame(run);
|
||||
// WebKit pauses rAF in background windows; don't leave the terminal unfitted until it is shown.
|
||||
timer = setTimeout(run, 100);
|
||||
},
|
||||
flush: run,
|
||||
cancel,
|
||||
};
|
||||
}
|
||||
|
||||
/** Calls `report` once the size has stayed the same for PROCESS_RESIZE_DELAY_MS. */
|
||||
export function settledResize(report: (cols: number, rows: number) => void) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
return {
|
||||
resized(cols: number, rows: number) {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => report(cols, rows), PROCESS_RESIZE_DELAY_MS);
|
||||
},
|
||||
cancel: () => clearTimeout(timer),
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import "@xterm/xterm/css/xterm.css";
|
||||
import { Channel } from "@tauri-apps/api/core";
|
||||
import { call, commands, isTauri, type TerminalId } from "@/platform/ipc";
|
||||
import { editorService, notifications } from "@/workbench/contributions";
|
||||
import { fitScheduler, settledResize } from "./fit";
|
||||
import { findTerminalLinks, resolvePath, type TerminalLink } from "./links";
|
||||
import { splitCommandLine } from "@/platform/commandLine";
|
||||
import { useProject } from "@/platform/project";
|
||||
@@ -158,8 +159,8 @@ export class TerminalSession {
|
||||
private disposed = false;
|
||||
private disposables: IDisposable[] = [];
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
private fitRaf = 0;
|
||||
private fitTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private readonly fitter = fitScheduler(() => this.doFit());
|
||||
private readonly ptyResize = settledResize((cols, rows) => this.resizePty(cols, rows));
|
||||
private inputRaf = 0;
|
||||
private pendingInput = "";
|
||||
private lastSize = { cols: 0, rows: 0 };
|
||||
@@ -326,17 +327,7 @@ export class TerminalSession {
|
||||
}
|
||||
|
||||
private scheduleFit() {
|
||||
if (this.fitRaf) return;
|
||||
const run = () => {
|
||||
if (!this.fitRaf) return;
|
||||
cancelAnimationFrame(this.fitRaf);
|
||||
clearTimeout(this.fitTimer);
|
||||
this.fitRaf = 0;
|
||||
this.doFit();
|
||||
};
|
||||
this.fitRaf = requestAnimationFrame(run);
|
||||
// WebKit pauses rAF in background windows; don't leave the shell unstarted until the window is shown.
|
||||
this.fitTimer = setTimeout(run, 100);
|
||||
this.fitter.schedule();
|
||||
}
|
||||
|
||||
private doFit() {
|
||||
@@ -347,12 +338,18 @@ export class TerminalSession {
|
||||
if (dims.cols !== this.term.cols || dims.rows !== this.term.rows) this.term.resize(dims.cols, dims.rows);
|
||||
if (this.snapshot.status === "idle") {
|
||||
void this.start();
|
||||
} else if (this.ptyId !== null && (this.lastSize.cols !== dims.cols || this.lastSize.rows !== dims.rows)) {
|
||||
this.lastSize = { cols: dims.cols, rows: dims.rows };
|
||||
void commands.termResize(this.ptyId, dims.cols, dims.rows);
|
||||
} else if (this.ptyId !== null) {
|
||||
this.ptyResize.resized(dims.cols, dims.rows);
|
||||
}
|
||||
}
|
||||
|
||||
private resizePty(cols: number, rows: number) {
|
||||
if (this.disposed || this.ptyId === null) return;
|
||||
if (this.lastSize.cols === cols && this.lastSize.rows === rows) return;
|
||||
this.lastSize = { cols, rows };
|
||||
void commands.termResize(this.ptyId, cols, rows);
|
||||
}
|
||||
|
||||
/** Spawns the PTY with the current size. */
|
||||
async start() {
|
||||
if (this.disposed || this.snapshot.status === "starting" || this.snapshot.status === "running") return;
|
||||
@@ -519,8 +516,8 @@ export class TerminalSession {
|
||||
dispose() {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
cancelAnimationFrame(this.fitRaf);
|
||||
clearTimeout(this.fitTimer);
|
||||
this.fitter.cancel();
|
||||
this.ptyResize.cancel();
|
||||
cancelAnimationFrame(this.inputRaf);
|
||||
this.resizeObserver?.disconnect();
|
||||
this.host.removeEventListener("mousedown", this.onMiddleClick);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { FIT_INTERVAL_MS, PROCESS_RESIZE_DELAY_MS, fitScheduler, settledResize } from "./fit";
|
||||
import { isAllowedIdeShortcut, terminalKeyAction } from "./keys";
|
||||
import { matchCounter } from "./FindBar";
|
||||
import { buildSearchDecorations } from "./theme";
|
||||
@@ -279,3 +280,38 @@ describe("terminal tab titles", () => {
|
||||
expect(trimMiddle("abc", 5)).toBe("abc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fit scheduling", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("refits at most once per interval while resizing, and once after it stops", () => {
|
||||
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++) {
|
||||
f.schedule();
|
||||
vi.advanceTimersByTime(16);
|
||||
}
|
||||
expect(fit).toHaveBeenCalledTimes(1);
|
||||
vi.advanceTimersByTime(FIT_INTERVAL_MS);
|
||||
expect(fit).toHaveBeenCalledTimes(2);
|
||||
f.schedule();
|
||||
f.cancel();
|
||||
vi.advanceTimersByTime(FIT_INTERVAL_MS * 2);
|
||||
expect(fit).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reports only the size the terminal settles on", () => {
|
||||
vi.useFakeTimers();
|
||||
const report = vi.fn();
|
||||
const r = settledResize(report);
|
||||
r.resized(80, 20);
|
||||
vi.advanceTimersByTime(PROCESS_RESIZE_DELAY_MS / 2);
|
||||
r.resized(80, 24);
|
||||
vi.advanceTimersByTime(PROCESS_RESIZE_DELAY_MS);
|
||||
expect(report).toHaveBeenCalledExactlyOnceWith(80, 24);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user