mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
fix: preserve terminal scroll position when splitting panes (#817)
This commit is contained in:
parent
0ef3a65635
commit
8e88fdae33
10 changed files with 549 additions and 148 deletions
|
|
@ -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 */
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
37
src/renderer/src/lib/pane-manager/pane-split-scroll.ts
Normal file
37
src/renderer/src/lib/pane-manager/pane-split-scroll.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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')) {
|
||||
|
|
|
|||
305
src/renderer/src/lib/pane-manager/scroll-reflow.test.ts
Normal file
305
src/renderer/src/lib/pane-manager/scroll-reflow.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue