diff --git a/src/renderer/src/components/terminal-pane/expand-collapse.ts b/src/renderer/src/components/terminal-pane/expand-collapse.ts index 28b20ce8..4b7969e4 100644 --- a/src/renderer/src/components/terminal-pane/expand-collapse.ts +++ b/src/renderer/src/components/terminal-pane/expand-collapse.ts @@ -1,4 +1,5 @@ import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import { captureScrollState, restoreScrollState } from '@/lib/pane-manager/pane-tree-ops' type ExpandCollapseState = { expandedPaneIdRef: React.MutableRefObject @@ -101,12 +102,9 @@ export function createExpandCollapseActions(state: ExpandCollapseState) { const panes = manager.getPanes() for (const p of panes) { try { - const buf = p.terminal.buffer.active - const wasAtBottom = buf.viewportY >= buf.baseY + const state = captureScrollState(p.terminal) p.fitAddon.fit() - if (wasAtBottom) { - p.terminal.scrollToBottom() - } + restoreScrollState(p.terminal, state) } catch { /* container may not have dimensions */ } diff --git a/src/renderer/src/components/terminal-pane/pane-helpers.ts b/src/renderer/src/components/terminal-pane/pane-helpers.ts index ba91594e..dc38171e 100644 --- a/src/renderer/src/components/terminal-pane/pane-helpers.ts +++ b/src/renderer/src/components/terminal-pane/pane-helpers.ts @@ -1,28 +1,7 @@ import type { PaneManager } from '@/lib/pane-manager/pane-manager' export function fitPanes(manager: PaneManager): void { - for (const pane of manager.getPanes()) { - try { - // Why: fitAddon.fit() calls _renderService.clear() + terminal.refresh() - // even when dimensions haven't changed (the patched FitAddon only skips - // terminal.resize()). On Windows the clear+refresh overhead is non-trivial - // with 10 000 scrollback lines. Skip entirely when the proposed dimensions - // match the current ones — this is the common case when a terminal simply - // transitions from hidden → visible at the same container size. - const dims = pane.fitAddon.proposeDimensions() - if (dims && dims.cols === pane.terminal.cols && dims.rows === pane.terminal.rows) { - continue - } - const buf = pane.terminal.buffer.active - const wasAtBottom = buf.viewportY >= buf.baseY - pane.fitAddon.fit() - if (wasAtBottom) { - pane.terminal.scrollToBottom() - } - } catch { - /* ignore */ - } - } + manager.fitAllPanes() } /** diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index 4fc28275..b1cfc95f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -8,6 +8,7 @@ import { resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' import { buildFontFamily } from './layout-serialization' +import { captureScrollState, restoreScrollState } from '@/lib/pane-manager/pane-tree-ops' import type { PtyTransport } from './pty-transport' export function applyTerminalAppearance( @@ -36,14 +37,9 @@ export function applyTerminalAppearance( pane.terminal.options.fontWeightBold = terminalFontWeights.fontWeightBold pane.terminal.options.macOptionIsMeta = settings.terminalMacOptionAsAlt === 'true' try { - // Why: preserve scroll-to-bottom state across the reflow so appearance - // changes (theme, font size, etc.) don't make the terminal scroll up. - const buf = pane.terminal.buffer.active - const wasAtBottom = buf.viewportY >= buf.baseY + const state = captureScrollState(pane.terminal) pane.fitAddon.fit() - if (wasAtBottom) { - pane.terminal.scrollToBottom() - } + restoreScrollState(pane.terminal, state) } catch { /* ignore */ } diff --git a/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts b/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts index a23fab37..e1c353ab 100644 --- a/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts +++ b/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts @@ -1,6 +1,7 @@ import { useEffect } from 'react' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { dispatchZoomLevelChanged } from '@/lib/zoom-events' +import { captureScrollState, restoreScrollState } from '@/lib/pane-manager/pane-tree-ops' type FontZoomDeps = { isActive: boolean @@ -50,12 +51,9 @@ export function useTerminalFontZoom({ pane.terminal.options.fontSize = nextSize try { - const buf = pane.terminal.buffer.active - const wasAtBottom = buf.viewportY >= buf.baseY + const state = captureScrollState(pane.terminal) pane.fitAddon.fit() - if (wasAtBottom) { - pane.terminal.scrollToBottom() - } + restoreScrollState(pane.terminal, state) } catch { /* ignore */ } diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index f6fbd982..aba2271d 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -11,7 +11,7 @@ import type { PaneManagerOptions, ManagedPaneInternal } from './pane-manager-typ import type { DragReorderState } from './pane-drag-reorder' import type { DragReorderCallbacks } from './pane-drag-reorder' import { attachPaneDrag } from './pane-drag-reorder' -import { safeFit } from './pane-tree-ops' +import { safeFit, captureScrollState, restoreScrollState } from './pane-tree-ops' // --------------------------------------------------------------------------- // Pane creation, terminal open/close, addon management @@ -133,7 +133,8 @@ export function createPaneDOM( unicode11Addon, webLinksAddon, webglAddon: null, - compositionHandler: null + compositionHandler: null, + pendingSplitScrollState: null } // Focus handler: clicking a pane makes it active and explicitly focuses @@ -227,6 +228,17 @@ export function openTerminal(pane: ManagedPaneInternal): void { }) } +export function disposeWebgl(pane: ManagedPaneInternal): void { + if (pane.webglAddon) { + try { + pane.webglAddon.dispose() + } catch { + /* ignore */ + } + pane.webglAddon = null + } +} + export function attachWebgl(pane: ManagedPaneInternal): void { if (!ENABLE_WEBGL_RENDERER || !pane.gpuRenderingEnabled) { pane.webglAddon = null @@ -251,13 +263,20 @@ export function attachWebgl(pane: ManagedPaneInternal): void { // 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. + // + // Why content-match instead of wasAtBottom: context loss often fires + // during splitPane when a new WebGL canvas is created and Chromium + // reclaims the old one. The fit() here triggers a reflow that changes + // line numbering; the simple wasAtBottom check can't track partially- + // scrolled positions and would undo scroll restoration from splitPane. requestAnimationFrame(() => { try { - const buf = pane.terminal.buffer.active - const wasAtBottom = buf.viewportY >= buf.baseY - pane.fitAddon.fit() - if (wasAtBottom) { - pane.terminal.scrollToBottom() + if (pane.pendingSplitScrollState) { + pane.fitAddon.fit() + } else { + const scrollState = captureScrollState(pane.terminal) + pane.fitAddon.fit() + restoreScrollState(pane.terminal, scrollState) } pane.terminal.refresh(0, pane.terminal.rows - 1) } catch { 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 b9f33eb0..1ea671b1 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -48,6 +48,13 @@ export type ManagedPane = { // Internal types // --------------------------------------------------------------------------- +export type ScrollState = { + wasAtBottom: boolean + firstVisibleLineContent: string + viewportY: number + totalLines: number +} + export type ManagedPaneInternal = { xtermContainer: HTMLElement linkTooltip: HTMLElement @@ -58,6 +65,12 @@ export type ManagedPaneInternal = { webLinksAddon: WebLinksAddon // Stored so disposePane() can remove it and avoid a memory leak. compositionHandler: (() => void) | null + // Why: during splitPane, multiple async operations (rAFs, ResizeObserver + // debounce, WebGL context loss) may independently attempt scroll + // restoration. This field acts as a lock: when set, safeFit and other + // intermediate fit paths skip their own scroll restoration, deferring to + // the splitPane's final authoritative restore. + pendingSplitScrollState: ScrollState | null } & ManagedPane export type DropZone = 'top' | 'bottom' | 'left' | 'right' diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index 8335e61d..0765551a 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -17,7 +17,13 @@ import { handlePaneDrop, updateMultiPaneState } from './pane-drag-reorder' -import { createPaneDOM, openTerminal, attachWebgl, disposePane } from './pane-lifecycle' +import { + createPaneDOM, + openTerminal, + attachWebgl, + disposeWebgl, + disposePane +} from './pane-lifecycle' import { shouldFollowMouseFocus } from './focus-follows-mouse' import { findPaneChildren, @@ -25,8 +31,11 @@ import { promoteSibling, wrapInSplit, safeFit, + fitAllPanesInternal, + captureScrollState, refitPanesUnder } from './pane-tree-ops' +import { scheduleSplitScrollRestore } from './pane-split-scroll' export type { PaneManagerOptions, PaneStyleOptions, ManagedPane, DropZone } @@ -86,9 +95,7 @@ export class PaneManager { if (!existing) { return null } - const newPane = this.createPaneInternal() - const parent = existing.container.parentElement if (!parent) { return null @@ -97,66 +104,37 @@ export class PaneManager { const isVertical = direction === 'vertical' const divider = this.createDividerWrapped(isVertical) - // Why: wrapInSplit reparents the existing container via replaceChild + - // appendChild, which can cause the browser to reset scrollTop on xterm's - // viewport element to 0 during the next layout. Capture the scroll-at- - // bottom state now, before the DOM reparenting corrupts it. - const buf = existing.terminal.buffer.active - const wasAtBottom = buf.viewportY >= buf.baseY + // Why: wrapInSplit reparents the existing container, which causes the + // browser to asynchronously reset scrollTop to 0 during layout. Capture + // the scroll state before reparenting so we can restore it after all + // layout and reflow have settled. + const scrollState = captureScrollState(existing.terminal) + + // Why: multiple async operations fire after the split (rAFs from + // queueResizeAll, WebGL context loss, ResizeObserver 150ms debounce). + // Each would independently try to restore scroll, potentially to wrong + // positions due to intermediate buffer states. The lock makes safeFit + // and fitAllPanesInternal skip their own scroll restoration, leaving + // the authoritative restore to the timeout below. + existing.pendingSplitScrollState = scrollState wrapInSplit(existing.container, newPane.container, isVertical, divider, opts) - // Why: immediately restore the scroll position after DOM reparenting so - // that xterm's internal viewportY stays correct when the browser fires - // asynchronous scroll events during its layout phase. - if (wasAtBottom) { - existing.terminal.scrollToBottom() - } - - // Open terminal for new pane openTerminal(newPane) - - // Set new pane active this.activePaneId = newPane.id applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions) this.applyDividerStylesWrapped() - - if (newPane.terminal) { - newPane.terminal.focus() - } - + newPane.terminal?.focus() updateMultiPaneState(this.getDragCallbacks()) - void this.options.onPaneCreated?.(this.toPublic(newPane)) this.options.onLayoutChanged?.() - // Why: belt-and-suspenders for the scroll position — the deferred - // fitPanes (from onLayoutChanged → queueResizeAll) reflows the buffer - // for the new column count, which changes baseY. If the browser's - // rendering pipeline fired a scroll event that reset viewportY between - // our synchronous scrollToBottom above and the rAF, safeFit's - // wasAtBottom check would read false and skip scrollToBottom. This - // final rAF runs after fitPanes (FIFO ordering) and unconditionally - // restores the scroll-to-bottom state. - if (wasAtBottom) { - const existingPaneId = existing.id - requestAnimationFrame(() => { - // Why: replayTerminalLayout can create a PaneManager, split panes, - // then tear the whole manager down (e.g. when the owning worktree - // deactivates) before this rAF fires. Touching xterm's renderer - // after disposePane throws "Cannot read properties of undefined - // (reading 'dimensions')". Resolve the pane from the live map so - // we no-op instead of crashing. - if (this.destroyed) { - return - } - const live = this.panes.get(existingPaneId) - if (!live) { - return - } - live.terminal.scrollToBottom() - }) - } + scheduleSplitScrollRestore( + (id) => this.panes.get(id), + existing.id, + scrollState, + () => this.destroyed + ) return this.toPublic(newPane) } @@ -166,29 +144,21 @@ export class PaneManager { if (!pane) { return } - const paneContainer = pane.container const parent = paneContainer.parentElement if (!parent) { return } - - // Dispose terminal and addons disposePane(pane, this.panes) - if (parent.classList.contains('pane-split')) { const siblings = findPaneChildren(parent) const sibling = siblings.find((c) => c !== paneContainer) ?? null - paneContainer.remove() removeDividers(parent) promoteSibling(sibling, parent, this.root) } else { - // Direct child of root (only pane) — just remove paneContainer.remove() } - - // Activate next pane if needed if (this.activePaneId === paneId) { const remaining = Array.from(this.panes.values()) if (remaining.length > 0) { @@ -198,14 +168,10 @@ export class PaneManager { this.activePaneId = null } } - applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions) - - // Refit remaining panes for (const p of this.panes.values()) { safeFit(p) } - updateMultiPaneState(this.getDragCallbacks()) this.options.onPaneClosed?.(paneId) this.options.onLayoutChanged?.() @@ -215,6 +181,10 @@ export class PaneManager { return Array.from(this.panes.values()).map((p) => this.toPublic(p)) } + fitAllPanes(): void { + fitAllPanesInternal(this.panes) + } + getActivePane(): ManagedPane | null { if (this.activePaneId === null) { return null @@ -228,7 +198,6 @@ export class PaneManager { if (!pane) { return } - const changed = this.activePaneId !== paneId this.activePaneId = paneId applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions) @@ -254,49 +223,23 @@ export class PaneManager { if (!pane) { return } - pane.gpuRenderingEnabled = enabled - if (!enabled) { - if (pane.webglAddon) { - try { - pane.webglAddon.dispose() - } catch { - /* ignore */ - } - pane.webglAddon = null - } + disposeWebgl(pane) return } - if (!pane.webglAddon) { attachWebgl(pane) safeFit(pane) } } - /** - * Suspend GPU rendering for all panes. Disposes WebGL addons to free - * GPU contexts while keeping Terminal instances alive (scrollback, cursor, - * screen buffer all preserved). Call when this tab/worktree becomes hidden. - */ suspendRendering(): void { for (const pane of this.panes.values()) { - if (pane.webglAddon) { - try { - pane.webglAddon.dispose() - } catch { - /* ignore */ - } - pane.webglAddon = null - } + disposeWebgl(pane) } } - /** - * Resume GPU rendering for all panes. Recreates WebGL addons. Call when - * this tab/worktree becomes visible again. Must be followed by a fit() pass. - */ resumeRendering(): void { for (const pane of this.panes.values()) { if (pane.gpuRenderingEnabled && !pane.webglAddon) { diff --git a/src/renderer/src/lib/pane-manager/pane-split-scroll.ts b/src/renderer/src/lib/pane-manager/pane-split-scroll.ts new file mode 100644 index 00000000..e405fb5c --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-split-scroll.ts @@ -0,0 +1,37 @@ +import type { ManagedPaneInternal, ScrollState } from './pane-manager-types' +import { restoreScrollState } from './pane-tree-ops' + +// Why: reparenting a terminal container during split resets the viewport +// scroll position (browser clears scrollTop on DOM move). This schedules a +// two-phase restore: an early double-rAF (~32ms) to minimise the visible +// flash, plus a 200ms authoritative restore that also clears the scroll lock. +export function scheduleSplitScrollRestore( + getPaneById: (id: number) => ManagedPaneInternal | undefined, + paneId: number, + scrollState: ScrollState, + isDestroyed: () => boolean +): void { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (isDestroyed()) { + return + } + const live = getPaneById(paneId) + if (live?.pendingSplitScrollState) { + restoreScrollState(live.terminal, scrollState) + } + }) + }) + + setTimeout(() => { + if (isDestroyed()) { + return + } + const live = getPaneById(paneId) + if (!live) { + return + } + live.pendingSplitScrollState = null + restoreScrollState(live.terminal, scrollState) + }, 200) +} 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 1690886e..d7b673e7 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts @@ -1,6 +1,101 @@ -import type { DropZone, ManagedPaneInternal, PaneStyleOptions } from './pane-manager-types' +import type { Terminal } from '@xterm/xterm' +import type { + DropZone, + ManagedPaneInternal, + PaneStyleOptions, + ScrollState +} from './pane-manager-types' import { createDivider } from './pane-divider' +// --------------------------------------------------------------------------- +// Scroll restoration after reflow +// --------------------------------------------------------------------------- + +// Why: xterm.js does NOT adjust viewportY for partially-scrolled buffers +// during resize/reflow. Line N before reflow shows different content than +// line N after reflow when wrapping changes (e.g. 80→40 cols makes each +// line wrap to 2 rows). To preserve the user's scroll position, we find +// the buffer line whose content matches what was at the top of the viewport +// before the reflow, then scroll to it. +// +// Why hintRatio: terminals frequently contain duplicate short lines (shell +// prompts, repeated log prefixes). A prefix-only search returns the first +// match which may be far from the actual scroll position. The proportional +// hint (viewportY / totalLines before reflow) disambiguates by preferring +// the match closest to the expected position in the reflowed buffer. +export function findLineByContent(terminal: Terminal, content: string, hintRatio?: number): number { + if (!content) { + return -1 + } + const buf = terminal.buffer.active + const totalLines = buf.baseY + terminal.rows + const prefix = content.substring(0, Math.min(content.length, 40)) + if (!prefix) { + return -1 + } + + const hintLine = hintRatio !== undefined ? Math.round(hintRatio * totalLines) : -1 + + let bestMatch = -1 + let bestDistance = Infinity + + for (let i = 0; i < totalLines; i++) { + const line = buf.getLine(i)?.translateToString(true)?.trimEnd() ?? '' + if (line.startsWith(prefix)) { + if (hintLine < 0) { + return i + } + const distance = Math.abs(i - hintLine) + if (distance < bestDistance) { + bestDistance = distance + bestMatch = i + } + } + } + return bestMatch +} + +export function captureScrollState(terminal: Terminal): ScrollState { + const buf = terminal.buffer.active + const viewportY = buf.viewportY + const wasAtBottom = viewportY >= buf.baseY + const firstVisibleLineContent = buf.getLine(viewportY)?.translateToString(true)?.trimEnd() ?? '' + const totalLines = buf.baseY + terminal.rows + return { wasAtBottom, firstVisibleLineContent, viewportY, totalLines } +} + +export function restoreScrollState(terminal: Terminal, state: ScrollState): void { + if (state.wasAtBottom) { + terminal.scrollToBottom() + forceViewportScrollbarSync(terminal) + return + } + const hintRatio = state.totalLines > 0 ? state.viewportY / state.totalLines : undefined + const target = findLineByContent(terminal, state.firstVisibleLineContent, hintRatio) + if (target >= 0) { + terminal.scrollToLine(target) + forceViewportScrollbarSync(terminal) + } +} + +// Why: xterm 6's Viewport._sync() updates scrollDimensions after resize but +// skips the scrollPosition update when ydisp matches _latestYDisp (a stale +// internal value). This leaves the scrollbar thumb at a wrong position even +// though the rendered content is correct. A scroll jiggle (-1/+1) in the +// same JS turn forces _sync() to fire with a differing ydisp, which triggers +// setScrollPosition and syncs the scrollbar. No paint occurs between the two +// synchronous calls so the intermediate state is never visible. +function forceViewportScrollbarSync(terminal: Terminal): void { + const buf = terminal.buffer.active + if (buf.viewportY > 0) { + terminal.scrollLines(-1) + terminal.scrollLines(1) + } else if (buf.viewportY < buf.baseY) { + terminal.scrollLines(1) + terminal.scrollLines(-1) + } +} + // --------------------------------------------------------------------------- // Split-tree manipulation: detach, insert, promote sibling // --------------------------------------------------------------------------- @@ -15,20 +110,38 @@ type TreeOpsCallbacks = { export function safeFit(pane: ManagedPaneInternal): void { try { - // Why: fitAddon.fit() triggers a terminal reflow that can leave the viewport - // at a stale scroll offset, making the terminal appear scrolled up after a - // resize. Preserve the scroll-to-bottom state across the reflow. - const buf = pane.terminal.buffer.active - const wasAtBottom = buf.viewportY >= buf.baseY - pane.fitAddon.fit() - if (wasAtBottom) { - pane.terminal.scrollToBottom() + if (pane.pendingSplitScrollState) { + pane.fitAddon.fit() + return } + const state = captureScrollState(pane.terminal) + pane.fitAddon.fit() + restoreScrollState(pane.terminal, state) } catch { // Container may not have dimensions yet } } +export function fitAllPanesInternal(panes: Map): void { + for (const pane of panes.values()) { + try { + const dims = pane.fitAddon.proposeDimensions() + if (dims && dims.cols === pane.terminal.cols && dims.rows === pane.terminal.rows) { + continue + } + if (pane.pendingSplitScrollState) { + pane.fitAddon.fit() + continue + } + const state = captureScrollState(pane.terminal) + pane.fitAddon.fit() + restoreScrollState(pane.terminal, state) + } catch { + /* ignore */ + } + } +} + export function refitPanesUnder(el: HTMLElement, panes: Map): void { // If the element is a pane, refit it if (el.classList.contains('pane')) { diff --git a/src/renderer/src/lib/pane-manager/scroll-reflow.test.ts b/src/renderer/src/lib/pane-manager/scroll-reflow.test.ts new file mode 100644 index 00000000..7ee5021f --- /dev/null +++ b/src/renderer/src/lib/pane-manager/scroll-reflow.test.ts @@ -0,0 +1,305 @@ +import { describe, expect, it } from 'vitest' +import { Terminal } from '@xterm/headless' +import type { Terminal as XtermTerminal } from '@xterm/xterm' +import { findLineByContent } from './pane-tree-ops' + +/** + * These tests verify what xterm.js actually does to viewportY (ydisp) + * during terminal.resize() when column count changes cause line reflow. + * Understanding this behavior is critical for preserving scroll position + * when splitting terminal panes (which narrows the terminal). + */ + +function writeSync(term: Terminal, data: string): Promise { + return new Promise((resolve) => term.write(data, resolve)) +} + +async function createTerminalWithContentAsync( + cols: number, + rows: number, + scrollback: number, + lineCount: number +): Promise { + const term = new Terminal({ cols, rows, scrollback, allowProposedApi: true }) + // headless terminal doesn't need open() + for (let i = 0; i < lineCount; i++) { + const line = `L${String(i).padStart(3, '0')}${'x'.repeat(cols - 4)}` + await writeSync(term, `${line}\r\n`) + } + return term +} + +describe('xterm.js scroll position during reflow', () => { + it('reports buffer state correctly', async () => { + const term = await createTerminalWithContentAsync(80, 24, 1000, 100) + const buf = term.buffer.active + // 100 lines of content, 24 visible rows + expect(buf.baseY).toBeGreaterThan(0) + // By default after writing, terminal should be at bottom + expect(buf.viewportY).toBe(buf.baseY) + term.dispose() + }) + + it('scrollToLine sets viewportY', async () => { + const term = await createTerminalWithContentAsync(80, 24, 1000, 100) + term.scrollToLine(10) + expect(term.buffer.active.viewportY).toBe(10) + term.dispose() + }) + + describe('resize from wider to narrower (split scenario)', () => { + it('when at bottom: resize adjusts viewportY to stay at bottom', async () => { + const term = await createTerminalWithContentAsync(80, 24, 1000, 100) + const buf = term.buffer.active + + expect(buf.viewportY).toBe(buf.baseY) + const oldBaseY = buf.baseY + + // Simulate split: narrow from 80 to 40 cols + term.resize(40, 24) + + // After narrowing, lines wrap → more total lines → baseY increases + expect(buf.baseY).toBeGreaterThanOrEqual(oldBaseY) + // Does xterm keep us at the bottom? + console.log( + `[at-bottom] old baseY=${oldBaseY}, new baseY=${buf.baseY}, ` + + `viewportY=${buf.viewportY}, at-bottom=${buf.viewportY >= buf.baseY}` + ) + + term.dispose() + }) + + it('when scrolled up: captures how xterm adjusts viewportY during reflow', async () => { + const term = await createTerminalWithContentAsync(80, 24, 1000, 100) + const buf = term.buffer.active + + // Scroll to line 30 + term.scrollToLine(30) + expect(buf.viewportY).toBe(30) + + const oldViewportY = buf.viewportY + const oldBaseY = buf.baseY + + // Simulate split: narrow from 80 to 40 cols + term.resize(40, 24) + + console.log( + `[scrolled-up] old viewportY=${oldViewportY}, old baseY=${oldBaseY}, ` + + `new viewportY=${buf.viewportY}, new baseY=${buf.baseY}` + ) + + term.dispose() + }) + + it('when scrolled up: does scrollToLine before resize help?', async () => { + const term = await createTerminalWithContentAsync(80, 24, 1000, 100) + const buf = term.buffer.active + + // Scroll to line 30 + term.scrollToLine(30) + expect(buf.viewportY).toBe(30) + + const savedViewportY = buf.viewportY + const oldBaseY = buf.baseY + + // Simulate browser clobbering scroll to 0 + term.scrollToLine(0) + expect(buf.viewportY).toBe(0) + + // Strategy A: restore scrollToLine BEFORE resize + term.scrollToLine(savedViewportY) + expect(buf.viewportY).toBe(savedViewportY) + + term.resize(40, 24) + + console.log( + `[strategy-A: restore-before-resize] saved=${savedViewportY}, ` + + `old baseY=${oldBaseY}, new viewportY=${buf.viewportY}, new baseY=${buf.baseY}` + ) + + term.dispose() + }) + + it('when scrolled up: does scrollToLine after resize work?', async () => { + const term = await createTerminalWithContentAsync(80, 24, 1000, 100) + const buf = term.buffer.active + + // Scroll to line 30 + term.scrollToLine(30) + const savedViewportY = buf.viewportY + const oldBaseY = buf.baseY + + // Simulate browser clobbering scroll to 0 + term.scrollToLine(0) + + // Strategy B: resize first (from clobbered state), then restore + term.resize(40, 24) + const postResizeViewportY = buf.viewportY + const newBaseY = buf.baseY + + // Now try to restore with the old viewportY + term.scrollToLine(savedViewportY) + + console.log( + `[strategy-B: restore-after-resize] saved=${savedViewportY}, ` + + `old baseY=${oldBaseY}, post-resize viewportY=${postResizeViewportY}, ` + + `new baseY=${newBaseY}, final viewportY=${buf.viewportY}` + ) + + term.dispose() + }) + + it('ratio-based restoration after resize', async () => { + const term = await createTerminalWithContentAsync(80, 24, 1000, 100) + const buf = term.buffer.active + + term.scrollToLine(30) + const savedViewportY = buf.viewportY + const oldBaseY = buf.baseY + const ratio = oldBaseY > 0 ? savedViewportY / oldBaseY : 0 + + // Simulate browser clobbering scroll to 0 + term.scrollToLine(0) + + // Resize (from clobbered state) + term.resize(40, 24) + const newBaseY = buf.baseY + + // Strategy C: restore using ratio + const targetLine = Math.round(ratio * newBaseY) + term.scrollToLine(targetLine) + + console.log( + `[strategy-C: ratio] saved=${savedViewportY}, old baseY=${oldBaseY}, ` + + `ratio=${ratio.toFixed(3)}, new baseY=${newBaseY}, ` + + `target=${targetLine}, final viewportY=${buf.viewportY}` + ) + + term.dispose() + }) + + it('getline-based: find first visible line content and match after reflow', async () => { + const term = await createTerminalWithContentAsync(80, 24, 1000, 100) + const buf = term.buffer.active + + term.scrollToLine(30) + const savedViewportY = buf.viewportY + + // Capture the text of the first visible line + const firstVisibleLine = buf.getLine(savedViewportY)?.translateToString(true) ?? '' + const firstVisiblePrefix = firstVisibleLine.substring(0, 10) + + // Simulate browser clobbering scroll to 0 + term.scrollToLine(0) + + // Resize (from clobbered state) + term.resize(40, 24) + const newBaseY = buf.baseY + + // Strategy D: scan for the line with matching content + let matchLine = -1 + for (let i = 0; i <= newBaseY + 24; i++) { + const line = buf.getLine(i)?.translateToString(true) ?? '' + if (line.startsWith(firstVisiblePrefix)) { + matchLine = i + break + } + } + + if (matchLine >= 0) { + term.scrollToLine(matchLine) + } + + console.log( + `[strategy-D: content-match] looking for "${firstVisiblePrefix}", ` + + `found at line ${matchLine}, viewportY=${buf.viewportY}, ` + + `saved was ${savedViewportY}, new baseY=${newBaseY}` + ) + + term.dispose() + }) + + it('distance-from-bottom preservation', async () => { + const term = await createTerminalWithContentAsync(80, 24, 1000, 100) + const buf = term.buffer.active + + term.scrollToLine(30) + const savedViewportY = buf.viewportY + const oldBaseY = buf.baseY + const distFromBottom = oldBaseY - savedViewportY + + // Simulate browser clobbering scroll to 0 + term.scrollToLine(0) + + // Resize (from clobbered state) + term.resize(40, 24) + const newBaseY = buf.baseY + + // Strategy E: preserve distance from bottom + const targetLine = Math.max(0, newBaseY - distFromBottom) + term.scrollToLine(targetLine) + + console.log( + `[strategy-E: dist-from-bottom] saved=${savedViewportY}, ` + + `old baseY=${oldBaseY}, distFromBottom=${distFromBottom}, ` + + `new baseY=${newBaseY}, target=${targetLine}, ` + + `final viewportY=${buf.viewportY}` + ) + + term.dispose() + }) + }) + + describe('reference: what does undisturbed resize do?', () => { + it('resize without any scroll clobbering (ideal reference)', async () => { + const term = await createTerminalWithContentAsync(80, 24, 1000, 100) + const buf = term.buffer.active + + term.scrollToLine(30) + const savedViewportY = buf.viewportY + const oldBaseY = buf.baseY + + // DON'T clobber scroll — just resize directly + term.resize(40, 24) + + console.log( + `[reference: undisturbed] saved=${savedViewportY}, old baseY=${oldBaseY}, ` + + `new viewportY=${buf.viewportY}, new baseY=${buf.baseY}` + ) + + // This is the gold standard — what xterm does natively + term.dispose() + }) + }) + + describe('findLineByContent after reflow', () => { + it('finds the correct line after narrowing', async () => { + const term = await createTerminalWithContentAsync(80, 24, 1000, 100) + const buf = term.buffer.active + + term.scrollToLine(30) + const firstVisibleContent = buf.getLine(30)?.translateToString(true)?.trimEnd() ?? '' + + term.resize(40, 24) + + // findLineByContent should locate the same content after reflow + const target = findLineByContent(term as unknown as XtermTerminal, firstVisibleContent) + expect(target).toBeGreaterThan(0) + + // After scrolling to target, the first visible line should contain + // the same prefix as before the reflow + term.scrollToLine(target) + const afterContent = buf.getLine(target)?.translateToString(true)?.trimEnd() ?? '' + expect(afterContent.startsWith(firstVisibleContent.substring(0, 10))).toBe(true) + + term.dispose() + }) + + it('returns -1 for empty content', async () => { + const term = await createTerminalWithContentAsync(80, 24, 1000, 10) + const target = findLineByContent(term as unknown as XtermTerminal, '') + expect(target).toBe(-1) + term.dispose() + }) + }) +})