This reverts commit 2ab2db9132.
This commit is contained in:
parent
2ab2db9132
commit
7a64ee3a11
|
|
@ -3,11 +3,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|||
import { DEFAULT_STATUS_BAR_ITEMS, DEFAULT_WORKTREE_CARD_PROPERTIES } from '../../shared/constants'
|
||||
|
||||
import { Minimize2, PanelLeft, PanelRight } from 'lucide-react'
|
||||
import {
|
||||
FOCUS_TERMINAL_PANE_EVENT,
|
||||
LAYOUT_WILL_CHANGE_EVENT,
|
||||
TOGGLE_TERMINAL_PANE_EXPAND_EVENT
|
||||
} from '@/constants/terminal'
|
||||
import { FOCUS_TERMINAL_PANE_EVENT, TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
|
||||
import { syncZoomCSSVar } from '@/lib/ui-zoom'
|
||||
import { toast } from 'sonner'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
|
|
@ -503,7 +499,6 @@ function App(): React.JSX.Element {
|
|||
if (!e.altKey && !e.shiftKey && e.key.toLowerCase() === 'b') {
|
||||
dispatchClearModifierHints()
|
||||
e.preventDefault()
|
||||
window.dispatchEvent(new Event(LAYOUT_WILL_CHANGE_EVENT))
|
||||
actions.toggleSidebar()
|
||||
return
|
||||
}
|
||||
|
|
@ -524,7 +519,6 @@ function App(): React.JSX.Element {
|
|||
if (!e.altKey && !e.shiftKey && e.key.toLowerCase() === 'l') {
|
||||
dispatchClearModifierHints()
|
||||
e.preventDefault()
|
||||
window.dispatchEvent(new Event(LAYOUT_WILL_CHANGE_EVENT))
|
||||
actions.toggleRightSidebar()
|
||||
return
|
||||
}
|
||||
|
|
@ -533,7 +527,6 @@ function App(): React.JSX.Element {
|
|||
if (e.shiftKey && !e.altKey && e.key.toLowerCase() === 'e') {
|
||||
dispatchClearModifierHints()
|
||||
e.preventDefault()
|
||||
window.dispatchEvent(new Event(LAYOUT_WILL_CHANGE_EVENT))
|
||||
actions.setRightSidebarTab('explorer')
|
||||
actions.setRightSidebarOpen(true)
|
||||
return
|
||||
|
|
@ -543,7 +536,6 @@ function App(): React.JSX.Element {
|
|||
if (e.shiftKey && !e.altKey && e.key.toLowerCase() === 'f') {
|
||||
dispatchClearModifierHints()
|
||||
e.preventDefault()
|
||||
window.dispatchEvent(new Event(LAYOUT_WILL_CHANGE_EVENT))
|
||||
actions.setRightSidebarTab('search')
|
||||
actions.setRightSidebarOpen(true)
|
||||
return
|
||||
|
|
@ -560,7 +552,6 @@ function App(): React.JSX.Element {
|
|||
}
|
||||
dispatchClearModifierHints()
|
||||
e.preventDefault()
|
||||
window.dispatchEvent(new Event(LAYOUT_WILL_CHANGE_EVENT))
|
||||
actions.setRightSidebarTab('source-control')
|
||||
actions.setRightSidebarOpen(true)
|
||||
}
|
||||
|
|
@ -606,10 +597,7 @@ function App(): React.JSX.Element {
|
|||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="sidebar-toggle"
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new Event(LAYOUT_WILL_CHANGE_EVENT))
|
||||
actions.toggleSidebar()
|
||||
}}
|
||||
onClick={actions.toggleSidebar}
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<PanelLeft size={16} />
|
||||
|
|
@ -738,10 +726,7 @@ function App(): React.JSX.Element {
|
|||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="sidebar-toggle mr-2"
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new Event(LAYOUT_WILL_CHANGE_EVENT))
|
||||
actions.toggleRightSidebar()
|
||||
}}
|
||||
onClick={actions.toggleRightSidebar}
|
||||
aria-label="Toggle right sidebar"
|
||||
>
|
||||
<PanelRight size={16} />
|
||||
|
|
@ -757,7 +742,6 @@ function App(): React.JSX.Element {
|
|||
if (activeView === 'tasks' && rightSidebarOpen) {
|
||||
// Why: hide the right sidebar immediately when entering the tasks page
|
||||
// so a previous open state can't bleed into that distraction-free view.
|
||||
window.dispatchEvent(new Event(LAYOUT_WILL_CHANGE_EVENT))
|
||||
actions.setRightSidebarOpen(false)
|
||||
}
|
||||
}, [activeView, rightSidebarOpen, actions])
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import type { ScrollState } from '@/lib/pane-manager/pane-manager-types'
|
||||
|
||||
export function fitPanes(manager: PaneManager, preCapturedStates?: Map<number, ScrollState>): void {
|
||||
manager.fitAllPanes(preCapturedStates)
|
||||
export function fitPanes(manager: PaneManager): void {
|
||||
manager.fitAllPanes()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -34,11 +33,8 @@ export function focusActivePane(manager: PaneManager): void {
|
|||
activePane?.terminal.focus()
|
||||
}
|
||||
|
||||
export function fitAndFocusPanes(
|
||||
manager: PaneManager,
|
||||
preCapturedStates?: Map<number, ScrollState>
|
||||
): void {
|
||||
fitPanes(manager, preCapturedStates)
|
||||
export function fitAndFocusPanes(manager: PaneManager): void {
|
||||
fitPanes(manager)
|
||||
focusActivePane(manager)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -103,10 +103,6 @@ function createPane(paneId: number) {
|
|||
},
|
||||
fitAddon: {
|
||||
fit: vi.fn()
|
||||
},
|
||||
container: {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -192,35 +192,10 @@ export function connectPanePty(
|
|||
transport.sendInput(data)
|
||||
})
|
||||
|
||||
// Why: during divider drag, each fit() fires terminal.onResize which sends
|
||||
// SIGWINCH to the PTY. Interactive programs (Claude Code) redraw on SIGWINCH,
|
||||
// writing new content that corrupts scroll position between capture and
|
||||
// restore. We fully suppress PTY resize during drag and store the latest
|
||||
// pending dimensions. When the drag ends, `pane-drag-end` fires on the pane
|
||||
// container and we flush the final dimensions in one shot — no debounce
|
||||
// timer that could fire mid-drag if the user pauses.
|
||||
let pendingDragResize: { cols: number; rows: number } | null = null
|
||||
const onResizeDisposable = pane.terminal.onResize(({ cols, rows }) => {
|
||||
if (manager.isPaneDragResizing(pane.id)) {
|
||||
pendingDragResize = { cols, rows }
|
||||
return
|
||||
}
|
||||
transport.resize(cols, rows)
|
||||
})
|
||||
|
||||
// Why: `unlockDragScroll` dispatches this event after clearing the drag
|
||||
// scroll lock and restoring scroll state. At this point the terminal has
|
||||
// its final dimensions from the last fit() but the PTY hasn't been told
|
||||
// yet. Flush the pending resize so the PTY (and Claude Code) redraws at
|
||||
// the correct size exactly once.
|
||||
const onDragEnd = (): void => {
|
||||
if (pendingDragResize) {
|
||||
transport.resize(pendingDragResize.cols, pendingDragResize.rows)
|
||||
pendingDragResize = null
|
||||
}
|
||||
}
|
||||
pane.container.addEventListener('pane-drag-end', onDragEnd)
|
||||
|
||||
// Defer PTY spawn/attach to next frame so FitAddon has time to calculate
|
||||
// the correct terminal dimensions from the laid-out container.
|
||||
deps.pendingWritesRef.current.set(pane.id, '')
|
||||
|
|
@ -518,8 +493,6 @@ export function connectPanePty(
|
|||
clearTimeout(startupInjectTimer)
|
||||
startupInjectTimer = null
|
||||
}
|
||||
pane.container.removeEventListener('pane-drag-end', onDragEnd)
|
||||
pendingDragResize = null
|
||||
if (connectFrame !== null) {
|
||||
// Why: StrictMode and split-group remounts can dispose a pane binding
|
||||
// before its deferred PTY attach/spawn work runs. Cancel that queued
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import {
|
||||
FOCUS_TERMINAL_PANE_EVENT,
|
||||
LAYOUT_WILL_CHANGE_EVENT,
|
||||
TOGGLE_TERMINAL_PANE_EXPAND_EVENT,
|
||||
type FocusTerminalPaneDetail
|
||||
} from '@/constants/terminal'
|
||||
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import type { ScrollState } from '@/lib/pane-manager/pane-manager-types'
|
||||
import { shellEscapePath } from './pane-helpers'
|
||||
import { fitAndFocusPanes, fitPanes, hasDimensionsChanged } from './pane-helpers'
|
||||
import type { PtyTransport } from './pty-transport'
|
||||
|
|
@ -71,14 +69,13 @@ export function useTerminalPaneGlobalEffects({
|
|||
return
|
||||
}
|
||||
if (isVisible) {
|
||||
// Why: capture scroll states BEFORE resumeRendering() because WebGL
|
||||
// context recreation and refresh() can fire xterm.js internal scroll
|
||||
// events that corrupt viewportY. This is the same pre-corruption
|
||||
// capture strategy used for sidebar toggles and divider drags.
|
||||
const preResumeScrollStates = manager.captureAllScrollStates()
|
||||
|
||||
// Why: resume WebGL immediately so the terminal shows its last-known
|
||||
// state on the first painted frame.
|
||||
// state on the first painted frame. Without this, the browser paints
|
||||
// 1+ frames of stale xterm DOM-fallback content at wrong dimensions
|
||||
// (the "stretched" flash). On macOS, WebGL context creation is ~5 ms
|
||||
// — fast enough to feel instant. On Windows (ANGLE → D3D11) it can
|
||||
// take 100–500 ms, but the alternative (deferring to a rAF) leaves
|
||||
// the terminal visibly distorted, which is a worse UX tradeoff.
|
||||
manager.resumeRendering()
|
||||
|
||||
fitEpochRef.current++
|
||||
|
|
@ -126,10 +123,10 @@ export function useTerminalPaneGlobalEffects({
|
|||
}
|
||||
fitRanForEpochRef.current = epoch
|
||||
if (isActive) {
|
||||
fitAndFocusPanes(mgr, preResumeScrollStates)
|
||||
fitAndFocusPanes(mgr)
|
||||
return
|
||||
}
|
||||
fitPanes(mgr, preResumeScrollStates)
|
||||
fitPanes(mgr)
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
|
|
@ -256,36 +253,28 @@ export function useTerminalPaneGlobalEffects({
|
|||
// terminal running at a stale column count.
|
||||
const RESIZE_DEBOUNCE_MS = 150
|
||||
let timerId: ReturnType<typeof setTimeout> | null = null
|
||||
// Why: scroll states must be captured at the instant the ResizeObserver
|
||||
// fires, not 150ms later when the debounced fit runs. During the debounce
|
||||
// window, async events (WebGL context loss, xterm.js Viewport._sync)
|
||||
// can corrupt the scroll position, so a later capture would record the
|
||||
// wrong viewportY. Capturing eagerly preserves the true pre-resize state.
|
||||
let pendingScrollStates: Map<number, ScrollState> | null = null
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
const manager = managerRef.current
|
||||
if (manager) {
|
||||
pendingScrollStates = manager.captureAllScrollStates()
|
||||
}
|
||||
if (timerId !== null) {
|
||||
clearTimeout(timerId)
|
||||
}
|
||||
timerId = setTimeout(() => {
|
||||
timerId = null
|
||||
const mgr = managerRef.current
|
||||
if (!mgr) {
|
||||
const manager = managerRef.current
|
||||
if (!manager) {
|
||||
return
|
||||
}
|
||||
// Why: apply the same epoch-based deduplication as the isActive
|
||||
// effect's rAF path. Read the current epoch at fire time (not a
|
||||
// captured value) because the ResizeObserver persists across the
|
||||
// activation. Dimension changes (e.g. window resize) bypass the
|
||||
// dedup so legitimate refits are never suppressed.
|
||||
const currentEpoch = fitEpochRef.current
|
||||
const dimensionsChanged = hasDimensionsChanged(mgr)
|
||||
const dimensionsChanged = hasDimensionsChanged(manager)
|
||||
if (!dimensionsChanged && fitRanForEpochRef.current >= currentEpoch) {
|
||||
pendingScrollStates = null
|
||||
return
|
||||
}
|
||||
fitRanForEpochRef.current = currentEpoch
|
||||
const states = pendingScrollStates
|
||||
pendingScrollStates = null
|
||||
fitPanes(mgr, states ?? undefined)
|
||||
fitPanes(manager)
|
||||
}, RESIZE_DEBOUNCE_MS)
|
||||
})
|
||||
resizeObserver.observe(container)
|
||||
|
|
@ -326,42 +315,4 @@ export function useTerminalPaneGlobalEffects({
|
|||
}
|
||||
})
|
||||
}, [isActiveRef, managerRef, paneTransportsRef])
|
||||
|
||||
// Why: instant layout changes (sidebar toggle via Cmd+L / Cmd+B) resize the
|
||||
// terminal container synchronously. xterm.js fires internal scroll events on
|
||||
// the viewport div during this synchronous reflow, corrupting scrollTop before
|
||||
// any ResizeObserver callback can capture state. By locking scroll states in
|
||||
// response to a synchronous event dispatched BEFORE the state change, we
|
||||
// guarantee capture happens while the viewport is still at its original
|
||||
// position — the same pattern that keeps divider drag scroll stable.
|
||||
useEffect(() => {
|
||||
let unlockTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const onLayoutWillChange = (): void => {
|
||||
const manager = managerRef.current
|
||||
if (!manager || !isVisibleRef.current) {
|
||||
return
|
||||
}
|
||||
manager.lockAllScrollStates()
|
||||
// Why: the sidebar CSS transition (200ms) + ResizeObserver debounce (150ms)
|
||||
// means the final fitAllPanesInternal runs at ~350ms. The unlock must fire
|
||||
// AFTER that fit so the locked state is used for the definitive restore.
|
||||
// 500ms provides margin for CSS transition variance and frame scheduling.
|
||||
// unlockAllScrollStates starts a 500ms settling rAF loop, so the lock
|
||||
// actually persists until ~1000ms total, absorbing any SIGWINCH redraws.
|
||||
if (unlockTimer !== null) {
|
||||
clearTimeout(unlockTimer)
|
||||
}
|
||||
unlockTimer = setTimeout(() => {
|
||||
unlockTimer = null
|
||||
managerRef.current?.unlockAllScrollStates()
|
||||
}, 500)
|
||||
}
|
||||
window.addEventListener(LAYOUT_WILL_CHANGE_EVENT, onLayoutWillChange)
|
||||
return () => {
|
||||
window.removeEventListener(LAYOUT_WILL_CHANGE_EVENT, onLayoutWillChange)
|
||||
if (unlockTimer !== null) {
|
||||
clearTimeout(unlockTimer)
|
||||
}
|
||||
}
|
||||
}, [managerRef, isVisibleRef])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
export const TOGGLE_TERMINAL_PANE_EXPAND_EVENT = 'orca-toggle-terminal-pane-expand'
|
||||
export const FOCUS_TERMINAL_PANE_EVENT = 'orca-focus-terminal-pane'
|
||||
export const LAYOUT_WILL_CHANGE_EVENT = 'orca-layout-will-change'
|
||||
|
||||
export type ToggleTerminalPaneExpandDetail = {
|
||||
tabId: string
|
||||
|
|
|
|||
|
|
@ -1053,7 +1053,7 @@ describe('useIpcEvents shortcut hint clearing', () => {
|
|||
expect(dispatchEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'orca:clear-modifier-hints' })
|
||||
)
|
||||
expect(dispatchEvent).toHaveBeenCalledTimes(3)
|
||||
expect(dispatchEvent).toHaveBeenCalledTimes(2)
|
||||
expect(toggleSidebar).toHaveBeenCalledTimes(1)
|
||||
expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-2')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import { dispatchZoomLevelChanged } from '@/lib/zoom-events'
|
|||
import { resolveZoomTarget } from './resolve-zoom-target'
|
||||
import { handleSwitchTab } from './ipc-tab-switch'
|
||||
import { dispatchClearModifierHints } from './useModifierHint'
|
||||
import { LAYOUT_WILL_CHANGE_EVENT } from '@/constants/terminal'
|
||||
import { isGitRepoKind } from '../../../shared/repo-kind'
|
||||
|
||||
export { resolveZoomTarget } from './resolve-zoom-target'
|
||||
|
|
@ -48,7 +47,6 @@ export function useIpcEvents(): void {
|
|||
unsubs.push(
|
||||
window.api.ui.onToggleLeftSidebar(() => {
|
||||
dispatchClearModifierHints()
|
||||
window.dispatchEvent(new Event(LAYOUT_WILL_CHANGE_EVENT))
|
||||
useAppStore.getState().toggleSidebar()
|
||||
})
|
||||
)
|
||||
|
|
@ -56,7 +54,6 @@ export function useIpcEvents(): void {
|
|||
unsubs.push(
|
||||
window.api.ui.onToggleRightSidebar(() => {
|
||||
dispatchClearModifierHints()
|
||||
window.dispatchEvent(new Event(LAYOUT_WILL_CHANGE_EVENT))
|
||||
useAppStore.getState().toggleRightSidebar()
|
||||
})
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ManagedPaneInternal, ScrollState } from './pane-manager-types'
|
||||
import type { ManagedPaneInternal } from './pane-manager-types'
|
||||
import { captureScrollState, restoreScrollState } from './pane-scroll'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -14,159 +14,17 @@ export function lockDragScroll(el: HTMLElement, panes: Map<number, ManagedPaneIn
|
|||
}
|
||||
}
|
||||
|
||||
// Why: SIGWINCH settling delay. After the drag ends we flush the suppressed
|
||||
// PTY resize, which sends SIGWINCH to the shell. Interactive TUIs (Claude
|
||||
// Code / Ink) redraw on SIGWINCH, writing cursor-positioned content that
|
||||
// moves xterm's viewportY. If we clear pendingDragScrollState immediately,
|
||||
// the ResizeObserver's captureAllPaneScrollStates runs against the corrupted
|
||||
// viewportY and cements the wrong position. Keeping the lock active for
|
||||
// this period makes captureAllPaneScrollStates skip the pane and makes
|
||||
// safeFit / fitAllPanesInternal restore from the original captured state.
|
||||
const SIGWINCH_SETTLE_MS = 500
|
||||
|
||||
export function unlockDragScroll(el: HTMLElement, panes: Map<number, ManagedPaneInternal>): void {
|
||||
for (const pane of findManagedPanesUnder(el, panes)) {
|
||||
if (pane.pendingDragScrollState) {
|
||||
const originalState = pane.pendingDragScrollState
|
||||
|
||||
try {
|
||||
restoreScrollState(pane.terminal, originalState)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
// Why: during divider drag, PTY resize is suppressed to prevent
|
||||
// SIGWINCH-driven redraws from corrupting scroll state. Dispatch
|
||||
// on the pane container so pty-connection.ts can flush the final
|
||||
// dimensions to the PTY when the drag ends.
|
||||
pane.container.dispatchEvent(new CustomEvent('pane-drag-end'))
|
||||
|
||||
// Why: the PTY resize we just flushed causes SIGWINCH → Claude
|
||||
// Code / Ink redraws → viewportY corruption. We keep the lock
|
||||
// and actively restore scroll every frame during the settling
|
||||
// period so the user never sees the viewport jump. Without this,
|
||||
// there is a visible flash where the viewport jumps to line 0
|
||||
// before the settling timeout fires.
|
||||
startSettlingLoop(pane, originalState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startSettlingLoop(pane: ManagedPaneInternal, originalState: ScrollState): void {
|
||||
let rafId: number | null = null
|
||||
|
||||
const restoreFrame = (): void => {
|
||||
if (pane.pendingDragScrollState !== originalState) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
restoreScrollState(pane.terminal, originalState)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
rafId = requestAnimationFrame(restoreFrame)
|
||||
}
|
||||
rafId = requestAnimationFrame(restoreFrame)
|
||||
|
||||
setTimeout(() => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
}
|
||||
if (pane.pendingDragScrollState === originalState) {
|
||||
try {
|
||||
restoreScrollState(pane.terminal, originalState)
|
||||
restoreScrollState(pane.terminal, pane.pendingDragScrollState)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
pane.pendingDragScrollState = null
|
||||
// Why: during the settling period isPaneDragResizing was true,
|
||||
// so any terminal.onResize events were suppressed and stored as
|
||||
// pending. Dispatch pane-drag-end again so pty-connection.ts
|
||||
// flushes any accumulated resize to the PTY.
|
||||
pane.container.dispatchEvent(new CustomEvent('pane-drag-end'))
|
||||
}
|
||||
}, SIGWINCH_SETTLE_MS)
|
||||
}
|
||||
|
||||
export function captureAllPaneScrollStates(
|
||||
panes: Map<number, ManagedPaneInternal>
|
||||
): Map<number, ScrollState> {
|
||||
const states = new Map<number, ScrollState>()
|
||||
for (const pane of panes.values()) {
|
||||
if (
|
||||
!pane.pendingSplitScrollState &&
|
||||
!pane.pendingDragScrollState &&
|
||||
!pane.pendingLayoutScrollState
|
||||
) {
|
||||
states.set(pane.id, captureScrollState(pane.terminal))
|
||||
}
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
export function lockAllPaneScrollStates(panes: Map<number, ManagedPaneInternal>): void {
|
||||
for (const pane of panes.values()) {
|
||||
if (
|
||||
!pane.pendingSplitScrollState &&
|
||||
!pane.pendingDragScrollState &&
|
||||
!pane.pendingLayoutScrollState
|
||||
) {
|
||||
pane.pendingLayoutScrollState = captureScrollState(pane.terminal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function unlockAllPaneScrollStates(panes: Map<number, ManagedPaneInternal>): void {
|
||||
for (const pane of panes.values()) {
|
||||
if (pane.pendingLayoutScrollState) {
|
||||
const originalState = pane.pendingLayoutScrollState
|
||||
|
||||
try {
|
||||
restoreScrollState(pane.terminal, originalState)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
// Why: PTY resize is NOT suppressed during layout changes (unlike
|
||||
// drag), so SIGWINCH fires immediately when fit() runs. Interactive
|
||||
// TUIs (Claude Code / Ink) redraw on SIGWINCH, corrupting viewportY.
|
||||
// The settling loop restores scroll every frame for 500ms, absorbing
|
||||
// these redraws. The lock stays active so fitAllPanesInternal
|
||||
// continues using the original captured state during settling.
|
||||
startLayoutSettlingLoop(pane, originalState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startLayoutSettlingLoop(pane: ManagedPaneInternal, originalState: ScrollState): void {
|
||||
let rafId: number | null = null
|
||||
|
||||
const restoreFrame = (): void => {
|
||||
if (pane.pendingLayoutScrollState !== originalState) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
restoreScrollState(pane.terminal, originalState)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
rafId = requestAnimationFrame(restoreFrame)
|
||||
}
|
||||
rafId = requestAnimationFrame(restoreFrame)
|
||||
|
||||
setTimeout(() => {
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
}
|
||||
if (pane.pendingLayoutScrollState === originalState) {
|
||||
try {
|
||||
restoreScrollState(pane.terminal, originalState)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
pane.pendingLayoutScrollState = null
|
||||
}
|
||||
}, SIGWINCH_SETTLE_MS)
|
||||
}
|
||||
|
||||
function findManagedPanesUnder(
|
||||
|
|
|
|||
|
|
@ -135,8 +135,7 @@ export function createPaneDOM(
|
|||
webglAddon: null,
|
||||
compositionHandler: null,
|
||||
pendingSplitScrollState: null,
|
||||
pendingDragScrollState: null,
|
||||
pendingLayoutScrollState: null
|
||||
pendingDragScrollState: null
|
||||
}
|
||||
|
||||
// Focus handler: clicking a pane makes it active and explicitly focuses
|
||||
|
|
@ -278,9 +277,6 @@ export function attachWebgl(pane: ManagedPaneInternal): void {
|
|||
} else if (pane.pendingDragScrollState) {
|
||||
pane.fitAddon.fit()
|
||||
restoreScrollState(pane.terminal, pane.pendingDragScrollState)
|
||||
} else if (pane.pendingLayoutScrollState) {
|
||||
pane.fitAddon.fit()
|
||||
restoreScrollState(pane.terminal, pane.pendingLayoutScrollState)
|
||||
} else {
|
||||
const scrollState = captureScrollState(pane.terminal)
|
||||
pane.fitAddon.fit()
|
||||
|
|
@ -346,24 +342,3 @@ export function disposePane(
|
|||
}
|
||||
panes.delete(pane.id)
|
||||
}
|
||||
|
||||
export function suspendAllRendering(panes: Map<number, ManagedPaneInternal>): void {
|
||||
for (const pane of panes.values()) {
|
||||
disposeWebgl(pane)
|
||||
}
|
||||
}
|
||||
|
||||
export function resumeAllRendering(panes: Map<number, ManagedPaneInternal>): void {
|
||||
for (const pane of panes.values()) {
|
||||
if (pane.gpuRenderingEnabled && !pane.webglAddon) {
|
||||
attachWebgl(pane)
|
||||
// Why: fresh WebGL canvas from attachWebgl() has no painted content;
|
||||
// without refresh the terminal appears frozen when dims are unchanged.
|
||||
try {
|
||||
pane.terminal.refresh(0, pane.terminal.rows - 1)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,12 +53,6 @@ export type ScrollState = {
|
|||
firstVisibleLineContent: string
|
||||
viewportY: number
|
||||
totalLines: number
|
||||
cols: number
|
||||
// Why: number of wrapped rows between the logical line start and the
|
||||
// viewport row. When the anchor is the logical line start (not the
|
||||
// viewport row itself), this offset lets restoreScrollState approximate
|
||||
// the correct viewport position after reflow changes wrap points.
|
||||
logicalLineOffset: number
|
||||
}
|
||||
|
||||
export type ManagedPaneInternal = {
|
||||
|
|
@ -83,11 +77,6 @@ export type ManagedPaneInternal = {
|
|||
// terminal to a completely wrong position. Capturing once at drag start
|
||||
// and reusing that state for every restore eliminates accumulation.
|
||||
pendingDragScrollState: ScrollState | null
|
||||
// Why: sidebar toggles and worktree switches resize the terminal container
|
||||
// synchronously, corrupting scroll before ResizeObserver fires. Separate
|
||||
// from pendingDragScrollState so a keyboard sidebar toggle during an active
|
||||
// divider drag doesn't steal the drag's lock.
|
||||
pendingLayoutScrollState: ScrollState | null
|
||||
} & ManagedPane
|
||||
|
||||
export type DropZone = 'top' | 'bottom' | 'left' | 'right'
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
/* oxlint-disable max-lines */
|
||||
import type {
|
||||
PaneManagerOptions,
|
||||
PaneStyleOptions,
|
||||
ManagedPane,
|
||||
ManagedPaneInternal,
|
||||
DropZone,
|
||||
ScrollState
|
||||
DropZone
|
||||
} from './pane-manager-types'
|
||||
import {
|
||||
createDivider,
|
||||
|
|
@ -24,9 +22,7 @@ import {
|
|||
openTerminal,
|
||||
attachWebgl,
|
||||
disposeWebgl,
|
||||
disposePane,
|
||||
suspendAllRendering,
|
||||
resumeAllRendering
|
||||
disposePane
|
||||
} from './pane-lifecycle'
|
||||
import { shouldFollowMouseFocus } from './focus-follows-mouse'
|
||||
import {
|
||||
|
|
@ -39,13 +35,7 @@ import {
|
|||
captureScrollState,
|
||||
refitPanesUnder
|
||||
} from './pane-tree-ops'
|
||||
import {
|
||||
lockDragScroll,
|
||||
unlockDragScroll,
|
||||
captureAllPaneScrollStates,
|
||||
lockAllPaneScrollStates,
|
||||
unlockAllPaneScrollStates
|
||||
} from './pane-drag-scroll'
|
||||
import { lockDragScroll, unlockDragScroll } from './pane-drag-scroll'
|
||||
import { scheduleSplitScrollRestore } from './pane-split-scroll'
|
||||
|
||||
export type { PaneManagerOptions, PaneStyleOptions, ManagedPane, DropZone }
|
||||
|
|
@ -192,25 +182,8 @@ export class PaneManager {
|
|||
return Array.from(this.panes.values()).map((p) => this.toPublic(p))
|
||||
}
|
||||
|
||||
fitAllPanes(preCapturedStates?: Map<number, ScrollState>): void {
|
||||
fitAllPanesInternal(this.panes, preCapturedStates)
|
||||
}
|
||||
|
||||
captureAllScrollStates(): Map<number, ScrollState> {
|
||||
return captureAllPaneScrollStates(this.panes)
|
||||
}
|
||||
|
||||
lockAllScrollStates(): void {
|
||||
lockAllPaneScrollStates(this.panes)
|
||||
}
|
||||
|
||||
unlockAllScrollStates(): void {
|
||||
unlockAllPaneScrollStates(this.panes)
|
||||
}
|
||||
|
||||
isPaneDragResizing(paneId: number): boolean {
|
||||
const pane = this.panes.get(paneId)
|
||||
return pane?.pendingDragScrollState != null
|
||||
fitAllPanes(): void {
|
||||
fitAllPanesInternal(this.panes)
|
||||
}
|
||||
|
||||
getActivePane(): ManagedPane | null {
|
||||
|
|
@ -263,13 +236,32 @@ export class PaneManager {
|
|||
}
|
||||
|
||||
suspendRendering(): void {
|
||||
suspendAllRendering(this.panes)
|
||||
for (const pane of this.panes.values()) {
|
||||
disposeWebgl(pane)
|
||||
}
|
||||
}
|
||||
|
||||
resumeRendering(): void {
|
||||
resumeAllRendering(this.panes)
|
||||
for (const pane of this.panes.values()) {
|
||||
if (pane.gpuRenderingEnabled && !pane.webglAddon) {
|
||||
attachWebgl(pane)
|
||||
// Why: the fitPanes() optimization skips panes whose dimensions are
|
||||
// unchanged (common when a worktree goes hidden→visible at the same
|
||||
// window size). But the fresh WebGL canvas created by attachWebgl()
|
||||
// has no painted content — without an explicit refresh the terminal
|
||||
// appears frozen until something forces a dimension change (e.g. a
|
||||
// split). This mirrors the onContextLoss handler in attachWebgl which
|
||||
// calls the same refresh after falling back to the DOM renderer.
|
||||
try {
|
||||
pane.terminal.refresh(0, pane.terminal.rows - 1)
|
||||
} catch {
|
||||
/* ignore — pane may not be fully initialised yet */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Move a pane from its current position to a new position relative to a target pane. */
|
||||
movePane(sourcePaneId: number, targetPaneId: number, zone: DropZone): void {
|
||||
handlePaneDrop(sourcePaneId, targetPaneId, zone, this.dragState, this.getDragCallbacks())
|
||||
}
|
||||
|
|
@ -284,6 +276,10 @@ export class PaneManager {
|
|||
this.activePaneId = null
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private createPaneInternal(): ManagedPaneInternal {
|
||||
const id = this.nextPaneId++
|
||||
const pane = createPaneDOM(
|
||||
|
|
@ -293,8 +289,12 @@ export class PaneManager {
|
|||
this.getDragCallbacks(),
|
||||
(paneId) => {
|
||||
if (!this.destroyed) {
|
||||
// Why: always re-focus on click — activePaneId can be stale after
|
||||
// splits, sending input to the wrong pane without an explicit focus.
|
||||
// Why: split-pane layout/focus callbacks can leave the manager's
|
||||
// activePaneId temporarily in sync while the browser's real focused
|
||||
// xterm textarea is still on a different pane. Clicking a pane must
|
||||
// always re-focus its terminal, even if the manager already thinks
|
||||
// that pane is active; otherwise input can keep going to the wrong
|
||||
// split after vertical/horizontal splits.
|
||||
this.setActivePane(paneId, { focus: true })
|
||||
}
|
||||
},
|
||||
|
|
@ -306,8 +306,16 @@ export class PaneManager {
|
|||
return pane
|
||||
}
|
||||
|
||||
// Why: modal overlays must be rendered OUTSIDE .pane elements, otherwise
|
||||
// mouseenter fires on the pane underneath and incorrectly switches focus.
|
||||
/**
|
||||
* Focus-follows-mouse entry point. Collects gate inputs from the manager
|
||||
* and delegates to the pure gate helper.
|
||||
*
|
||||
* Invariant for future contributors: modal overlays (context menus, close
|
||||
* dialogs, command palette) must be rendered as portals/siblings OUTSIDE
|
||||
* the pane container. If a future overlay is ever rendered inside a .pane
|
||||
* element, mouseenter will still fire on the pane underneath and this
|
||||
* handler will incorrectly switch focus. Keep overlays out of the pane.
|
||||
*/
|
||||
private handlePaneMouseEnter(paneId: number, event: MouseEvent): void {
|
||||
if (
|
||||
shouldFollowMouseFocus({
|
||||
|
|
@ -348,6 +356,7 @@ export class PaneManager {
|
|||
}
|
||||
}
|
||||
|
||||
/** Build the callbacks object for drag-reorder functions. */
|
||||
private getDragCallbacks() {
|
||||
return {
|
||||
getPanes: () => this.panes,
|
||||
|
|
|
|||
|
|
@ -53,128 +53,32 @@ 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
|
||||
|
||||
// Why: if the viewport starts at a wrapped continuation row, its content
|
||||
// won't appear as a line start after reflow (column count change shifts
|
||||
// wrap points). Walk backward to the logical line start — that content
|
||||
// always remains a line start regardless of column width, making content
|
||||
// matching reliable for long-line terminals like Claude Code.
|
||||
let anchorY = viewportY
|
||||
while (anchorY > 0 && buf.getLine(anchorY)?.isWrapped) {
|
||||
anchorY--
|
||||
}
|
||||
const firstVisibleLineContent = buf.getLine(anchorY)?.translateToString(true)?.trimEnd() ?? ''
|
||||
const logicalLineOffset = viewportY - anchorY
|
||||
|
||||
return {
|
||||
wasAtBottom,
|
||||
firstVisibleLineContent,
|
||||
viewportY,
|
||||
totalLines,
|
||||
cols: terminal.cols,
|
||||
logicalLineOffset
|
||||
}
|
||||
return { wasAtBottom, firstVisibleLineContent, viewportY, totalLines }
|
||||
}
|
||||
|
||||
export function restoreScrollState(terminal: Terminal, state: ScrollState): void {
|
||||
if (state.wasAtBottom) {
|
||||
scrollToLineSync(terminal, terminal.buffer.active.baseY)
|
||||
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) {
|
||||
let scrollTarget = target
|
||||
if (state.logicalLineOffset > 0) {
|
||||
const newCols = terminal.cols
|
||||
const scaledOffset =
|
||||
state.cols > 0 && newCols > 0
|
||||
? Math.round(state.logicalLineOffset * (state.cols / newCols))
|
||||
: state.logicalLineOffset
|
||||
scrollTarget = Math.min(target + scaledOffset, terminal.buffer.active.baseY)
|
||||
}
|
||||
scrollToLineSync(terminal, scrollTarget)
|
||||
return
|
||||
}
|
||||
if (hintRatio !== undefined) {
|
||||
const newTotalLines = terminal.buffer.active.baseY + terminal.rows
|
||||
const fallbackLine = Math.round(hintRatio * newTotalLines)
|
||||
const clampedLine = Math.min(fallbackLine, terminal.buffer.active.baseY)
|
||||
scrollToLineSync(terminal, clampedLine)
|
||||
terminal.scrollToLine(target)
|
||||
forceViewportScrollbarSync(terminal)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the public terminal.scrollToLine(line) goes through
|
||||
// CoreBrowserTerminal.scrollLines → viewport.scrollLines, which calls
|
||||
// SmoothScrollableElement.setScrollPosition WITHOUT updating Viewport's
|
||||
// _latestYDisp. A subsequent queued _sync (from resize) then sees
|
||||
// ydisp === _latestYDisp (stale) and skips setScrollPosition, leaving the
|
||||
// SmoothScrollableElement's internal scrollTop out of sync with the buffer.
|
||||
// When the user later scrolls (mouse wheel), the SmoothScrollableElement
|
||||
// adjusts from its stale scrollTop, causing the terminal to jump.
|
||||
//
|
||||
// Viewport.scrollToLine(line, disableSmoothScroll=true) directly sets
|
||||
// _latestYDisp = line before calling setScrollPosition, keeping the
|
||||
// scrollbar state in sync. We access this through _core._viewport which
|
||||
// is an internal API (tested against @xterm/xterm 6.0.0). Falls back to
|
||||
// the public API + scroll jiggle if internals are unavailable.
|
||||
function scrollToLineSync(terminal: Terminal, line: number): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const viewport = (terminal as any)._core?._viewport
|
||||
if (viewport && typeof viewport._sync === 'function') {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const cellH = (terminal as any)._core?._renderService?.dimensions?.css?.cell?.height
|
||||
const scrollableEl = viewport._scrollableElement
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const bufLines = (terminal as any)._core?._bufferService?.buffer?.lines?.length
|
||||
|
||||
// Why: _sync(line) calls setScrollDimensions (possibly with stale
|
||||
// renderer values) then setScrollPosition. If the stale dimensions
|
||||
// give a maxScrollTop smaller than our target, the position is
|
||||
// silently clamped. We call _sync first to let it do its thing,
|
||||
// then OVERRIDE with correct dimensions computed from the actual
|
||||
// buffer state, then re-set the scroll position. The second
|
||||
// setScrollDimensions + setScrollPosition pair operates against
|
||||
// accurate maxScrollTop so the target isn't clamped.
|
||||
viewport._latestYDisp = undefined
|
||||
viewport._sync(line)
|
||||
|
||||
if (
|
||||
cellH &&
|
||||
bufLines &&
|
||||
scrollableEl?.setScrollDimensions &&
|
||||
scrollableEl?.setScrollPosition
|
||||
) {
|
||||
// Why: suppress _handleScroll during our dimension fix-up to
|
||||
// prevent stale scroll events from overwriting buffer.ydisp.
|
||||
viewport._suppressOnScrollHandler = true
|
||||
scrollableEl.setScrollDimensions({
|
||||
height: cellH * terminal.rows,
|
||||
scrollHeight: cellH * bufLines
|
||||
})
|
||||
viewport._suppressOnScrollHandler = false
|
||||
|
||||
scrollableEl.setScrollPosition({ scrollTop: line * cellH })
|
||||
}
|
||||
|
||||
viewport._latestYDisp = line
|
||||
} catch {
|
||||
terminal.scrollToLine(line)
|
||||
forceViewportScrollbarSync(terminal)
|
||||
}
|
||||
return
|
||||
}
|
||||
terminal.scrollToLine(line)
|
||||
forceViewportScrollbarSync(terminal)
|
||||
}
|
||||
|
||||
// Why: fallback for when the internal viewport API is unavailable. The
|
||||
// scroll jiggle (-1/+1) triggers _handleScroll with diff≠0, which updates
|
||||
// _latestYDisp. Less reliable than the direct viewport.scrollToLine path
|
||||
// because it reads from the SmoothScrollableElement's potentially-stale
|
||||
// scrollTop, but better than nothing.
|
||||
// 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) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,4 @@
|
|||
import type {
|
||||
DropZone,
|
||||
ManagedPaneInternal,
|
||||
PaneStyleOptions,
|
||||
ScrollState
|
||||
} from './pane-manager-types'
|
||||
import type { DropZone, ManagedPaneInternal, PaneStyleOptions } from './pane-manager-types'
|
||||
import { createDivider } from './pane-divider'
|
||||
import { captureScrollState, restoreScrollState } from './pane-scroll'
|
||||
|
||||
|
|
@ -31,21 +26,7 @@ export function safeFit(pane: ManagedPaneInternal): void {
|
|||
}
|
||||
if (pane.pendingDragScrollState) {
|
||||
pane.fitAddon.fit()
|
||||
// Why: fit() → resize() → queueSync() schedules a deferred _sync via
|
||||
// addRefreshCallback that updates the Scrollable's dimensions in the next
|
||||
// animation frame. Any scroll position we set synchronously here can be
|
||||
// silently clamped when that deferred _sync calls setScrollDimensions
|
||||
// (with _suppressOnScrollHandler=true, so clamping updates neither
|
||||
// _latestYDisp nor buffer.ydisp). By scheduling our restore via the same
|
||||
// addRefreshCallback mechanism, we run AFTER the deferred _sync has
|
||||
// corrected the Scrollable dimensions, so our setScrollPosition operates
|
||||
// against accurate maxScrollTop and isn't clamped.
|
||||
deferredDragScrollRestore(pane)
|
||||
return
|
||||
}
|
||||
if (pane.pendingLayoutScrollState) {
|
||||
pane.fitAddon.fit()
|
||||
restoreScrollState(pane.terminal, pane.pendingLayoutScrollState)
|
||||
restoreScrollState(pane.terminal, pane.pendingDragScrollState)
|
||||
return
|
||||
}
|
||||
const state = captureScrollState(pane.terminal)
|
||||
|
|
@ -56,32 +37,7 @@ export function safeFit(pane: ManagedPaneInternal): void {
|
|||
}
|
||||
}
|
||||
|
||||
function deferredDragScrollRestore(pane: ManagedPaneInternal): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const renderService = (pane.terminal as any)._core?._renderService
|
||||
if (!renderService?.addRefreshCallback) {
|
||||
if (pane.pendingDragScrollState) {
|
||||
restoreScrollState(pane.terminal, pane.pendingDragScrollState)
|
||||
}
|
||||
return
|
||||
}
|
||||
renderService.addRefreshCallback(() => {
|
||||
try {
|
||||
const state = pane.pendingDragScrollState
|
||||
if (!state) {
|
||||
return
|
||||
}
|
||||
restoreScrollState(pane.terminal, state)
|
||||
} catch {
|
||||
/* pane may have been disposed */
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function fitAllPanesInternal(
|
||||
panes: Map<number, ManagedPaneInternal>,
|
||||
preCapturedStates?: Map<number, ScrollState>
|
||||
): void {
|
||||
export function fitAllPanesInternal(panes: Map<number, ManagedPaneInternal>): void {
|
||||
for (const pane of panes.values()) {
|
||||
try {
|
||||
const dims = pane.fitAddon.proposeDimensions()
|
||||
|
|
@ -97,17 +53,7 @@ export function fitAllPanesInternal(
|
|||
restoreScrollState(pane.terminal, pane.pendingDragScrollState)
|
||||
continue
|
||||
}
|
||||
if (pane.pendingLayoutScrollState) {
|
||||
pane.fitAddon.fit()
|
||||
restoreScrollState(pane.terminal, pane.pendingLayoutScrollState)
|
||||
continue
|
||||
}
|
||||
// Why: use pre-captured state when available because the ResizeObserver
|
||||
// debounce (150ms) gives async events (WebGL context loss, viewport
|
||||
// _sync) time to corrupt the scroll position before we run. Capturing
|
||||
// at the instant the ResizeObserver fires preserves the true pre-resize
|
||||
// viewport position.
|
||||
const state = preCapturedStates?.get(pane.id) ?? captureScrollState(pane.terminal)
|
||||
const state = captureScrollState(pane.terminal)
|
||||
pane.fitAddon.fit()
|
||||
restoreScrollState(pane.terminal, state)
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
/**
|
||||
* Simulates Ink's (React for CLI) inline viewport rendering behavior.
|
||||
*
|
||||
* Ink renders inline in the normal terminal buffer (not the alt screen).
|
||||
* On SIGWINCH it re-renders by:
|
||||
* 1. Moving cursor up to the start of the previous render
|
||||
* 2. Clearing from cursor to end of display (\033[J)
|
||||
* 3. Writing the new rendered output
|
||||
*
|
||||
* This is the exact pattern that causes scroll position corruption during
|
||||
* divider drag resize, because the clear + rewrite sequence moves xterm.js's
|
||||
* viewportY when the terminal has scrollback content above the render area.
|
||||
*
|
||||
* Usage: node tests/e2e/fixtures/ink-tui-sim.mjs
|
||||
* Exits cleanly on SIGTERM or SIGINT.
|
||||
*/
|
||||
|
||||
const TUI_HEIGHT = 12
|
||||
|
||||
let lastLineCount = 0
|
||||
|
||||
function render() {
|
||||
const { columns: cols, rows } = process.stdout
|
||||
|
||||
// Step 1: if we previously rendered, move cursor back to start of that render
|
||||
if (lastLineCount > 0) {
|
||||
process.stdout.write(`\x1b[${lastLineCount}A\r`)
|
||||
}
|
||||
|
||||
// Step 2: clear from cursor to end of display (exactly what Ink does)
|
||||
process.stdout.write('\x1b[J')
|
||||
|
||||
// Step 3: write the TUI content
|
||||
const lines = []
|
||||
const hr = '━'.repeat(Math.min(cols, 80))
|
||||
lines.push(`\x1b[1m${hr}\x1b[0m`)
|
||||
lines.push(` \x1b[36m◆\x1b[0m Claude Code \x1b[2mv2.1.10\x1b[0m`)
|
||||
lines.push(` \x1b[2mModel: claude-sonnet-4-20250514\x1b[0m`)
|
||||
lines.push('')
|
||||
lines.push(` \x1b[33m⠋\x1b[0m Thinking...`)
|
||||
lines.push('')
|
||||
|
||||
// Fill remaining TUI height with status lines
|
||||
const used = lines.length
|
||||
for (let i = used; i < TUI_HEIGHT - 2; i++) {
|
||||
const pad = ' '.repeat(Math.max(0, Math.min(cols, 80) - 30))
|
||||
lines.push(` \x1b[2mcontext: ${cols}×${rows} │ tokens: ${1234 + i}${pad}\x1b[0m`)
|
||||
}
|
||||
|
||||
lines.push(`\x1b[1m${hr}\x1b[0m`)
|
||||
lines.push(
|
||||
`\x1b[7m ${' '.repeat(Math.max(0, Math.min(cols, 80) - 22))}` +
|
||||
`${cols}×${rows} | Ctrl+C to exit \x1b[0m`
|
||||
)
|
||||
|
||||
const output = `${lines.join('\n')}\n`
|
||||
process.stdout.write(output)
|
||||
lastLineCount = lines.length
|
||||
}
|
||||
|
||||
// Initial render
|
||||
render()
|
||||
|
||||
// Re-render on SIGWINCH (terminal resize) — this is the critical behavior
|
||||
process.on('SIGWINCH', render)
|
||||
|
||||
// Clean exit
|
||||
process.on('SIGTERM', () => process.exit(0))
|
||||
process.on('SIGINT', () => process.exit(0))
|
||||
|
||||
// Keep alive
|
||||
setInterval(() => {}, 60_000)
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
/* oxlint-disable max-lines, curly */
|
||||
/**
|
||||
* E2E tests for terminal pane splitting, state retention, resizing, and closing.
|
||||
*
|
||||
|
|
@ -34,7 +33,6 @@ import {
|
|||
ensureTerminalVisible
|
||||
} from './helpers/store'
|
||||
import { pressShortcut } from './helpers/shortcuts'
|
||||
import path from 'path'
|
||||
|
||||
// Why: only the pointer-drag resize test needs a visible window (pointer
|
||||
// capture requires a real pointer id). Every other pane operation here is
|
||||
|
|
@ -282,775 +280,6 @@ test.describe('Terminal Panes', () => {
|
|||
.toBe(true)
|
||||
})
|
||||
|
||||
/**
|
||||
* Regression test: dragging a vertical divider to resize split panes must
|
||||
* preserve scroll position in partially-scrolled terminals. The bug manifests
|
||||
* as terminals jumping to the top (or near-top) during/after drag.
|
||||
*
|
||||
* Why headful: pointer capture requires a real pointer ID from a visible window.
|
||||
*/
|
||||
test('@headful scroll position preserved during divider drag resize', async ({ orcaPage }) => {
|
||||
await splitActiveTerminalPane(orcaPage, 'vertical')
|
||||
await waitForPaneCount(orcaPage, 2)
|
||||
|
||||
const ptyId = await discoverActivePtyId(orcaPage)
|
||||
// Why: long lines that wrap are essential to reproduce the bug. Short
|
||||
// lines (like `seq 1 5000`) don't wrap, so baseY stays constant during
|
||||
// reflow and scroll preservation is trivially correct. Claude Code output
|
||||
// contains long formatted lines that reflow dramatically on column changes.
|
||||
// Simulate Claude Code-like output: ANSI-formatted text with unicode
|
||||
// box-drawing characters and long wrapped lines, similar to what Claude
|
||||
// Code renders in a real session.
|
||||
await execInTerminal(
|
||||
orcaPage,
|
||||
ptyId,
|
||||
`python3 -c "
|
||||
import sys
|
||||
# Banner like Claude Code
|
||||
print('\\033[1;36m ▐▛███▜▌ Claude Code v2.1.10\\033[0m')
|
||||
print('\\033[90m' + '─' * 200 + '\\033[0m')
|
||||
# Simulated conversation with long wrapped lines
|
||||
for i in range(500):
|
||||
if i % 10 == 0:
|
||||
print(f'\\033[1;32m❯ User message {i//10}:\\033[0m')
|
||||
print(' ' + 'Here is a long user prompt that wraps across multiple lines. ' * 4)
|
||||
else:
|
||||
# Code block with indentation
|
||||
print(f'\\033[90m {i:4d} │\\033[0m ' + 'const result = await fetch(url).then(r => r.json()).catch(err => console.error(err)); // ' + 'x' * 80)
|
||||
print('\\033[90m' + '─' * 200 + '\\033[0m')
|
||||
print('DONE_MARKER')
|
||||
"`
|
||||
)
|
||||
await waitForTerminalOutput(orcaPage, 'DONE_MARKER', 15_000)
|
||||
await orcaPage.waitForTimeout(500)
|
||||
|
||||
// Scroll to ~middle of scrollback
|
||||
const scrollBefore = await orcaPage.evaluate(() => {
|
||||
const tabId = (() => {
|
||||
const store = window.__store
|
||||
if (!store) return null
|
||||
const state = store.getState()
|
||||
const wId = state.activeWorktreeId
|
||||
if (!wId) return null
|
||||
return (state.tabsByWorktree[wId] ?? [])[0]?.id ?? null
|
||||
})()
|
||||
if (!tabId) return null
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
if (!manager) return null
|
||||
const pane = manager.getActivePane()
|
||||
if (!pane) return null
|
||||
const buf = pane.terminal.buffer.active
|
||||
const targetLine = Math.floor(buf.baseY / 2)
|
||||
pane.terminal.scrollToLine(targetLine)
|
||||
return {
|
||||
viewportY: pane.terminal.buffer.active.viewportY,
|
||||
baseY: buf.baseY,
|
||||
paneId: pane.id,
|
||||
cols: pane.terminal.cols
|
||||
}
|
||||
})
|
||||
expect(scrollBefore).not.toBeNull()
|
||||
expect(scrollBefore!.viewportY).toBeGreaterThan(0)
|
||||
|
||||
// Capture renderer console logs for diagnostics
|
||||
const consoleLogs: string[] = []
|
||||
orcaPage.on('console', (msg) => {
|
||||
const text = msg.text()
|
||||
if (text.includes('[restoreScrollState]') || text.includes('[scrollToLineSync]')) {
|
||||
consoleLogs.push(text)
|
||||
}
|
||||
})
|
||||
|
||||
// Drag the divider LEFT then RIGHT then LEFT — simulating the user's
|
||||
// "adjust right and left" gesture. Each direction change causes reflow.
|
||||
const divider = orcaPage.locator('.pane-divider.is-vertical').first()
|
||||
await expect(divider).toBeVisible({ timeout: 3_000 })
|
||||
const box = await divider.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
const startX = box!.x + box!.width / 2
|
||||
const startY = box!.y + box!.height / 2
|
||||
await orcaPage.mouse.move(startX, startY)
|
||||
await orcaPage.mouse.down()
|
||||
// Drag left (narrowing), then right (widening), then left again
|
||||
await orcaPage.mouse.move(startX - 250, startY, { steps: 20 })
|
||||
await orcaPage.mouse.move(startX + 150, startY, { steps: 20 })
|
||||
await orcaPage.mouse.move(startX - 200, startY, { steps: 20 })
|
||||
await orcaPage.mouse.up()
|
||||
await orcaPage.waitForTimeout(500)
|
||||
|
||||
const scrollAfter = await orcaPage.evaluate(
|
||||
({ paneId }) => {
|
||||
const tabId = (() => {
|
||||
const store = window.__store
|
||||
if (!store) return null
|
||||
const state = store.getState()
|
||||
const wId = state.activeWorktreeId
|
||||
if (!wId) return null
|
||||
return (state.tabsByWorktree[wId] ?? [])[0]?.id ?? null
|
||||
})()
|
||||
if (!tabId) return null
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
if (!manager) return null
|
||||
const panes = manager.getPanes() ?? []
|
||||
const pane = panes.find((p: { id: number }) => p.id === paneId)
|
||||
if (!pane) return null
|
||||
return {
|
||||
viewportY: pane.terminal.buffer.active.viewportY,
|
||||
baseY: pane.terminal.buffer.active.baseY,
|
||||
cols: pane.terminal.cols
|
||||
}
|
||||
},
|
||||
{ paneId: scrollBefore!.paneId }
|
||||
)
|
||||
expect(scrollAfter).not.toBeNull()
|
||||
const drift = Math.abs(scrollAfter!.viewportY - scrollBefore!.viewportY)
|
||||
// Use proportional position (viewportY / baseY) since absolute line
|
||||
// numbers change legitimately during reflow (more/less wrapping).
|
||||
const ratioBefore = scrollBefore!.viewportY / scrollBefore!.baseY
|
||||
const ratioAfter = scrollAfter!.baseY > 0 ? scrollAfter!.viewportY / scrollAfter!.baseY : 0
|
||||
const proportionalDrift = Math.abs(ratioAfter - ratioBefore)
|
||||
console.log(
|
||||
'[scroll-test] wrapping-content:',
|
||||
JSON.stringify({
|
||||
before: scrollBefore!.viewportY,
|
||||
after: scrollAfter!.viewportY,
|
||||
absoluteDrift: drift,
|
||||
ratioBefore: ratioBefore.toFixed(4),
|
||||
ratioAfter: ratioAfter.toFixed(4),
|
||||
proportionalDrift: proportionalDrift.toFixed(4),
|
||||
baseYBefore: scrollBefore!.baseY,
|
||||
baseYAfter: scrollAfter!.baseY,
|
||||
colsBefore: scrollBefore!.cols,
|
||||
colsAfter: scrollAfter!.cols
|
||||
})
|
||||
)
|
||||
// Proportional drift should be <5% — the same relative content should
|
||||
// be visible regardless of how wrapping changed the absolute line count.
|
||||
expect(proportionalDrift).toBeLessThanOrEqual(0.05)
|
||||
// Must not be at the very top
|
||||
expect(scrollAfter!.viewportY).toBeGreaterThan(scrollAfter!.baseY * 0.1)
|
||||
|
||||
// Check for Scrollable desync: do a small mouse wheel scroll and verify
|
||||
// the viewport doesn't jump to a completely different position.
|
||||
const paneEl = orcaPage.locator(`.pane[data-pane-id="${scrollBefore!.paneId}"]`).first()
|
||||
const paneBox = await paneEl.boundingBox()
|
||||
if (paneBox) {
|
||||
await orcaPage.mouse.move(paneBox.x + paneBox.width / 2, paneBox.y + paneBox.height / 2)
|
||||
await orcaPage.mouse.wheel(0, 50) // scroll down slightly
|
||||
await orcaPage.waitForTimeout(200)
|
||||
|
||||
const scrollAfterWheel = await orcaPage.evaluate(
|
||||
({ paneId }) => {
|
||||
const tabId = (() => {
|
||||
const store = window.__store
|
||||
if (!store) return null
|
||||
const state = store.getState()
|
||||
const wId = state.activeWorktreeId
|
||||
if (!wId) return null
|
||||
return (state.tabsByWorktree[wId] ?? [])[0]?.id ?? null
|
||||
})()
|
||||
if (!tabId) return null
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
if (!manager) return null
|
||||
const panes = manager.getPanes() ?? []
|
||||
const pane = panes.find((p: { id: number }) => p.id === paneId)
|
||||
if (!pane) return null
|
||||
return {
|
||||
viewportY: pane.terminal.buffer.active.viewportY,
|
||||
baseY: pane.terminal.buffer.active.baseY
|
||||
}
|
||||
},
|
||||
{ paneId: scrollBefore!.paneId }
|
||||
)
|
||||
if (scrollAfterWheel) {
|
||||
const wheelJump = Math.abs(scrollAfterWheel.viewportY - scrollAfter!.viewportY)
|
||||
const ratioAfterWheel =
|
||||
scrollAfterWheel.baseY > 0 ? scrollAfterWheel.viewportY / scrollAfterWheel.baseY : 0
|
||||
console.log(
|
||||
'[scroll-test] post-wheel:',
|
||||
JSON.stringify({
|
||||
viewportYAfterDrag: scrollAfter!.viewportY,
|
||||
viewportYAfterWheel: scrollAfterWheel.viewportY,
|
||||
wheelJump,
|
||||
ratioAfterWheel: ratioAfterWheel.toFixed(4)
|
||||
})
|
||||
)
|
||||
// A small wheel scroll should move by a few lines, not jump hundreds
|
||||
expect(wheelJump).toBeLessThan(scrollAfterWheel.baseY * 0.05)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('@headful scroll position preserved in triple split layout', async ({ orcaPage }) => {
|
||||
// Reproduce user's exact layout: triple split (top-left has content,
|
||||
// bottom-left and right are empty)
|
||||
await splitActiveTerminalPane(orcaPage, 'vertical') // left | right
|
||||
await waitForPaneCount(orcaPage, 2)
|
||||
await splitActiveTerminalPane(orcaPage, 'horizontal') // top-left / bottom-left | right
|
||||
await waitForPaneCount(orcaPage, 3)
|
||||
|
||||
// Focus the first pane (top-left) and fill with wrapping content
|
||||
const ptyId = await discoverActivePtyId(orcaPage)
|
||||
await execInTerminal(
|
||||
orcaPage,
|
||||
ptyId,
|
||||
'python3 -c "import string; s=string.ascii_letters+string.digits; [print(f\'LINE_{i:04d} \' + (s*8)[:500]) for i in range(1000)]"'
|
||||
)
|
||||
await waitForTerminalOutput(orcaPage, 'LINE_0999', 15_000)
|
||||
await orcaPage.waitForTimeout(500)
|
||||
|
||||
const scrollBefore = await orcaPage.evaluate(() => {
|
||||
const tabId = (() => {
|
||||
const store = window.__store
|
||||
if (!store) return null
|
||||
const state = store.getState()
|
||||
const wId = state.activeWorktreeId
|
||||
if (!wId) return null
|
||||
return (state.tabsByWorktree[wId] ?? [])[0]?.id ?? null
|
||||
})()
|
||||
if (!tabId) return null
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
if (!manager) return null
|
||||
const pane = manager.getActivePane()
|
||||
if (!pane) return null
|
||||
const buf = pane.terminal.buffer.active
|
||||
const targetLine = Math.floor(buf.baseY / 2)
|
||||
pane.terminal.scrollToLine(targetLine)
|
||||
return {
|
||||
viewportY: pane.terminal.buffer.active.viewportY,
|
||||
baseY: buf.baseY,
|
||||
paneId: pane.id,
|
||||
cols: pane.terminal.cols
|
||||
}
|
||||
})
|
||||
expect(scrollBefore).not.toBeNull()
|
||||
expect(scrollBefore!.viewportY).toBeGreaterThan(0)
|
||||
|
||||
const consoleLogs: string[] = []
|
||||
orcaPage.on('console', (msg) => {
|
||||
const text = msg.text()
|
||||
if (text.includes('[restoreScrollState]') || text.includes('[scrollToLineSync]')) {
|
||||
consoleLogs.push(text)
|
||||
}
|
||||
})
|
||||
|
||||
// Drag the HORIZONTAL divider (between top-left and bottom-left) UP
|
||||
// to change the height of the content pane — this changes rows and
|
||||
// triggers reflow in a triple-split layout.
|
||||
// Also try the vertical divider to change cols.
|
||||
const divider = orcaPage.locator('.pane-divider.is-vertical').first()
|
||||
await expect(divider).toBeVisible({ timeout: 3_000 })
|
||||
const box = await divider.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
const startX = box!.x + box!.width / 2
|
||||
const startY = box!.y + box!.height / 2
|
||||
await orcaPage.mouse.move(startX, startY)
|
||||
await orcaPage.mouse.down()
|
||||
// Drag RIGHT to widen left panes (more cols, less wrapping)
|
||||
await orcaPage.mouse.move(startX + 200, startY, { steps: 30 })
|
||||
await orcaPage.mouse.up()
|
||||
await orcaPage.waitForTimeout(500)
|
||||
|
||||
const scrollAfter = await orcaPage.evaluate(
|
||||
({ paneId }) => {
|
||||
const tabId = (() => {
|
||||
const store = window.__store
|
||||
if (!store) return null
|
||||
const state = store.getState()
|
||||
const wId = state.activeWorktreeId
|
||||
if (!wId) return null
|
||||
return (state.tabsByWorktree[wId] ?? [])[0]?.id ?? null
|
||||
})()
|
||||
if (!tabId) return null
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
if (!manager) return null
|
||||
const panes = manager.getPanes() ?? []
|
||||
const pane = panes.find((p: { id: number }) => p.id === paneId)
|
||||
if (!pane) return null
|
||||
return {
|
||||
viewportY: pane.terminal.buffer.active.viewportY,
|
||||
baseY: pane.terminal.buffer.active.baseY,
|
||||
cols: pane.terminal.cols
|
||||
}
|
||||
},
|
||||
{ paneId: scrollBefore!.paneId }
|
||||
)
|
||||
expect(scrollAfter).not.toBeNull()
|
||||
|
||||
const ratioBefore = scrollBefore!.viewportY / scrollBefore!.baseY
|
||||
const ratioAfter = scrollAfter!.baseY > 0 ? scrollAfter!.viewportY / scrollAfter!.baseY : 0
|
||||
const proportionalDrift = Math.abs(ratioAfter - ratioBefore)
|
||||
console.log(
|
||||
'[scroll-test] triple-split:',
|
||||
JSON.stringify({
|
||||
before: scrollBefore!.viewportY,
|
||||
after: scrollAfter!.viewportY,
|
||||
ratioBefore: ratioBefore.toFixed(4),
|
||||
ratioAfter: ratioAfter.toFixed(4),
|
||||
proportionalDrift: proportionalDrift.toFixed(4),
|
||||
baseYBefore: scrollBefore!.baseY,
|
||||
baseYAfter: scrollAfter!.baseY,
|
||||
colsBefore: scrollBefore!.cols,
|
||||
colsAfter: scrollAfter!.cols
|
||||
})
|
||||
)
|
||||
|
||||
// Check for Scrollable desync via mouse wheel
|
||||
const paneEl = orcaPage.locator(`.pane[data-pane-id="${scrollBefore!.paneId}"]`).first()
|
||||
const paneBox = await paneEl.boundingBox()
|
||||
if (paneBox) {
|
||||
await orcaPage.mouse.move(paneBox.x + paneBox.width / 2, paneBox.y + paneBox.height / 2)
|
||||
await orcaPage.mouse.wheel(0, 50)
|
||||
await orcaPage.waitForTimeout(200)
|
||||
|
||||
const scrollAfterWheel = await orcaPage.evaluate(
|
||||
({ paneId }) => {
|
||||
const tabId = (() => {
|
||||
const store = window.__store
|
||||
if (!store) return null
|
||||
const state = store.getState()
|
||||
const wId = state.activeWorktreeId
|
||||
if (!wId) return null
|
||||
return (state.tabsByWorktree[wId] ?? [])[0]?.id ?? null
|
||||
})()
|
||||
if (!tabId) return null
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
if (!manager) return null
|
||||
const panes = manager.getPanes() ?? []
|
||||
const pane = panes.find((p: { id: number }) => p.id === paneId)
|
||||
if (!pane) return null
|
||||
return {
|
||||
viewportY: pane.terminal.buffer.active.viewportY,
|
||||
baseY: pane.terminal.buffer.active.baseY
|
||||
}
|
||||
},
|
||||
{ paneId: scrollBefore!.paneId }
|
||||
)
|
||||
if (scrollAfterWheel) {
|
||||
const wheelJump = Math.abs(scrollAfterWheel.viewportY - scrollAfter!.viewportY)
|
||||
console.log(
|
||||
'[scroll-test] triple-split-post-wheel:',
|
||||
JSON.stringify({
|
||||
viewportYAfterDrag: scrollAfter!.viewportY,
|
||||
viewportYAfterWheel: scrollAfterWheel.viewportY,
|
||||
wheelJump
|
||||
})
|
||||
)
|
||||
expect(wheelJump).toBeLessThan(scrollAfterWheel.baseY * 0.05)
|
||||
}
|
||||
}
|
||||
|
||||
expect(proportionalDrift).toBeLessThanOrEqual(0.05)
|
||||
expect(scrollAfter!.viewportY).toBeGreaterThan(scrollAfter!.baseY * 0.1)
|
||||
})
|
||||
|
||||
test('@headful scroll position preserved during drag with active terminal writes', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
await splitActiveTerminalPane(orcaPage, 'vertical')
|
||||
await waitForPaneCount(orcaPage, 2)
|
||||
|
||||
const ptyId = await discoverActivePtyId(orcaPage)
|
||||
await execInTerminal(orcaPage, ptyId, 'seq 1 5000')
|
||||
await waitForTerminalOutput(orcaPage, '5000')
|
||||
await orcaPage.waitForTimeout(300)
|
||||
|
||||
const scrollBefore = await orcaPage.evaluate(() => {
|
||||
const tabId = (() => {
|
||||
const store = window.__store
|
||||
if (!store) return null
|
||||
const state = store.getState()
|
||||
const wId = state.activeWorktreeId
|
||||
if (!wId) return null
|
||||
return (state.tabsByWorktree[wId] ?? [])[0]?.id ?? null
|
||||
})()
|
||||
if (!tabId) return null
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
if (!manager) return null
|
||||
const pane = manager.getActivePane()
|
||||
if (!pane) return null
|
||||
const buf = pane.terminal.buffer.active
|
||||
const targetLine = Math.floor(buf.baseY / 2)
|
||||
pane.terminal.scrollToLine(targetLine)
|
||||
return {
|
||||
viewportY: pane.terminal.buffer.active.viewportY,
|
||||
baseY: buf.baseY,
|
||||
paneId: pane.id,
|
||||
cols: pane.terminal.cols
|
||||
}
|
||||
})
|
||||
expect(scrollBefore).not.toBeNull()
|
||||
expect(scrollBefore!.viewportY).toBeGreaterThan(0)
|
||||
|
||||
// Start a background loop that writes to the terminal during drag,
|
||||
// simulating what Claude Code does when it redraws on SIGWINCH
|
||||
await execInTerminal(
|
||||
orcaPage,
|
||||
ptyId,
|
||||
'while true; do echo "REDRAW $(date +%s%N)"; sleep 0.05; done &'
|
||||
)
|
||||
await orcaPage.waitForTimeout(200)
|
||||
// Re-scroll after the writes started (they push to bottom)
|
||||
await orcaPage.evaluate(
|
||||
({ paneId, targetViewportY }) => {
|
||||
const tabId = (() => {
|
||||
const store = window.__store
|
||||
if (!store) return null
|
||||
const state = store.getState()
|
||||
const wId = state.activeWorktreeId
|
||||
if (!wId) return null
|
||||
return (state.tabsByWorktree[wId] ?? [])[0]?.id ?? null
|
||||
})()
|
||||
if (!tabId) return
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
if (!manager) return
|
||||
const panes = manager.getPanes() ?? []
|
||||
const pane = panes.find((p: { id: number }) => p.id === paneId)
|
||||
if (!pane) return
|
||||
pane.terminal.scrollToLine(targetViewportY)
|
||||
},
|
||||
{ paneId: scrollBefore!.paneId, targetViewportY: scrollBefore!.viewportY }
|
||||
)
|
||||
await orcaPage.waitForTimeout(100)
|
||||
|
||||
const divider = orcaPage.locator('.pane-divider.is-vertical').first()
|
||||
await expect(divider).toBeVisible({ timeout: 3_000 })
|
||||
const box = await divider.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
const startX = box!.x + box!.width / 2
|
||||
const startY = box!.y + box!.height / 2
|
||||
await orcaPage.mouse.move(startX, startY)
|
||||
await orcaPage.mouse.down()
|
||||
await orcaPage.mouse.move(startX + 100, startY, { steps: 15 })
|
||||
await orcaPage.mouse.up()
|
||||
|
||||
// Kill the background writer
|
||||
await execInTerminal(orcaPage, ptyId, 'kill %1 2>/dev/null; true')
|
||||
await orcaPage.waitForTimeout(500)
|
||||
|
||||
const scrollAfter = await orcaPage.evaluate(
|
||||
({ paneId }) => {
|
||||
const tabId = (() => {
|
||||
const store = window.__store
|
||||
if (!store) return null
|
||||
const state = store.getState()
|
||||
const wId = state.activeWorktreeId
|
||||
if (!wId) return null
|
||||
return (state.tabsByWorktree[wId] ?? [])[0]?.id ?? null
|
||||
})()
|
||||
if (!tabId) return null
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
if (!manager) return null
|
||||
const panes = manager.getPanes() ?? []
|
||||
const pane = panes.find((p: { id: number }) => p.id === paneId)
|
||||
if (!pane) return null
|
||||
return {
|
||||
viewportY: pane.terminal.buffer.active.viewportY,
|
||||
baseY: pane.terminal.buffer.active.baseY,
|
||||
cols: pane.terminal.cols
|
||||
}
|
||||
},
|
||||
{ paneId: scrollBefore!.paneId }
|
||||
)
|
||||
expect(scrollAfter).not.toBeNull()
|
||||
const drift = Math.abs(scrollAfter!.viewportY - scrollBefore!.viewportY)
|
||||
console.log('[scroll-test] active-writes:', {
|
||||
before: scrollBefore!.viewportY,
|
||||
after: scrollAfter!.viewportY,
|
||||
drift,
|
||||
baseYBefore: scrollBefore!.baseY,
|
||||
baseYAfter: scrollAfter!.baseY,
|
||||
colsBefore: scrollBefore!.cols,
|
||||
colsAfter: scrollAfter!.cols
|
||||
})
|
||||
// With active writes, allow more tolerance but must not jump to top
|
||||
const maxDrift = scrollBefore!.baseY * 0.1
|
||||
expect(drift).toBeLessThanOrEqual(maxDrift)
|
||||
expect(scrollAfter!.viewportY).toBeGreaterThan(scrollBefore!.baseY * 0.1)
|
||||
})
|
||||
|
||||
test('@headful scroll position preserved during drag with TUI-style redraws', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
await splitActiveTerminalPane(orcaPage, 'vertical')
|
||||
await waitForPaneCount(orcaPage, 2)
|
||||
|
||||
const ptyId = await discoverActivePtyId(orcaPage)
|
||||
await execInTerminal(orcaPage, ptyId, 'seq 1 5000')
|
||||
await waitForTerminalOutput(orcaPage, '5000')
|
||||
await orcaPage.waitForTimeout(300)
|
||||
|
||||
const scrollBefore = await orcaPage.evaluate(() => {
|
||||
const tabId = (() => {
|
||||
const store = window.__store
|
||||
if (!store) return null
|
||||
const state = store.getState()
|
||||
const wId = state.activeWorktreeId
|
||||
if (!wId) return null
|
||||
return (state.tabsByWorktree[wId] ?? [])[0]?.id ?? null
|
||||
})()
|
||||
if (!tabId) return null
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
if (!manager) return null
|
||||
const pane = manager.getActivePane()
|
||||
if (!pane) return null
|
||||
const buf = pane.terminal.buffer.active
|
||||
const targetLine = Math.floor(buf.baseY / 2)
|
||||
pane.terminal.scrollToLine(targetLine)
|
||||
return {
|
||||
viewportY: pane.terminal.buffer.active.viewportY,
|
||||
baseY: buf.baseY,
|
||||
paneId: pane.id,
|
||||
cols: pane.terminal.cols
|
||||
}
|
||||
})
|
||||
expect(scrollBefore).not.toBeNull()
|
||||
expect(scrollBefore!.viewportY).toBeGreaterThan(0)
|
||||
|
||||
// Simulate TUI redraw: a SIGWINCH-responsive script that clears the
|
||||
// last few lines and rewrites them — like Claude Code's status bar.
|
||||
// This uses cursor movement + clear-to-end escape sequences.
|
||||
await execInTerminal(
|
||||
orcaPage,
|
||||
ptyId,
|
||||
String.raw`trap 'printf "\033[s\033[999;1H\033[2K--- RESIZE %dx%d ---\033[u" $COLUMNS $LINES' WINCH; while true; do sleep 0.1; done &`
|
||||
)
|
||||
await orcaPage.waitForTimeout(200)
|
||||
// Re-scroll after trap setup
|
||||
await orcaPage.evaluate(
|
||||
({ paneId, targetViewportY }) => {
|
||||
const tabId = (() => {
|
||||
const store = window.__store
|
||||
if (!store) return null
|
||||
const state = store.getState()
|
||||
const wId = state.activeWorktreeId
|
||||
if (!wId) return null
|
||||
return (state.tabsByWorktree[wId] ?? [])[0]?.id ?? null
|
||||
})()
|
||||
if (!tabId) return
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
if (!manager) return
|
||||
const panes = manager.getPanes() ?? []
|
||||
const pane = panes.find((p: { id: number }) => p.id === paneId)
|
||||
if (!pane) return
|
||||
pane.terminal.scrollToLine(targetViewportY)
|
||||
},
|
||||
{ paneId: scrollBefore!.paneId, targetViewportY: scrollBefore!.viewportY }
|
||||
)
|
||||
await orcaPage.waitForTimeout(100)
|
||||
|
||||
const divider = orcaPage.locator('.pane-divider.is-vertical').first()
|
||||
await expect(divider).toBeVisible({ timeout: 3_000 })
|
||||
const box = await divider.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
const startX = box!.x + box!.width / 2
|
||||
const startY = box!.y + box!.height / 2
|
||||
await orcaPage.mouse.move(startX, startY)
|
||||
await orcaPage.mouse.down()
|
||||
// Slower drag with more steps to trigger multiple SIGWINCHes
|
||||
await orcaPage.mouse.move(startX + 150, startY, { steps: 30 })
|
||||
await orcaPage.mouse.up()
|
||||
|
||||
await execInTerminal(orcaPage, ptyId, 'kill %1 2>/dev/null; true')
|
||||
await orcaPage.waitForTimeout(500)
|
||||
|
||||
const scrollAfter = await orcaPage.evaluate(
|
||||
({ paneId }) => {
|
||||
const tabId = (() => {
|
||||
const store = window.__store
|
||||
if (!store) return null
|
||||
const state = store.getState()
|
||||
const wId = state.activeWorktreeId
|
||||
if (!wId) return null
|
||||
return (state.tabsByWorktree[wId] ?? [])[0]?.id ?? null
|
||||
})()
|
||||
if (!tabId) return null
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
if (!manager) return null
|
||||
const panes = manager.getPanes() ?? []
|
||||
const pane = panes.find((p: { id: number }) => p.id === paneId)
|
||||
if (!pane) return null
|
||||
return {
|
||||
viewportY: pane.terminal.buffer.active.viewportY,
|
||||
baseY: pane.terminal.buffer.active.baseY,
|
||||
cols: pane.terminal.cols
|
||||
}
|
||||
},
|
||||
{ paneId: scrollBefore!.paneId }
|
||||
)
|
||||
expect(scrollAfter).not.toBeNull()
|
||||
const drift = Math.abs(scrollAfter!.viewportY - scrollBefore!.viewportY)
|
||||
console.log('[scroll-test] tui-redraw:', {
|
||||
before: scrollBefore!.viewportY,
|
||||
after: scrollAfter!.viewportY,
|
||||
drift,
|
||||
baseYBefore: scrollBefore!.baseY,
|
||||
baseYAfter: scrollAfter!.baseY,
|
||||
colsBefore: scrollBefore!.cols,
|
||||
colsAfter: scrollAfter!.cols
|
||||
})
|
||||
const maxDrift = scrollBefore!.baseY * 0.1
|
||||
expect(drift).toBeLessThanOrEqual(maxDrift)
|
||||
expect(scrollAfter!.viewportY).toBeGreaterThan(scrollBefore!.baseY * 0.1)
|
||||
})
|
||||
|
||||
/**
|
||||
* Regression test for the SIGWINCH scroll corruption bug.
|
||||
*
|
||||
* Uses an Ink-like TUI simulator that reproduces the exact rendering
|
||||
* pattern that caused Claude Code sessions to lose scroll position during
|
||||
* divider drag resize: cursor-up + clear-to-end-of-display + rewrite.
|
||||
*
|
||||
* The bug: drag resize → PTY resize → SIGWINCH → TUI redraws → viewportY
|
||||
* corrupted → ResizeObserver captures corrupted state → scroll jumps to 0.
|
||||
*
|
||||
* Why headful: pointer capture requires a real pointer ID from a visible window.
|
||||
*/
|
||||
test('@headful scroll position preserved during drag with Ink-style TUI redraws', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
await splitActiveTerminalPane(orcaPage, 'vertical')
|
||||
await waitForPaneCount(orcaPage, 2)
|
||||
|
||||
const ptyId = await discoverActivePtyId(orcaPage)
|
||||
|
||||
// Generate scrollback content with long wrapping lines
|
||||
await execInTerminal(
|
||||
orcaPage,
|
||||
ptyId,
|
||||
[
|
||||
'python3 -c "',
|
||||
'import string, sys',
|
||||
'chars = string.ascii_letters + string.digits',
|
||||
'for i in range(300):',
|
||||
" line = f\\'LINE{i:04d} \\' + (chars * 3)[:200]",
|
||||
' print(line)',
|
||||
'sys.stdout.flush()',
|
||||
'"'
|
||||
].join('\n')
|
||||
)
|
||||
await waitForTerminalOutput(orcaPage, 'LINE0299', 15_000)
|
||||
await orcaPage.waitForTimeout(500)
|
||||
|
||||
// Start the Ink TUI simulator (redraws on SIGWINCH like Claude Code)
|
||||
const fixturePath = path.resolve('tests/e2e/fixtures/ink-tui-sim.mjs')
|
||||
await execInTerminal(orcaPage, ptyId, `node ${fixturePath} &`)
|
||||
await orcaPage.waitForTimeout(1000)
|
||||
|
||||
// Scroll to ~middle of scrollback
|
||||
const scrollBefore = await orcaPage.evaluate(() => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
return null
|
||||
}
|
||||
const state = store.getState()
|
||||
const wId = state.activeWorktreeId
|
||||
if (!wId) {
|
||||
return null
|
||||
}
|
||||
const tabId = (state.tabsByWorktree[wId] ?? [])[0]?.id ?? null
|
||||
if (!tabId) {
|
||||
return null
|
||||
}
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
if (!manager) {
|
||||
return null
|
||||
}
|
||||
const pane = manager.getActivePane()
|
||||
if (!pane) {
|
||||
return null
|
||||
}
|
||||
const buf = pane.terminal.buffer.active
|
||||
const targetLine = Math.floor(buf.baseY / 2)
|
||||
pane.terminal.scrollToLine(targetLine)
|
||||
return {
|
||||
viewportY: pane.terminal.buffer.active.viewportY,
|
||||
baseY: buf.baseY,
|
||||
paneId: pane.id,
|
||||
cols: pane.terminal.cols
|
||||
}
|
||||
})
|
||||
expect(scrollBefore).not.toBeNull()
|
||||
expect(scrollBefore!.viewportY).toBeGreaterThan(0)
|
||||
await orcaPage.waitForTimeout(200)
|
||||
|
||||
// Drag the divider — each step triggers fit() → onResize. The PTY resize
|
||||
// is suppressed during drag but flushed at drag end, sending SIGWINCH to
|
||||
// the Ink TUI simulator which does cursor-up + clear + rewrite.
|
||||
const divider = orcaPage.locator('.pane-divider.is-vertical').first()
|
||||
await expect(divider).toBeVisible({ timeout: 3_000 })
|
||||
const box = await divider.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
const startX = box!.x + box!.width / 2
|
||||
const startY = box!.y + box!.height / 2
|
||||
await orcaPage.mouse.move(startX, startY)
|
||||
await orcaPage.mouse.down()
|
||||
await orcaPage.mouse.move(startX + 150, startY, { steps: 20 })
|
||||
await orcaPage.mouse.up()
|
||||
|
||||
// Wait for the SIGWINCH settling period (500ms) + buffer
|
||||
await orcaPage.waitForTimeout(1000)
|
||||
|
||||
const scrollAfter = await orcaPage.evaluate(
|
||||
({ paneId }) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
return null
|
||||
}
|
||||
const state = store.getState()
|
||||
const wId = state.activeWorktreeId
|
||||
if (!wId) {
|
||||
return null
|
||||
}
|
||||
const tabId = (state.tabsByWorktree[wId] ?? [])[0]?.id ?? null
|
||||
if (!tabId) {
|
||||
return null
|
||||
}
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
if (!manager) {
|
||||
return null
|
||||
}
|
||||
const panes = manager.getPanes() ?? []
|
||||
const pane = panes.find((p: { id: number }) => p.id === paneId)
|
||||
if (!pane) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
viewportY: pane.terminal.buffer.active.viewportY,
|
||||
baseY: pane.terminal.buffer.active.baseY,
|
||||
cols: pane.terminal.cols
|
||||
}
|
||||
},
|
||||
{ paneId: scrollBefore!.paneId }
|
||||
)
|
||||
expect(scrollAfter).not.toBeNull()
|
||||
|
||||
// Kill the background TUI simulator
|
||||
await execInTerminal(orcaPage, ptyId, 'kill %1 2>/dev/null; true')
|
||||
await orcaPage.waitForTimeout(300)
|
||||
|
||||
const ratioBefore = scrollBefore!.viewportY / scrollBefore!.baseY
|
||||
const ratioAfter = scrollAfter!.baseY > 0 ? scrollAfter!.viewportY / scrollAfter!.baseY : 0
|
||||
const proportionalDrift = Math.abs(ratioAfter - ratioBefore)
|
||||
console.log(
|
||||
'[scroll-test] ink-tui-sim:',
|
||||
JSON.stringify({
|
||||
before: scrollBefore!.viewportY,
|
||||
after: scrollAfter!.viewportY,
|
||||
ratioBefore: ratioBefore.toFixed(4),
|
||||
ratioAfter: ratioAfter.toFixed(4),
|
||||
proportionalDrift: proportionalDrift.toFixed(4),
|
||||
baseYBefore: scrollBefore!.baseY,
|
||||
baseYAfter: scrollAfter!.baseY,
|
||||
colsBefore: scrollBefore!.cols,
|
||||
colsAfter: scrollAfter!.cols
|
||||
})
|
||||
)
|
||||
|
||||
expect(proportionalDrift).toBeLessThanOrEqual(0.05)
|
||||
expect(scrollAfter!.viewportY).toBeGreaterThan(scrollAfter!.baseY * 0.1)
|
||||
})
|
||||
|
||||
/**
|
||||
* User Prompt:
|
||||
* - closing panes works
|
||||
|
|
|
|||
Loading…
Reference in New Issue