diff --git a/config/patches/@xterm__addon-fit@0.11.0.patch b/config/patches/@xterm__addon-fit@0.11.0.patch deleted file mode 100644 index 9883fb55c..000000000 --- a/config/patches/@xterm__addon-fit@0.11.0.patch +++ /dev/null @@ -1,65 +0,0 @@ -diff --git a/lib/addon-fit.js b/lib/addon-fit.js -index 9b25be35bbc0a6826b769e9bbc09f0d8ea1816e4..8c11959d05e8d51350ee79b7be415fa85e0b1468 100644 ---- a/lib/addon-fit.js -+++ b/lib/addon-fit.js -@@ -1,2 +1,2 @@ --!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core._renderService.dimensions;if(0===e.css.cell.width||0===e.css.cell.height)return;const t=0===this._terminal.options.scrollback?0:this._terminal.options.overviewRuler?.width||14,r=window.getComputedStyle(this._terminal.element.parentElement),i=parseInt(r.getPropertyValue("height")),o=Math.max(0,parseInt(r.getPropertyValue("width"))),s=window.getComputedStyle(this._terminal.element),n=i-(parseInt(s.getPropertyValue("padding-top"))+parseInt(s.getPropertyValue("padding-bottom"))),l=o-(parseInt(s.getPropertyValue("padding-right"))+parseInt(s.getPropertyValue("padding-left")))-t;return{cols:Math.max(2,Math.floor(l/e.css.cell.width)),rows:Math.max(1,Math.floor(n/e.css.cell.height))}}}})(),e})())); -+!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;const i=this._terminal.cols!==e.cols,r2=this._terminal.rows!==e.rows,b=this._terminal.buffer.active,w=b.viewportY>=b.baseY;t._renderService.clear();(i||r2)&&(this._terminal.resize(e.cols,e.rows),w&&this._terminal.scrollToBottom());this._terminal.refresh(0,this._terminal.rows-1)}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core._renderService.dimensions;if(0===e.css.cell.width||0===e.css.cell.height)return;const t=0===this._terminal.options.scrollback?0:this._terminal.options.overviewRuler?.width||14,r=window.getComputedStyle(this._terminal.element.parentElement),i=parseInt(r.getPropertyValue("height")),o=Math.max(0,parseInt(r.getPropertyValue("width"))),s=window.getComputedStyle(this._terminal.element),n=i-(parseInt(s.getPropertyValue("padding-top"))+parseInt(s.getPropertyValue("padding-bottom"))),l=o-(parseInt(s.getPropertyValue("padding-right"))+parseInt(s.getPropertyValue("padding-left")))-t;return{cols:Math.max(2,Math.floor(l/e.css.cell.width)),rows:Math.max(1,Math.floor(n/e.css.cell.height))}}}})(),e})())); - //# sourceMappingURL=addon-fit.js.map -\ No newline at end of file -diff --git a/lib/addon-fit.mjs b/lib/addon-fit.mjs -index 24d4f61877d369edb7f2773570575a1c10edf832..a6a841b9fb2e03082f417bde57a8e74376e4c22f 100644 ---- a/lib/addon-fit.mjs -+++ b/lib/addon-fit.mjs -@@ -14,5 +14,5 @@ - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ --var h=2,_=1,o=class{activate(e){this._terminal=e}dispose(){}fit(){let e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;let t=this._terminal._core;(this._terminal.rows!==e.rows||this._terminal.cols!==e.cols)&&(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal||!this._terminal.element||!this._terminal.element.parentElement)return;let t=this._terminal._core._renderService.dimensions;if(t.css.cell.width===0||t.css.cell.height===0)return;let s=this._terminal.options.scrollback===0?0:this._terminal.options.overviewRuler?.width||14,r=window.getComputedStyle(this._terminal.element.parentElement),l=parseInt(r.getPropertyValue("height")),a=Math.max(0,parseInt(r.getPropertyValue("width"))),i=window.getComputedStyle(this._terminal.element),n={top:parseInt(i.getPropertyValue("padding-top")),bottom:parseInt(i.getPropertyValue("padding-bottom")),right:parseInt(i.getPropertyValue("padding-right")),left:parseInt(i.getPropertyValue("padding-left"))},m=n.top+n.bottom,d=n.right+n.left,c=l-m,p=a-d-s;return{cols:Math.max(h,Math.floor(p/t.css.cell.width)),rows:Math.max(_,Math.floor(c/t.css.cell.height))}}};export{o as FitAddon}; -+var h=2,_=1,o=class{activate(e){this._terminal=e}dispose(){}fit(){let e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;let t=this._terminal._core,i=this._terminal.cols!==e.cols,r=this._terminal.rows!==e.rows,b=this._terminal.buffer.active,w=b.viewportY>=b.baseY;t._renderService.clear();(i||r)&&(this._terminal.resize(e.cols,e.rows),w&&this._terminal.scrollToBottom());this._terminal.refresh(0,this._terminal.rows-1)}proposeDimensions(){if(!this._terminal||!this._terminal.element||!this._terminal.element.parentElement)return;let t=this._terminal._core._renderService.dimensions;if(t.css.cell.width===0||t.css.cell.height===0)return;let s=this._terminal.options.scrollback===0?0:this._terminal.options.overviewRuler?.width||14,r=window.getComputedStyle(this._terminal.element.parentElement),l=parseInt(r.getPropertyValue("height")),a=Math.max(0,parseInt(r.getPropertyValue("width"))),i=window.getComputedStyle(this._terminal.element),n={top:parseInt(i.getPropertyValue("padding-top")),bottom:parseInt(i.getPropertyValue("padding-bottom")),right:parseInt(i.getPropertyValue("padding-right")),left:parseInt(i.getPropertyValue("padding-left"))},m=n.top+n.bottom,d=n.right+n.left,c=l-m,p=a-d-s;return{cols:Math.max(h,Math.floor(p/t.css.cell.width)),rows:Math.max(_,Math.floor(c/t.css.cell.height))}}};export{o as FitAddon}; - //# sourceMappingURL=addon-fit.mjs.map -diff --git a/src/FitAddon.ts b/src/FitAddon.ts -index a282ed3f8c607f5d073af3d0f1585e8cbc7b9f63..63796e8cc23f5d3dff61f322ee9a16747da51c4c 100644 ---- a/src/FitAddon.ts -+++ b/src/FitAddon.ts -@@ -41,10 +41,37 @@ export class FitAddon implements ITerminalAddon , IFitApi { - // TODO: Remove reliance on private API - const core = (this._terminal as any)._core; - -- // Force a full render -- if (this._terminal.rows !== dims.rows || this._terminal.cols !== dims.cols) { -- core._renderService.clear(); -+ const colsChanged = this._terminal.cols !== dims.cols; -+ const rowsChanged = this._terminal.rows !== dims.rows; -+ -+ // Capture viewport scroll position before resize so we can restore it -+ // after xterm.js reflows the scrollback buffer. `baseY` is the number -+ // of rows scrolled into the scrollback; `viewportY === baseY` means the -+ // viewport is pinned to the bottom (the common interactive-shell case). -+ const buf = this._terminal.buffer.active; -+ const wasAtBottom = buf.viewportY >= buf.baseY; -+ -+ // Always clear the renderer — even same-dimension refits may need a -+ // repaint when the container's pixel size changed by less than one cell -+ // (upstream fix: xtermjs/xterm.js#5777). -+ core._renderService.clear(); -+ -+ if (colsChanged || rowsChanged) { - this._terminal.resize(dims.cols, dims.rows); -+ -+ // After reflow the viewport can land at a stale scroll offset — the -+ // scrollback appears to vanish (blank rows at top, recent output at -+ // bottom). Snap back to the bottom if the user was following output; -+ // otherwise preserve their scroll-up position. -+ if (wasAtBottom) { -+ this._terminal.scrollToBottom(); -+ } - } -+ -+ // Always refresh the full viewport after clear() — even when cols/rows -+ // did not change, the pixel-level container resize can leave the WebGL -+ // or DOM renderer with blank content (e.g. when a new pane opens and the -+ // container shrinks by less than one cell width). -+ this._terminal.refresh(0, this._terminal.rows - 1); - } - diff --git a/package.json b/package.json index 43271640e..be1e88e9b 100644 --- a/package.json +++ b/package.json @@ -65,14 +65,14 @@ "@tiptap/pm": "^3.22.4", "@tiptap/react": "^3.22.4", "@tiptap/starter-kit": "^3.22.4", - "@xterm/addon-fit": "^0.11.0", - "@xterm/addon-search": "^0.16.0", - "@xterm/addon-serialize": "^0.14.0", - "@xterm/addon-unicode11": "^0.9.0", - "@xterm/addon-web-links": "^0.12.0", - "@xterm/addon-webgl": "^0.19.0", - "@xterm/headless": "^6.0.0", - "@xterm/xterm": "^6.0.0", + "@xterm/addon-fit": "0.12.0-beta.198", + "@xterm/addon-search": "0.17.0-beta.198", + "@xterm/addon-serialize": "0.15.0-beta.198", + "@xterm/addon-unicode11": "0.10.0-beta.198", + "@xterm/addon-web-links": "0.13.0-beta.198", + "@xterm/addon-webgl": "0.20.0-beta.197", + "@xterm/headless": "6.1.0-beta.198", + "@xterm/xterm": "6.1.0-beta.198", "agent-browser": "~0.24.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -151,7 +151,6 @@ "node-pty" ], "patchedDependencies": { - "@xterm/addon-fit@0.11.0": "config/patches/@xterm__addon-fit@0.11.0.patch", "node-pty@1.1.0": "patches/node-pty@1.1.0.patch" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b7b084ab..772a8dd11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,9 +5,6 @@ settings: excludeLinksFromLockfile: false patchedDependencies: - '@xterm/addon-fit@0.11.0': - hash: 12b1dc2811a859b6703ebb63be654d1682ff1bb6045713a54e7d47e28deb7776 - path: config/patches/@xterm__addon-fit@0.11.0.patch node-pty@1.1.0: hash: 02e16954edbb8e511963557e3b2c80f8a6fb230cd0c6a75ade936022f3347322 path: patches/node-pty@1.1.0.patch @@ -83,29 +80,29 @@ importers: specifier: ^3.22.4 version: 3.22.4 '@xterm/addon-fit': - specifier: ^0.11.0 - version: 0.11.0(patch_hash=12b1dc2811a859b6703ebb63be654d1682ff1bb6045713a54e7d47e28deb7776) + specifier: 0.12.0-beta.198 + version: 0.12.0-beta.198(@xterm/xterm@6.1.0-beta.198) '@xterm/addon-search': - specifier: ^0.16.0 - version: 0.16.0 + specifier: 0.17.0-beta.198 + version: 0.17.0-beta.198(@xterm/xterm@6.1.0-beta.198) '@xterm/addon-serialize': - specifier: ^0.14.0 - version: 0.14.0 + specifier: 0.15.0-beta.198 + version: 0.15.0-beta.198(@xterm/xterm@6.1.0-beta.198) '@xterm/addon-unicode11': - specifier: ^0.9.0 - version: 0.9.0 + specifier: 0.10.0-beta.198 + version: 0.10.0-beta.198(@xterm/xterm@6.1.0-beta.198) '@xterm/addon-web-links': - specifier: ^0.12.0 - version: 0.12.0 + specifier: 0.13.0-beta.198 + version: 0.13.0-beta.198(@xterm/xterm@6.1.0-beta.198) '@xterm/addon-webgl': - specifier: ^0.19.0 - version: 0.19.0 + specifier: 0.20.0-beta.197 + version: 0.20.0-beta.197(@xterm/xterm@6.1.0-beta.198) '@xterm/headless': - specifier: ^6.0.0 - version: 6.0.0 + specifier: 6.1.0-beta.198 + version: 6.1.0-beta.198 '@xterm/xterm': - specifier: ^6.0.0 - version: 6.0.0 + specifier: 6.1.0-beta.198 + version: 6.1.0-beta.198 agent-browser: specifier: ~0.24.1 version: 0.24.1 @@ -2917,29 +2914,41 @@ packages: engines: {node: '>=10.0.0'} deprecated: this version has critical issues, please update to the latest version - '@xterm/addon-fit@0.11.0': - resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} + '@xterm/addon-fit@0.12.0-beta.198': + resolution: {integrity: sha512-1EZFzOaUjRnlo5p2diIV9+HHVtHqr0O7TEFCstSuFqV/5QaKkRiWiKaMfsPEg2pF5P7yBOASk73yUPII7oquaA==} + peerDependencies: + '@xterm/xterm': ^6.1.0-beta.198 - '@xterm/addon-search@0.16.0': - resolution: {integrity: sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==} + '@xterm/addon-search@0.17.0-beta.198': + resolution: {integrity: sha512-loNSX0cB4P+9Sg13sgB/cQPPBgdOi1hxRyZ75Jj90+4iKe227mRmgzdqU3wxr10nyFWJWCpysCjVT+47tdC8mQ==} + peerDependencies: + '@xterm/xterm': ^6.1.0-beta.198 - '@xterm/addon-serialize@0.14.0': - resolution: {integrity: sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==} + '@xterm/addon-serialize@0.15.0-beta.198': + resolution: {integrity: sha512-0yf13cLPaKX8nkgUqclcdbU5FexacA/xnvWzHzGFYTku0WH+SqygbQcDOcg4bTUGgCcvfZqntEb1NQpr+69KgQ==} + peerDependencies: + '@xterm/xterm': ^6.1.0-beta.198 - '@xterm/addon-unicode11@0.9.0': - resolution: {integrity: sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==} + '@xterm/addon-unicode11@0.10.0-beta.198': + resolution: {integrity: sha512-3itjZ5lTQ5tnDcpUAplX88BsTJTZOvEzX9wNTNMO2aPuctLavMNg2+pXU8KTkeRhUZnDyhx9Lsv81dDs94vWDQ==} + peerDependencies: + '@xterm/xterm': ^6.1.0-beta.198 - '@xterm/addon-web-links@0.12.0': - resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==} + '@xterm/addon-web-links@0.13.0-beta.198': + resolution: {integrity: sha512-qA/B1tuNLpqlzscrAVFjLKjuH0CmKKyfbTn78Y3RLKs6H7xxdYSE6/DjJ1zg9C6aDtLwF9lsaOFpPSPf2AfpYQ==} + peerDependencies: + '@xterm/xterm': ^6.1.0-beta.198 - '@xterm/addon-webgl@0.19.0': - resolution: {integrity: sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==} + '@xterm/addon-webgl@0.20.0-beta.197': + resolution: {integrity: sha512-0o30FvLzfd9ODuB1gbpwYMGQHYiz0FebmVaMP0Idesw1sLIG3qc7aIIBQEUnWiBDsKbDHhaysW8WTxacg2AhaQ==} + peerDependencies: + '@xterm/xterm': ^6.1.0-beta.198 - '@xterm/headless@6.0.0': - resolution: {integrity: sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==} + '@xterm/headless@6.1.0-beta.198': + resolution: {integrity: sha512-pDS/I5HLGwwJf8sWZFaT+qbHxgFu0qdVQv5/XDzhNyKXMXQJ7cL3ZsJsIox5JxLo2ZuYLVVY8/lirHronhOSfA==} - '@xterm/xterm@6.0.0': - resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} + '@xterm/xterm@6.1.0-beta.198': + resolution: {integrity: sha512-udjhNFi4YfypbVBJV7gFNwQV3KziZFJdq+fg87WefGF5JkGbC+4LvIhEsHXR0Kb5Ez8ESxeqM7Yjvj+kw8zDAg==} abbrev@3.0.1: resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} @@ -8671,21 +8680,33 @@ snapshots: '@xmldom/xmldom@0.8.11': {} - '@xterm/addon-fit@0.11.0(patch_hash=12b1dc2811a859b6703ebb63be654d1682ff1bb6045713a54e7d47e28deb7776)': {} + '@xterm/addon-fit@0.12.0-beta.198(@xterm/xterm@6.1.0-beta.198)': + dependencies: + '@xterm/xterm': 6.1.0-beta.198 - '@xterm/addon-search@0.16.0': {} + '@xterm/addon-search@0.17.0-beta.198(@xterm/xterm@6.1.0-beta.198)': + dependencies: + '@xterm/xterm': 6.1.0-beta.198 - '@xterm/addon-serialize@0.14.0': {} + '@xterm/addon-serialize@0.15.0-beta.198(@xterm/xterm@6.1.0-beta.198)': + dependencies: + '@xterm/xterm': 6.1.0-beta.198 - '@xterm/addon-unicode11@0.9.0': {} + '@xterm/addon-unicode11@0.10.0-beta.198(@xterm/xterm@6.1.0-beta.198)': + dependencies: + '@xterm/xterm': 6.1.0-beta.198 - '@xterm/addon-web-links@0.12.0': {} + '@xterm/addon-web-links@0.13.0-beta.198(@xterm/xterm@6.1.0-beta.198)': + dependencies: + '@xterm/xterm': 6.1.0-beta.198 - '@xterm/addon-webgl@0.19.0': {} + '@xterm/addon-webgl@0.20.0-beta.197(@xterm/xterm@6.1.0-beta.198)': + dependencies: + '@xterm/xterm': 6.1.0-beta.198 - '@xterm/headless@6.0.0': {} + '@xterm/headless@6.1.0-beta.198': {} - '@xterm/xterm@6.0.0': {} + '@xterm/xterm@6.1.0-beta.198': {} abbrev@3.0.1: {} diff --git a/src/renderer/src/lib/pane-manager/pane-divider.ts b/src/renderer/src/lib/pane-manager/pane-divider.ts index 46fbd9e6a..384d41c53 100644 --- a/src/renderer/src/lib/pane-manager/pane-divider.ts +++ b/src/renderer/src/lib/pane-manager/pane-divider.ts @@ -94,11 +94,6 @@ function attachDividerDrag( callbacks.lockDragScroll(nextEl) } - // Why: fitAddon.fit() triggers a full xterm.js reflow which can take - // hundreds of ms with large scrollbacks. Gating behind rAF caps refit - // to once per paint frame instead of once per pointer event (~250Hz). - let refitRafId: number | null = null - const onPointerMove = (e: PointerEvent): void => { if (!dragging || !prevEl || !nextEl) { return @@ -121,20 +116,11 @@ function attachDividerDrag( newPrev = totalSize - MIN_PANE_SIZE } + // Why: keep drag-time work to flex layout only; pane-local ResizeObservers + // schedule the terminal fit on the next frame so the divider stays smooth. // Use flex-grow proportionally prevEl.style.flex = `${newPrev} 1 0%` nextEl.style.flex = `${newNext} 1 0%` - - // Refit terminals in affected panes (throttled to one per animation frame) - if (refitRafId === null) { - const p = prevEl - const n = nextEl - refitRafId = requestAnimationFrame(() => { - refitRafId = null - callbacks.refitPanesUnder(p) - callbacks.refitPanesUnder(n) - }) - } } const onPointerUp = (e: PointerEvent): void => { @@ -142,10 +128,6 @@ function attachDividerDrag( return } dragging = false - if (refitRafId !== null) { - cancelAnimationFrame(refitRafId) - refitRafId = null - } divider.releasePointerCapture(e.pointerId) divider.classList.remove('is-dragging') // Final refit at the exact drop position, then unlock drag scroll state diff --git a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts new file mode 100644 index 000000000..600ad7419 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ManagedPaneInternal, ScrollState } from './pane-manager-types' +import { + attachPaneFitResizeObserver, + detachPaneFitResizeObserver +} from './pane-fit-resize-observer' + +type ResizeObserverCallbackLike = ConstructorParameters[0] + +class MockResizeObserver { + observe = vi.fn() + disconnect = vi.fn() + + constructor(private readonly callback: ResizeObserverCallbackLike) { + mockResizeObservers.push(this) + } + + trigger(): void { + this.callback([], this as never) + } +} + +let mockResizeObservers: MockResizeObserver[] = [] +let nextRafId = 1 +let pendingRafs = new Map() + +function flushAnimationFrames(timestamp = 16): void { + const callbacks = Array.from(pendingRafs.entries()) + pendingRafs = new Map() + for (const [, callback] of callbacks) { + callback(timestamp) + } +} + +function createPane(): ManagedPaneInternal { + return { + id: 1, + terminal: { + cols: 79, + rows: 24 + } as never, + container: {} as never, + xtermContainer: {} as never, + linkTooltip: {} as never, + gpuRenderingEnabled: true, + fitAddon: { + fit: vi.fn(), + proposeDimensions: vi.fn(() => ({ cols: 80, rows: 24 })) + } as never, + fitResizeObserver: null, + pendingObservedFitRafId: null, + searchAddon: {} as never, + serializeAddon: {} as never, + unicode11Addon: {} as never, + webLinksAddon: {} as never, + webglAddon: null, + compositionHandler: null, + pendingSplitScrollState: { + wasAtBottom: true, + firstVisibleLineContent: '', + viewportY: 0, + totalLines: 24 + } satisfies ScrollState, + pendingDragScrollState: null + } +} + +describe('attachPaneFitResizeObserver', () => { + beforeEach(() => { + mockResizeObservers = [] + nextRafId = 1 + pendingRafs = new Map() + + vi.stubGlobal('ResizeObserver', MockResizeObserver as never) + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + const id = nextRafId++ + pendingRafs.set(id, callback) + return id + }) + ) + vi.stubGlobal( + 'cancelAnimationFrame', + vi.fn((id: number) => { + pendingRafs.delete(id) + }) + ) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('coalesces repeated observer callbacks into a single fit per frame', () => { + const pane = createPane() + + attachPaneFitResizeObserver(pane) + mockResizeObservers[0]?.trigger() + mockResizeObservers[0]?.trigger() + + expect(requestAnimationFrame).toHaveBeenCalledTimes(1) + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + + flushAnimationFrames() + + expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) + }) + + it('disconnects the observer and cancels any queued fit', () => { + const pane = createPane() + + attachPaneFitResizeObserver(pane) + mockResizeObservers[0]?.trigger() + const scheduledRafId = pane.pendingObservedFitRafId + + detachPaneFitResizeObserver(pane) + flushAnimationFrames() + + expect(mockResizeObservers[0]?.disconnect).toHaveBeenCalledTimes(1) + expect(cancelAnimationFrame).toHaveBeenCalledWith(scheduledRafId) + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + expect(pane.pendingObservedFitRafId).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.ts b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.ts new file mode 100644 index 000000000..da4a9b65e --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.ts @@ -0,0 +1,35 @@ +import type { ManagedPaneInternal } from './pane-manager-types' +import { safeFit } from './pane-tree-ops' + +export function attachPaneFitResizeObserver(pane: ManagedPaneInternal): void { + detachPaneFitResizeObserver(pane) + + if (typeof ResizeObserver === 'undefined') { + return + } + + const observer = new ResizeObserver(() => { + if (pane.pendingObservedFitRafId !== null) { + return + } + // Why: keep xterm fit work off the divider pointermove hot path and let + // the browser coalesce drag-driven size changes the same way Superset does. + pane.pendingObservedFitRafId = requestAnimationFrame(() => { + pane.pendingObservedFitRafId = null + safeFit(pane) + }) + }) + + observer.observe(pane.xtermContainer) + pane.fitResizeObserver = observer +} + +export function detachPaneFitResizeObserver(pane: ManagedPaneInternal): void { + pane.fitResizeObserver?.disconnect() + pane.fitResizeObserver = null + + if (pane.pendingObservedFitRafId !== null) { + cancelAnimationFrame(pane.pendingObservedFitRafId) + pane.pendingObservedFitRafId = null + } +} diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index 335fd4ad9..7d53bd9b7 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -12,6 +12,10 @@ import type { DragReorderState } from './pane-drag-reorder' import type { DragReorderCallbacks } from './pane-drag-reorder' import { attachPaneDrag } from './pane-drag-reorder' import { safeFit, captureScrollState, restoreScrollState } from './pane-tree-ops' +import { + attachPaneFitResizeObserver, + detachPaneFitResizeObserver +} from './pane-fit-resize-observer' // --------------------------------------------------------------------------- // Pane creation, terminal open/close, addon management @@ -128,6 +132,8 @@ export function createPaneDOM( linkTooltip, gpuRenderingEnabled: ENABLE_WEBGL_RENDERER, fitAddon, + fitResizeObserver: null, + pendingObservedFitRafId: null, searchAddon, serializeAddon, unicode11Addon, @@ -223,6 +229,8 @@ export function openTerminal(pane: ManagedPaneInternal): void { attachWebgl(pane) } + attachPaneFitResizeObserver(pane) + // Initial fit (deferred to ensure layout has settled) requestAnimationFrame(() => { safeFit(pane) @@ -301,6 +309,7 @@ export function disposePane( pane: ManagedPaneInternal, panes: Map ): void { + detachPaneFitResizeObserver(pane) if (pane.compositionHandler) { pane.terminal.element?.removeEventListener('compositionstart', pane.compositionHandler, true) pane.compositionHandler = null diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index 1d5d01473..e1d871bf9 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -60,6 +60,8 @@ export type ManagedPaneInternal = { linkTooltip: HTMLElement gpuRenderingEnabled: boolean webglAddon: WebglAddon | null + fitResizeObserver: ResizeObserver | null + pendingObservedFitRafId: number | null serializeAddon: SerializeAddon unicode11Addon: Unicode11Addon webLinksAddon: WebLinksAddon diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts new file mode 100644 index 000000000..d29e74310 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest' +import { safeFit } from './pane-tree-ops' +import type { ManagedPaneInternal, ScrollState } from './pane-manager-types' + +function createPane({ + proposedCols, + proposedRows, + terminalCols, + terminalRows +}: { + proposedCols: number + proposedRows: number + terminalCols: number + terminalRows: number +}): ManagedPaneInternal { + const fit = vi.fn() + const proposeDimensions = vi.fn(() => ({ cols: proposedCols, rows: proposedRows })) + const terminal = { + cols: terminalCols, + rows: terminalRows, + buffer: { + active: { + viewportY: 0, + baseY: 0, + getLine: vi.fn(() => ({ translateToString: () => '' })) + } + }, + scrollToBottom: vi.fn(), + scrollToLine: vi.fn(), + scrollLines: vi.fn() + } + + return { + id: 1, + terminal: terminal as never, + container: {} as never, + xtermContainer: {} as never, + linkTooltip: {} as never, + gpuRenderingEnabled: true, + fitAddon: { + fit, + proposeDimensions + } as never, + fitResizeObserver: null, + pendingObservedFitRafId: null, + searchAddon: {} as never, + serializeAddon: {} as never, + unicode11Addon: {} as never, + webLinksAddon: {} as never, + webglAddon: null, + compositionHandler: null, + pendingSplitScrollState: null, + pendingDragScrollState: null + } +} + +describe('safeFit', () => { + it('skips drag-frame refits when the pane grid dimensions did not change', () => { + const pane = createPane({ + proposedCols: 120, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + + safeFit(pane) + + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + }) + + it('still refits when the proposed grid dimensions changed', () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + + safeFit(pane) + + expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) + }) + + it('still refits when a split-scroll lock is active and the grid changed', () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + pane.pendingSplitScrollState = { + wasAtBottom: true, + firstVisibleLineContent: '', + viewportY: 0, + totalLines: 32 + } satisfies ScrollState + + safeFit(pane) + + expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts index 3d860da6f..1422d388c 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts @@ -18,8 +18,23 @@ type TreeOpsCallbacks = { onLayoutChanged?: () => void } +function getProposedDimensions(pane: ManagedPaneInternal): { cols: number; rows: number } | null { + try { + return pane.fitAddon.proposeDimensions() ?? null + } catch { + return null + } +} + export function safeFit(pane: ManagedPaneInternal): void { try { + const dims = getProposedDimensions(pane) + if (dims && dims.cols === pane.terminal.cols && dims.rows === pane.terminal.rows) { + // Why: divider drags fire refits every frame, but most frames do not + // cross a cell boundary. Skipping those avoids FitAddon.clear()+refresh() + // churn, which was causing visible terminal blinking while resizing. + return + } if (pane.pendingSplitScrollState) { pane.fitAddon.fit() return @@ -40,7 +55,7 @@ export function safeFit(pane: ManagedPaneInternal): void { export function fitAllPanesInternal(panes: Map): void { for (const pane of panes.values()) { try { - const dims = pane.fitAddon.proposeDimensions() + const dims = getProposedDimensions(pane) if (dims && dims.cols === pane.terminal.cols && dims.rows === pane.terminal.rows) { continue }