fix: preserve terminal scroll position when splitting panes (#817)

This commit is contained in:
Jinwoo Hong 2026-04-19 23:35:37 -04:00 committed by GitHub
parent 0ef3a65635
commit 8e88fdae33
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 549 additions and 148 deletions

View file

@ -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<number | null>
@ -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 */
}

View file

@ -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()
}
/**

View file

@ -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 */
}

View file

@ -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 */
}

View file

@ -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 {

View file

@ -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'

View file

@ -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) {

View file

@ -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)
}

View file

@ -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<number, ManagedPaneInternal>): 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<number, ManagedPaneInternal>): void {
// If the element is a pane, refit it
if (el.classList.contains('pane')) {

View file

@ -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<void> {
return new Promise((resolve) => term.write(data, resolve))
}
async function createTerminalWithContentAsync(
cols: number,
rows: number,
scrollback: number,
lineCount: number
): Promise<Terminal> {
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()
})
})
})