diff --git a/package.json b/package.json index c50cd0913..f5ee86b8f 100644 --- a/package.json +++ b/package.json @@ -125,6 +125,9 @@ "electron", "esbuild", "node-pty" - ] + ], + "patchedDependencies": { + "@xterm/addon-fit@0.11.0": "patches/@xterm__addon-fit@0.11.0.patch" + } } } diff --git a/patches/@xterm__addon-fit@0.11.0.patch b/patches/@xterm__addon-fit@0.11.0.patch new file mode 100644 index 000000000..0afc94ee7 --- /dev/null +++ b/patches/@xterm__addon-fit@0.11.0.patch @@ -0,0 +1,65 @@ +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),this._terminal.refresh(0,this._terminal.rows-1),w&&this._terminal.scrollToBottom())}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),this._terminal.refresh(0,this._terminal.rows-1),w&&this._terminal.scrollToBottom())}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); ++ ++ // xterm.js v6 rewraps the entire scrollback on column-count changes. ++ // The WebGL renderer's dirty-row tracking may not repaint the full ++ // viewport afterward, leaving blank space at the top or stale content. ++ // Force a full refresh so every visible row is redrawn. ++ this._terminal.refresh(0, this._terminal.rows - 1); ++ ++ // 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(); ++ } + } + } + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28e8f407e..7494612f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + '@xterm/addon-fit@0.11.0': + hash: a7e8a871306c0c675c0cdaea1673d91c886d21fb57f7e2d873a825b38182dfea + path: patches/@xterm__addon-fit@0.11.0.patch + importers: .: @@ -73,7 +78,7 @@ importers: version: 3.22.1 '@xterm/addon-fit': specifier: ^0.11.0 - version: 0.11.0 + version: 0.11.0(patch_hash=a7e8a871306c0c675c0cdaea1673d91c886d21fb57f7e2d873a825b38182dfea) '@xterm/addon-search': specifier: ^0.16.0 version: 0.16.0 @@ -2456,6 +2461,41 @@ packages: '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260406.1': + resolution: {integrity: sha512-q6AMrDHlD6dQHpRuGOewhvKTCBDWRgJ42678+muvHbdHkBafRSUSBRiYasUp8cInK22jlkVwNLqQn6j7Sl9zVg==} + cpu: [arm64] + os: [darwin] + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260406.1': + resolution: {integrity: sha512-3v8LplbhJwY4GlBawgJt4ydn0sYeowGMniGiPMBl38ioYAIJOriuBvAk+S/gbOoIfLjMLq0L65fnQN+w9+i5ag==} + cpu: [x64] + os: [darwin] + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260406.1': + resolution: {integrity: sha512-hRY2czBKisYtcbwgl8HZBA7u/KKUumNlL0X2OpCK1BtQzKbHXAXi1HxUCPbwgHu4v6uGibvniny0BPA6MVrHDA==} + cpu: [arm64] + os: [linux] + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260406.1': + resolution: {integrity: sha512-KLa4bK2BxnQwc9uefI8rtaso3cNiRI5Y19z9Jx7UzFJS4YaxtFp5cVjfy4FlQ55ixtUExmCJH7GhSzuVeFl/Jw==} + cpu: [arm] + os: [linux] + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260406.1': + resolution: {integrity: sha512-Cbcy+lSctRHNmqvLl+l8RgomL0qX3wxEPKCOIdQ/ooicsIPFbkK57Cwdhw3RSpJ+Xb2LzLdneA2Q1Vg+f/Nwxg==} + cpu: [x64] + os: [linux] + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260406.1': + resolution: {integrity: sha512-IFIkbYABge7f83A16awJJWUCrDG1Lw4//NCdU927i/CjgxG09q4ZJeVXcIbZQ5lJWwraiSTa6AMqvhF/tnR2KQ==} + cpu: [arm64] + os: [win32] + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260406.1': + resolution: {integrity: sha512-Jk7wP6SPaELTGY+ijvpude+dmXX9WeuLQLk9qjevQXTusFVcT8vjvepxJVshSJ1+Thmxy1t9v4l1pHnM3XUMjw==} + cpu: [x64] + os: [win32] + '@typescript/native-preview@7.0.0-dev.20260406.1': resolution: {integrity: sha512-hlVajr01Y/ZmI9iZ7A6BgPxqXccGqxuc/PmVNdanr/LZdtsH9q11y2H3NFMaOrbPlDoeWxHvGUT3wsZllCphyg==} hasBin: true @@ -7512,7 +7552,36 @@ snapshots: '@types/node': 25.5.0 optional: true - '@typescript/native-preview@7.0.0-dev.20260406.1': {} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260406.1': + optional: true + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260406.1': + optional: true + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260406.1': + optional: true + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260406.1': + optional: true + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260406.1': + optional: true + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260406.1': + optional: true + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260406.1': + optional: true + + '@typescript/native-preview@7.0.0-dev.20260406.1': + optionalDependencies: + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260406.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260406.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260406.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260406.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260406.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260406.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260406.1 '@ungap/structured-clone@1.3.0': {} @@ -7572,7 +7641,7 @@ snapshots: '@xmldom/xmldom@0.8.11': {} - '@xterm/addon-fit@0.11.0': {} + '@xterm/addon-fit@0.11.0(patch_hash=a7e8a871306c0c675c0cdaea1673d91c886d21fb57f7e2d873a825b38182dfea)': {} '@xterm/addon-search@0.16.0': {} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts index 55929b4d1..1501743e8 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts @@ -101,15 +101,37 @@ export function useTerminalPaneGlobalEffects({ if (!container) { return } + // Why: ResizeObserver fires on every incremental size change during + // continuous window resizes or layout animations. Each fitPanes() call + // triggers fitAddon.fit() → terminal.resize() which, when the column + // count changes, reflows the entire scrollback buffer and recalculates + // the viewport scroll position. Rapid-fire reflows can leave the + // viewport at a stale scroll offset, causing the terminal to appear + // scrolled to the top or to show blank space where scrollback should be. + // Batching through requestAnimationFrame coalesces bursts into a single + // reflow per paint frame — the same pattern used by queueResizeAll in + // use-terminal-pane-lifecycle.ts. + let rafId: number | null = null const resizeObserver = new ResizeObserver(() => { - const manager = managerRef.current - if (!manager) { + if (rafId !== null) { return } - fitPanes(manager) + rafId = requestAnimationFrame(() => { + rafId = null + const manager = managerRef.current + if (!manager) { + return + } + fitPanes(manager) + }) }) resizeObserver.observe(container) - return () => resizeObserver.disconnect() + return () => { + resizeObserver.disconnect() + if (rafId !== null) { + cancelAnimationFrame(rafId) + } + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isActive, isVisible]) diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index 3c19afd20..86fad6d6b 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -179,6 +179,23 @@ export function attachWebgl(pane: ManagedPaneInternal): void { ) webglAddon.dispose() pane.webglAddon = null + // Why: when the WebGL context is lost (GPU memory pressure, Chromium + // context limit, driver hiccup), the GPU-rendered canvas goes blank + // instantly — this is standard browser behaviour. After disposing the + // addon, xterm.js falls back to the DOM renderer, but it may not + // redraw the viewport unprompted. Without an explicit + // refresh + refit, the scrollback area appears as blank space at the + // top of the terminal while only the most recent output is visible at + // the bottom. Deferring to the next frame gives the DOM renderer time + // to initialise before we ask it to repaint. + requestAnimationFrame(() => { + try { + pane.fitAddon.fit() + pane.terminal.refresh(0, pane.terminal.rows - 1) + } catch { + /* ignore — pane may have been disposed in the meantime */ + } + }) }) pane.terminal.loadAddon(webglAddon) pane.webglAddon = webglAddon