fix(terminal): make viewport ownership transactional across output, replay, and reflow (#8674)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
1ba71d2cee
commit
69c14fadce
|
|
@ -6205,11 +6205,7 @@ describe('registerPtyHandlers', () => {
|
|||
paneKey
|
||||
})) as number
|
||||
await spawn()
|
||||
const ready = spawnController.waitForRendererSerializer?.(
|
||||
reusedPtyId,
|
||||
priorGeneration,
|
||||
1_000
|
||||
)
|
||||
const ready = spawnController.waitForRendererSerializer?.(reusedPtyId, priorGeneration, 1_000)
|
||||
clearProviderPtyState(reusedPtyId)
|
||||
clearProviderPtyState(reusedPtyId)
|
||||
await handlers.get('pty:settlePaneSerializer')!(null, { paneKey, gen: secondGen })
|
||||
|
|
|
|||
|
|
@ -19311,13 +19311,10 @@ export class OrcaRuntimeService {
|
|||
timeoutMs?: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<boolean> {
|
||||
return this.ptyController?.waitForRendererSerializer?.(
|
||||
ptyId,
|
||||
afterGeneration,
|
||||
timeoutMs,
|
||||
signal
|
||||
) ??
|
||||
return (
|
||||
this.ptyController?.waitForRendererSerializer?.(ptyId, afterGeneration, timeoutMs, signal) ??
|
||||
Promise.resolve(false)
|
||||
)
|
||||
}
|
||||
|
||||
// Why: a leaf appears in the graph before its PTY spawns. If we issue a
|
||||
|
|
|
|||
|
|
@ -1411,7 +1411,13 @@ export type PreloadApi = {
|
|||
onClearBufferRequest: (callback: (data: { ptyId: string }) => void) => () => void
|
||||
sendSerializedBuffer: (
|
||||
requestId: string,
|
||||
snapshot: { data: string; cols: number; rows: number; seq?: number; lastTitle?: string } | null
|
||||
snapshot: {
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq?: number
|
||||
lastTitle?: string
|
||||
} | null
|
||||
) => void
|
||||
declarePendingPaneSerializer: (paneKey: string) => Promise<number>
|
||||
settlePaneSerializer: (paneKey: string, gen: number) => Promise<void>
|
||||
|
|
|
|||
|
|
@ -1142,7 +1142,13 @@ const api = {
|
|||
|
||||
sendSerializedBuffer: (
|
||||
requestId: string,
|
||||
snapshot: { data: string; cols: number; rows: number; seq?: number; lastTitle?: string } | null
|
||||
snapshot: {
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq?: number
|
||||
lastTitle?: string
|
||||
} | null
|
||||
): void => {
|
||||
ipcRenderer.send('pty:serializeBuffer:response', { requestId, snapshot })
|
||||
},
|
||||
|
|
|
|||
|
|
@ -107,7 +107,9 @@ import {
|
|||
} from '../native-chat/native-chat-leaf-routing'
|
||||
import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability'
|
||||
import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution'
|
||||
import { safeFit } from '@/lib/pane-manager/pane-tree-ops'
|
||||
import { safeFit, safeFitAndThen } from '@/lib/pane-manager/pane-tree-ops'
|
||||
import { applyDesktopFitFallbackAfterReplay } from './desktop-fit-fallback'
|
||||
import { clearTerminalScrollbackAndFollowOutput } from '@/lib/pane-manager/terminal-scrollback-clear'
|
||||
import { captureTerminalShutdownLayout } from './terminal-shutdown-layout-capture'
|
||||
import { getOverrideAffectedPanes, getPanesNeedingOverrideFit } from './override-affected-panes'
|
||||
import {
|
||||
|
|
@ -517,15 +519,12 @@ export default function TerminalPane({
|
|||
if (rect.width === 0 || rect.height === 0) {
|
||||
continue
|
||||
}
|
||||
safeFit(pane)
|
||||
const stuckAtMobile =
|
||||
event.priorCols != null &&
|
||||
event.priorRows != null &&
|
||||
pane.terminal.cols === event.priorCols &&
|
||||
pane.terminal.rows === event.priorRows
|
||||
if (stuckAtMobile && event.cols > 0 && event.rows > 0) {
|
||||
pane.terminal.resize(event.cols, event.rows)
|
||||
}
|
||||
applyDesktopFitFallbackAfterReplay(pane, {
|
||||
...event,
|
||||
// Why: the timeout/replay queue can outlive this pane binding;
|
||||
// never apply old server dimensions to a replacement PTY.
|
||||
shouldApply: () => getAffectedPanes().includes(pane)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1130,7 +1129,7 @@ export default function TerminalPane({
|
|||
const clearPaneScrollback = useCallback(
|
||||
(pane: ManagedPane): void => {
|
||||
clearedScrollbackLeafIdsRef.current.add(pane.leafId)
|
||||
pane.terminal.clear()
|
||||
clearTerminalScrollbackAndFollowOutput(pane.terminal)
|
||||
// Why: also clear the host buffer for remote-server panes, or the next
|
||||
// host snapshot replays the scrollback we just cleared locally.
|
||||
const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null
|
||||
|
|
@ -1884,26 +1883,27 @@ export default function TerminalPane({
|
|||
return
|
||||
}
|
||||
for (const pane of manager.getPanes()) {
|
||||
safeFit(pane)
|
||||
const transport = paneTransportsRef.current.get(pane.id)
|
||||
if (!transport?.isConnected()) {
|
||||
continue
|
||||
}
|
||||
const ptyId = transport.getPtyId()
|
||||
if (!ptyId) {
|
||||
continue
|
||||
}
|
||||
// Why: match pty-connection resize guards so web refit retries do not
|
||||
// forward SIGWINCH while mobile-lock or phone-fit overrides are active.
|
||||
if (getFitOverrideForPty(ptyId) || isPtyLocked(ptyId)) {
|
||||
continue
|
||||
}
|
||||
// Why: skip forwarding a stale near-zero fit to the host PTY while the
|
||||
// overlay is still settling after a worktree switch.
|
||||
if (pane.terminal.cols < 8 || pane.terminal.rows < 4) {
|
||||
continue
|
||||
}
|
||||
transport.resize(pane.terminal.cols, pane.terminal.rows)
|
||||
safeFitAndThen(pane, 'web-client-pty-resize', () => {
|
||||
const transport = paneTransportsRef.current.get(pane.id)
|
||||
if (!transport?.isConnected()) {
|
||||
return
|
||||
}
|
||||
const ptyId = transport.getPtyId()
|
||||
if (!ptyId) {
|
||||
return
|
||||
}
|
||||
// Why: match pty-connection resize guards so web refit retries do not
|
||||
// forward SIGWINCH while mobile-lock or phone-fit overrides are active.
|
||||
if (getFitOverrideForPty(ptyId) || isPtyLocked(ptyId)) {
|
||||
return
|
||||
}
|
||||
// Why: skip forwarding a stale near-zero fit to the host PTY while the
|
||||
// overlay is still settling after a worktree switch.
|
||||
if (pane.terminal.cols < 8 || pane.terminal.rows < 4) {
|
||||
return
|
||||
}
|
||||
transport.resize(pane.terminal.cols, pane.terminal.rows)
|
||||
})
|
||||
}
|
||||
}
|
||||
const scheduleFrame = (): void => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { applyDesktopFitFallbackAfterReplay } from './desktop-fit-fallback'
|
||||
import {
|
||||
beginTerminalScrollIntentBufferRebuild,
|
||||
endTerminalScrollIntentBufferRebuild
|
||||
} from '@/lib/pane-manager/terminal-scroll-intent-rebuild'
|
||||
|
||||
function createPane() {
|
||||
const terminal = {
|
||||
cols: 49,
|
||||
rows: 20,
|
||||
buffer: { active: { type: 'normal', viewportY: 0, baseY: 0 } },
|
||||
resize: vi.fn((cols: number, rows: number) => {
|
||||
terminal.cols = cols
|
||||
terminal.rows = rows
|
||||
})
|
||||
}
|
||||
return {
|
||||
terminal,
|
||||
container: {
|
||||
dataset: {},
|
||||
getBoundingClientRect: () => ({ width: 800, height: 600 })
|
||||
},
|
||||
fitAddon: {
|
||||
proposeDimensions: vi.fn(() => null),
|
||||
fit: vi.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('desktop fit fallback', () => {
|
||||
it('waits until structural replay completes before direct resize', async () => {
|
||||
const pane = createPane()
|
||||
beginTerminalScrollIntentBufferRebuild(pane.terminal)
|
||||
|
||||
applyDesktopFitFallbackAfterReplay(pane as never, {
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
priorCols: 49,
|
||||
priorRows: 20
|
||||
})
|
||||
expect(pane.terminal.resize).not.toHaveBeenCalled()
|
||||
|
||||
endTerminalScrollIntentBufferRebuild(pane.terminal)
|
||||
await Promise.resolve()
|
||||
expect(pane.terminal.resize).toHaveBeenCalledWith(120, 40)
|
||||
})
|
||||
|
||||
it('drops deferred dimensions when the pane binding becomes stale', async () => {
|
||||
const pane = createPane()
|
||||
let isCurrent = true
|
||||
beginTerminalScrollIntentBufferRebuild(pane.terminal)
|
||||
applyDesktopFitFallbackAfterReplay(pane as never, {
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
priorCols: 49,
|
||||
priorRows: 20,
|
||||
shouldApply: () => isCurrent
|
||||
})
|
||||
|
||||
isCurrent = false
|
||||
endTerminalScrollIntentBufferRebuild(pane.terminal)
|
||||
await Promise.resolve()
|
||||
expect(pane.terminal.resize).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import type { ManagedPane } from '@/lib/pane-manager/pane-manager-types'
|
||||
import { safeFit } from '@/lib/pane-manager/pane-fit'
|
||||
import { deferTerminalGeometryMutationDuringRebuild } from '@/lib/pane-manager/terminal-scroll-intent-rebuild'
|
||||
|
||||
type DesktopFitFallbackDimensions = {
|
||||
cols: number
|
||||
rows: number
|
||||
priorCols?: number | null
|
||||
priorRows?: number | null
|
||||
shouldApply?: () => boolean
|
||||
}
|
||||
|
||||
export function applyDesktopFitFallbackAfterReplay(
|
||||
pane: ManagedPane,
|
||||
dimensions: DesktopFitFallbackDimensions
|
||||
): void {
|
||||
const applyFallback = (): void => {
|
||||
if (dimensions.shouldApply?.() === false) {
|
||||
return
|
||||
}
|
||||
safeFit(pane)
|
||||
const stuckAtPriorGrid =
|
||||
dimensions.priorCols != null &&
|
||||
dimensions.priorRows != null &&
|
||||
pane.terminal.cols === dimensions.priorCols &&
|
||||
pane.terminal.rows === dimensions.priorRows
|
||||
if (stuckAtPriorGrid && dimensions.cols > 0 && dimensions.rows > 0) {
|
||||
pane.terminal.resize(dimensions.cols, dimensions.rows)
|
||||
}
|
||||
}
|
||||
// Why: the server dimensions are only a fallback; source-dimension replay
|
||||
// must parse and restore its viewport before this can reflow xterm.
|
||||
if (
|
||||
!deferTerminalGeometryMutationDuringRebuild(
|
||||
pane.terminal,
|
||||
'desktop-fit-fallback',
|
||||
applyFallback
|
||||
)
|
||||
) {
|
||||
applyFallback()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -15,7 +15,7 @@ describe('createPtySizeReassertion', () => {
|
|||
getPtyId: () => 'pty-1',
|
||||
isRemotePtyId: () => false,
|
||||
shouldSuppressDesktopResize: () => false,
|
||||
fit: vi.fn(),
|
||||
fitAndRun: (continuation) => continuation(),
|
||||
getTerminalDimensions: () => ({ cols: 82, rows: 30 }),
|
||||
getAppliedSize: vi.fn(async () => ({ cols: 120, rows: 30 })),
|
||||
forwardResize
|
||||
|
|
@ -34,7 +34,7 @@ describe('createPtySizeReassertion', () => {
|
|||
getPtyId: () => 'pty-1',
|
||||
isRemotePtyId: () => false,
|
||||
shouldSuppressDesktopResize: () => false,
|
||||
fit: vi.fn(),
|
||||
fitAndRun: (continuation) => continuation(),
|
||||
getTerminalDimensions: () => ({ cols: 82, rows: 30 }),
|
||||
getAppliedSize: vi.fn(async () => ({ cols: 82, rows: 30 })),
|
||||
forwardResize
|
||||
|
|
@ -53,8 +53,9 @@ describe('createPtySizeReassertion', () => {
|
|||
getPtyId: () => 'pty-1',
|
||||
isRemotePtyId: () => false,
|
||||
shouldSuppressDesktopResize: () => false,
|
||||
fit: vi.fn(() => {
|
||||
fitAndRun: vi.fn((continuation) => {
|
||||
calls.push('fit')
|
||||
continuation()
|
||||
}),
|
||||
getTerminalDimensions: vi.fn(() => {
|
||||
calls.push('measure')
|
||||
|
|
@ -81,8 +82,9 @@ describe('createPtySizeReassertion', () => {
|
|||
getPtyId: () => 'pty-1',
|
||||
isRemotePtyId: () => false,
|
||||
shouldSuppressDesktopResize: () => false,
|
||||
fit: vi.fn(() => {
|
||||
fitAndRun: vi.fn((continuation) => {
|
||||
forwardResize(82, 30)
|
||||
continuation()
|
||||
}),
|
||||
getTerminalDimensions: () => ({ cols: 82, rows: 30 }),
|
||||
getAppliedSize: vi.fn(async () => ({ cols: 82, rows: 30 })),
|
||||
|
|
@ -97,14 +99,14 @@ describe('createPtySizeReassertion', () => {
|
|||
})
|
||||
|
||||
it('can verify current dimensions without fitting again', async () => {
|
||||
const fit = vi.fn()
|
||||
const fitAndRun = vi.fn((continuation: () => void) => continuation())
|
||||
const forwardResize = vi.fn()
|
||||
const reassertion = createPtySizeReassertion({
|
||||
isDisposed: () => false,
|
||||
getPtyId: () => 'pty-1',
|
||||
isRemotePtyId: () => false,
|
||||
shouldSuppressDesktopResize: () => false,
|
||||
fit,
|
||||
fitAndRun,
|
||||
getTerminalDimensions: () => ({ cols: 82, rows: 30 }),
|
||||
getAppliedSize: vi.fn(async () => ({ cols: 120, rows: 30 })),
|
||||
forwardResize
|
||||
|
|
@ -113,7 +115,7 @@ describe('createPtySizeReassertion', () => {
|
|||
reassertion.request({ fit: false })
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(fit).not.toHaveBeenCalled()
|
||||
expect(fitAndRun).not.toHaveBeenCalled()
|
||||
expect(forwardResize).toHaveBeenCalledWith(82, 30)
|
||||
})
|
||||
|
||||
|
|
@ -124,7 +126,7 @@ describe('createPtySizeReassertion', () => {
|
|||
getPtyId: () => 'remote:terminal-1',
|
||||
isRemotePtyId: () => true,
|
||||
shouldSuppressDesktopResize: () => false,
|
||||
fit: vi.fn(),
|
||||
fitAndRun: (continuation) => continuation(),
|
||||
getTerminalDimensions: () => ({ cols: 82, rows: 30 }),
|
||||
getAppliedSize,
|
||||
forwardResize: vi.fn()
|
||||
|
|
@ -134,7 +136,7 @@ describe('createPtySizeReassertion', () => {
|
|||
getPtyId: () => 'pty-1',
|
||||
isRemotePtyId: () => false,
|
||||
shouldSuppressDesktopResize: () => true,
|
||||
fit: vi.fn(),
|
||||
fitAndRun: (continuation) => continuation(),
|
||||
getTerminalDimensions: () => ({ cols: 82, rows: 30 }),
|
||||
getAppliedSize,
|
||||
forwardResize: vi.fn()
|
||||
|
|
@ -164,7 +166,7 @@ describe('createPtySizeReassertion', () => {
|
|||
getPtyId: () => 'pty-1',
|
||||
isRemotePtyId: () => false,
|
||||
shouldSuppressDesktopResize: () => false,
|
||||
fit: vi.fn(),
|
||||
fitAndRun: (continuation) => continuation(),
|
||||
getTerminalDimensions: () => ({ cols: 82, rows: 30 }),
|
||||
getAppliedSize,
|
||||
forwardResize
|
||||
|
|
@ -201,7 +203,7 @@ describe('createPtySizeReassertion', () => {
|
|||
getPtyId: () => 'pty-1',
|
||||
isRemotePtyId: () => false,
|
||||
shouldSuppressDesktopResize: () => false,
|
||||
fit: vi.fn(),
|
||||
fitAndRun: (continuation) => continuation(),
|
||||
getTerminalDimensions: () => ({ cols: targetCols, rows: 40 }),
|
||||
getAppliedSize,
|
||||
forwardResize
|
||||
|
|
@ -240,7 +242,7 @@ describe('createPtySizeReassertion', () => {
|
|||
getPtyId: () => 'pty-1',
|
||||
isRemotePtyId: () => false,
|
||||
shouldSuppressDesktopResize: () => false,
|
||||
fit: vi.fn(),
|
||||
fitAndRun: (continuation) => continuation(),
|
||||
getTerminalDimensions: () => dims,
|
||||
getAppliedSize,
|
||||
forwardResize
|
||||
|
|
@ -273,7 +275,7 @@ describe('createPtySizeReassertion', () => {
|
|||
getPtyId: () => 'pty-1',
|
||||
isRemotePtyId: () => false,
|
||||
shouldSuppressDesktopResize: () => false,
|
||||
fit: vi.fn(),
|
||||
fitAndRun: (continuation) => continuation(),
|
||||
getTerminalDimensions: () => dims,
|
||||
getAppliedSize,
|
||||
forwardResize
|
||||
|
|
@ -313,7 +315,7 @@ describe('createPtySizeReassertion', () => {
|
|||
getPtyId: () => 'pty-1',
|
||||
isRemotePtyId: () => false,
|
||||
shouldSuppressDesktopResize: () => false,
|
||||
fit: vi.fn(),
|
||||
fitAndRun: (continuation) => continuation(),
|
||||
getTerminalDimensions: () => dims,
|
||||
getAppliedSize,
|
||||
forwardResize
|
||||
|
|
@ -346,7 +348,7 @@ describe('createPtySizeReassertion', () => {
|
|||
getPtyId: () => 'pty-1',
|
||||
isRemotePtyId: () => false,
|
||||
shouldSuppressDesktopResize: () => false,
|
||||
fit: vi.fn(),
|
||||
fitAndRun: (continuation) => continuation(),
|
||||
getTerminalDimensions: () => dims,
|
||||
getAppliedSize,
|
||||
forwardResize
|
||||
|
|
@ -369,7 +371,7 @@ describe('createPtySizeReassertion', () => {
|
|||
getPtyId: () => 'pty-1',
|
||||
isRemotePtyId: () => false,
|
||||
shouldSuppressDesktopResize: () => false,
|
||||
fit: vi.fn(),
|
||||
fitAndRun: (continuation) => continuation(),
|
||||
getTerminalDimensions: () => ({ cols: 82, rows: 30 }),
|
||||
getAppliedSize: vi.fn(async () => {
|
||||
throw new Error('unavailable')
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ export type PtySizeReassertionOptions = {
|
|||
getPtyId: () => string | null
|
||||
isRemotePtyId: (ptyId: string) => boolean
|
||||
shouldSuppressDesktopResize: () => boolean
|
||||
fit: () => void
|
||||
fitAndRun: (continuation: () => void) => void
|
||||
getTerminalDimensions: () => PtySizeReassertionDimensions
|
||||
getAppliedSize: (ptyId: string) => Promise<PtySizeReassertionDimensions | null>
|
||||
forwardResize: (cols: number, rows: number) => void
|
||||
|
|
@ -46,7 +46,8 @@ export function createPtySizeReassertion(options: PtySizeReassertionOptions): Pt
|
|||
return
|
||||
}
|
||||
if (shouldFit) {
|
||||
options.fit()
|
||||
options.fitAndRun(() => run(false))
|
||||
return
|
||||
}
|
||||
const target = options.getTerminalDimensions()
|
||||
if (!dimensionsAreUsable(target)) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
isPaneReplaying,
|
||||
replayIntoTerminal,
|
||||
replayIntoTerminalAsync,
|
||||
waitForTerminalReplayWritesParsed,
|
||||
type ReplayingPanesRef
|
||||
} from './replay-guard'
|
||||
import { configureLazyArabicShapingJoiner } from '@/lib/pane-manager/terminal-arabic-shaping-joiner'
|
||||
|
|
@ -418,6 +419,27 @@ describe('replay-guard', () => {
|
|||
})
|
||||
|
||||
describe('replay-guard stall handling (probe-certified release)', () => {
|
||||
it('waits for the FIFO replay sentinel without releasing on elapsed time', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { terminal } = makeFakePane(1)
|
||||
let resolved = false
|
||||
|
||||
void waitForTerminalReplayWritesParsed(terminal, { stallCheckMs: 1_000 }).then(() => {
|
||||
resolved = true
|
||||
})
|
||||
expect(terminal.lastData).toEqual([''])
|
||||
|
||||
vi.advanceTimersByTime(1_000)
|
||||
expect(terminal.lastData).toEqual(['', ''])
|
||||
expect(resolved).toBe(false)
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(resolved).toBe(false)
|
||||
|
||||
terminal.flush()
|
||||
await Promise.resolve()
|
||||
expect(resolved).toBe(true)
|
||||
})
|
||||
|
||||
it('HOLDS the guard while a slow replay is still parsing — a probe is queued, never a blind release', () => {
|
||||
// Why this is the load-bearing safety test: a time-based release here
|
||||
// would leak xterm auto-replies into the shell (and a leaked ESC into an
|
||||
|
|
|
|||
|
|
@ -247,3 +247,49 @@ export function replayIntoTerminalAsync(
|
|||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Resolves after every replay write already queued on this terminal has
|
||||
* parsed. A delayed FIFO probe covers a lost sentinel callback without ever
|
||||
* treating elapsed time alone as proof that parsing finished. */
|
||||
export function waitForTerminalReplayWritesParsed(
|
||||
terminal: ReplayGuardWriteTarget,
|
||||
options: Pick<ReplayTerminalOptions, 'stallCheckMs'> = {}
|
||||
): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
let finished = false
|
||||
let stallTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const finish = (): void => {
|
||||
if (finished) {
|
||||
return
|
||||
}
|
||||
finished = true
|
||||
if (stallTimer !== null) {
|
||||
clearTimeout(stallTimer)
|
||||
stallTimer = null
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
const queueProbe = (): void => {
|
||||
if (finished) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Why: an empty write is FIFO with earlier replay bytes. Its callback
|
||||
// can recover a lost sentinel callback without changing parser state.
|
||||
terminal.write('', finish)
|
||||
} catch {
|
||||
// A disposed terminal cannot parse any remaining replay bytes.
|
||||
finish()
|
||||
}
|
||||
}
|
||||
stallTimer = setTimeout(queueProbe, options.stallCheckMs ?? REPLAY_GUARD_STALL_CHECK_MS)
|
||||
try {
|
||||
// Why empty: pendingEscapeTailAnsi must remain the final replay bytes;
|
||||
// xterm still orders this completion after every earlier write.
|
||||
terminal.write('', finish)
|
||||
} catch {
|
||||
// A disposed terminal cannot parse any remaining replay bytes.
|
||||
finish()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
} from '@/lib/terminal-theme'
|
||||
import { buildFontFamily } from './layout-serialization'
|
||||
import { guardParserHandler } from './terminal-parser-handler-guard'
|
||||
import { captureScrollState, restoreScrollState, safeFit } from '@/lib/pane-manager/pane-tree-ops'
|
||||
import { safeFit, safeFitAndThen } from '@/lib/pane-manager/pane-tree-ops'
|
||||
import {
|
||||
normalizeTerminalFastScrollSensitivity,
|
||||
normalizeTerminalScrollSensitivity,
|
||||
|
|
@ -300,21 +300,26 @@ export function applyTerminalAppearance(
|
|||
// separate hook and lets live toggles (settings change, font swap)
|
||||
// land immediately.
|
||||
manager.setPaneLigaturesEnabled(pane.id, ligaturesEnabled)
|
||||
try {
|
||||
const state = captureScrollState(pane.terminal)
|
||||
safeFit(pane)
|
||||
restoreScrollState(pane.terminal, state)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const transport = paneTransports.get(pane.id)
|
||||
// Why: skip PTY resize when a mobile-fit override is active — the PTY
|
||||
// is already at the correct phone dimensions and must not be resized
|
||||
// back to desktop dimensions by an appearance change.
|
||||
const appearancePtyId = transport?.getPtyId()
|
||||
if (transport?.isConnected() && (!appearancePtyId || !getFitOverrideForPty(appearancePtyId))) {
|
||||
transport.resize(pane.terminal.cols, pane.terminal.rows)
|
||||
maybePushMode2031Flip(pane.id, appearance.mode, transport, paneMode2031, paneLastThemeMode)
|
||||
safeFitAndThen(pane, 'appearance-pty-resize', () => {
|
||||
const currentTransport = paneTransports.get(pane.id)
|
||||
if (
|
||||
currentTransport !== transport ||
|
||||
!transport.isConnected() ||
|
||||
transport.getPtyId() !== appearancePtyId
|
||||
) {
|
||||
return
|
||||
}
|
||||
transport.resize(pane.terminal.cols, pane.terminal.rows)
|
||||
})
|
||||
} else {
|
||||
safeFit(pane)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({
|
|||
requestTerminalBacklogRecovery: vi.fn()
|
||||
}))
|
||||
vi.mock('@/lib/pane-manager/terminal-scroll-intent', () => ({
|
||||
enforceTerminalCurrentScrollIntent: vi.fn()
|
||||
enforceTerminalCurrentScrollIntent: vi.fn(),
|
||||
syncTerminalScrollIntentFromViewport: vi.fn()
|
||||
}))
|
||||
vi.mock('./pane-helpers', () => ({
|
||||
fitAndFocusPanes: vi.fn(),
|
||||
|
|
@ -73,6 +74,22 @@ describe('resumeTerminalVisibility reveal repaint', () => {
|
|||
expect(scheduleTabRevealWebglAtlasRecovery).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('captures native trim movement before enforcing viewport intent', async () => {
|
||||
const terminal = { name: 'trimmed-terminal' }
|
||||
const manager = createManager()
|
||||
manager.getPanes.mockReturnValue([{ terminal }])
|
||||
const { enforceTerminalCurrentScrollIntent, syncTerminalScrollIntentFromViewport } = vi.mocked(
|
||||
await import('@/lib/pane-manager/terminal-scroll-intent')
|
||||
)
|
||||
|
||||
resumeTerminalVisibility(resumeArgs(manager, true))
|
||||
|
||||
expect(syncTerminalScrollIntentFromViewport).toHaveBeenCalledWith(terminal)
|
||||
expect(syncTerminalScrollIntentFromViewport.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
enforceTerminalCurrentScrollIntent.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY
|
||||
)
|
||||
})
|
||||
|
||||
it('schedules the repaint after rendering resumes on a heavy reveal', () => {
|
||||
const order: string[] = []
|
||||
const manager = createManager(order)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ import {
|
|||
flushTerminalOutput,
|
||||
requestTerminalBacklogRecovery
|
||||
} from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import { enforceTerminalCurrentScrollIntent } from '@/lib/pane-manager/terminal-scroll-intent'
|
||||
import {
|
||||
enforceTerminalCurrentScrollIntent,
|
||||
syncTerminalScrollIntentFromViewport
|
||||
} from '@/lib/pane-manager/terminal-scroll-intent'
|
||||
import { fitAndFocusPanes, fitPanes, focusActivePane } from './pane-helpers'
|
||||
import { scheduleTabRevealWebglAtlasRecovery } from './terminal-webgl-atlas-recovery'
|
||||
|
||||
|
|
@ -51,6 +54,7 @@ export function resumeTerminalVisibility({
|
|||
captureViewportPositions,
|
||||
withSuppressedScrollTracking
|
||||
}: ResumeTerminalVisibilityArgs): void {
|
||||
syncTerminalViewportIntents(manager)
|
||||
// Why: WebGL resume can disturb xterm's viewport bookkeeping before the
|
||||
// post-resume fit runs. Capture numeric viewport positions first; the
|
||||
// restore path avoids content matching so duplicate agent log lines do
|
||||
|
|
@ -133,6 +137,7 @@ export function recoverVisibleTerminalWindowWake({
|
|||
requestTerminalBacklogRecovery(pane.terminal)
|
||||
flushTerminalOutput(pane.terminal, { maxChars: WINDOW_WAKE_FLUSH_CHARS })
|
||||
}
|
||||
syncTerminalViewportIntents(manager)
|
||||
manager.resumeRendering()
|
||||
if (isActive) {
|
||||
fitAndFocusPanes(manager)
|
||||
|
|
@ -170,6 +175,7 @@ function resumeTerminalVisibilityHeavy(manager: PaneManager, isActive: boolean):
|
|||
requestTerminalBacklogRecovery(pane.terminal)
|
||||
flushTerminalOutput(pane.terminal, { maxChars: VISIBLE_RESUME_FLUSH_CHARS })
|
||||
}
|
||||
syncTerminalViewportIntents(manager)
|
||||
// Resume WebGL immediately so the terminal shows its last-known state
|
||||
// on the first painted frame. macOS context creation is ~5 ms; on
|
||||
// Windows (ANGLE -> D3D11) it can be 100-500 ms but a deferred resume
|
||||
|
|
@ -190,3 +196,11 @@ function enforceTerminalViewportIntents(manager: PaneManager): void {
|
|||
enforceTerminalCurrentScrollIntent(pane.terminal)
|
||||
}
|
||||
}
|
||||
|
||||
function syncTerminalViewportIntents(manager: PaneManager): void {
|
||||
for (const pane of manager.getPanes()) {
|
||||
// Why: native scrollback trimming moves a pinned viewport content-stably.
|
||||
// Capture that live position before resume/fit can disturb it.
|
||||
syncTerminalScrollIntentFromViewport(pane.terminal)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({
|
|||
getTerminalOutputEpoch: vi.fn(() => 0),
|
||||
handleTerminalFileDrop: vi.fn(),
|
||||
enforceTerminalCurrentScrollIntent: vi.fn(),
|
||||
syncTerminalScrollIntentFromViewport: vi.fn(),
|
||||
pasteTerminalText: vi.fn(),
|
||||
recordTerminalUserInputForLeaf: vi.fn(),
|
||||
requestTerminalBacklogRecovery: vi.fn(),
|
||||
|
|
@ -79,7 +80,8 @@ vi.mock('@/lib/pane-manager/pane-scroll', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('@/lib/pane-manager/terminal-scroll-intent', () => ({
|
||||
enforceTerminalCurrentScrollIntent: mocks.enforceTerminalCurrentScrollIntent
|
||||
enforceTerminalCurrentScrollIntent: mocks.enforceTerminalCurrentScrollIntent,
|
||||
syncTerminalScrollIntentFromViewport: mocks.syncTerminalScrollIntentFromViewport
|
||||
}))
|
||||
|
||||
vi.mock('./terminal-drop-handler', () => ({
|
||||
|
|
|
|||
|
|
@ -106,10 +106,8 @@ import { getConnectionId } from '@/lib/connection-context'
|
|||
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { isPaneReplaying, type ReplayingPanesRef } from './replay-guard'
|
||||
import { fitAndFocusPanes, fitPanes } from './pane-helpers'
|
||||
import {
|
||||
markTerminalPinnedViewport,
|
||||
syncTerminalScrollIntentSoon
|
||||
} from '@/lib/pane-manager/terminal-scroll-intent'
|
||||
import { markTerminalPinnedViewport } from '@/lib/pane-manager/terminal-scroll-intent'
|
||||
import { syncTerminalScrollIntentSoon } from '@/lib/pane-manager/terminal-scroll-intent-settle'
|
||||
import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
|
||||
import { captureParkedTerminalPaneCandidates } from './terminal-parked-tab-watchers'
|
||||
import { e2eConfig } from '@/lib/e2e-config'
|
||||
|
|
@ -1024,11 +1022,20 @@ export function useTerminalPaneLifecycle({
|
|||
}
|
||||
|
||||
if (e.type === 'keydown') {
|
||||
const shouldSyncCurrentTerminal = (): boolean =>
|
||||
managerRef.current
|
||||
?.getPanes()
|
||||
.some((candidate) => candidate.terminal === pane.terminal) === true
|
||||
if (e.key === 'PageUp' || e.key === 'Home') {
|
||||
markTerminalPinnedViewport(pane.terminal)
|
||||
syncTerminalScrollIntentSoon(pane.terminal, { preservePinnedAtBottom: true })
|
||||
syncTerminalScrollIntentSoon(pane.terminal, {
|
||||
preservePinnedAtBottom: true,
|
||||
shouldSync: shouldSyncCurrentTerminal
|
||||
})
|
||||
} else if (e.key === 'PageDown' || e.key === 'End') {
|
||||
syncTerminalScrollIntentSoon(pane.terminal)
|
||||
syncTerminalScrollIntentSoon(pane.terminal, {
|
||||
shouldSync: shouldSyncCurrentTerminal
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect } from 'react'
|
||||
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import { dispatchZoomLevelChanged } from '@/lib/zoom-events'
|
||||
import { captureScrollState, restoreScrollState, safeFit } from '@/lib/pane-manager/pane-tree-ops'
|
||||
import { safeFit } from '@/lib/pane-manager/pane-tree-ops'
|
||||
import { getPaneOwnedActiveHelperTextarea } from './regular-terminal-focus-ownership'
|
||||
|
||||
type FontZoomDeps = {
|
||||
|
|
@ -57,13 +57,7 @@ export function useTerminalFontZoom({
|
|||
}
|
||||
|
||||
pane.terminal.options.fontSize = nextSize
|
||||
try {
|
||||
const state = captureScrollState(pane.terminal)
|
||||
safeFit(pane)
|
||||
restoreScrollState(pane.terminal, state)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
safeFit(pane)
|
||||
|
||||
const percent = Math.round((nextSize / globalSize) * 100)
|
||||
dispatchZoomLevelChanged('terminal', percent)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ import {
|
|||
detachPaneFitResizeObserver,
|
||||
requestStablePaneFit
|
||||
} from './pane-fit-resize-observer'
|
||||
import {
|
||||
beginTerminalScrollIntentBufferRebuild,
|
||||
endTerminalScrollIntentBufferRebuild
|
||||
} from './terminal-scroll-intent-rebuild'
|
||||
|
||||
type ResizeObserverCallbackLike = ConstructorParameters<typeof ResizeObserver>[0]
|
||||
|
||||
|
|
@ -174,6 +178,23 @@ describe('attachPaneFitResizeObserver', () => {
|
|||
expect(onSettled).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not notify settled callbacks until a replay-deferred fit completes', async () => {
|
||||
const onSettled = vi.fn()
|
||||
const pane = createPane()
|
||||
beginTerminalScrollIntentBufferRebuild(pane.terminal)
|
||||
|
||||
requestStablePaneFit(pane, onSettled)
|
||||
flushAnimationFrames()
|
||||
|
||||
expect(pane.fitAddon.fit).not.toHaveBeenCalled()
|
||||
expect(onSettled).not.toHaveBeenCalled()
|
||||
endTerminalScrollIntentBufferRebuild(pane.terminal)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1)
|
||||
expect(onSettled).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('skips observer fits while the terminal has no visible geometry', () => {
|
||||
const pane = createPane(() => ({ cols: 80, rows: 24 }), {
|
||||
rect: { width: 0, height: 0 }
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { ManagedPane, ManagedPaneInternal } from './pane-manager-types'
|
||||
import { safeFit } from './pane-tree-ops'
|
||||
import { cancelPendingSafeFitContinuations, safeFitAndThen } from './pane-tree-ops'
|
||||
|
||||
type ProposedDimensions = {
|
||||
cols: number
|
||||
|
|
@ -77,7 +77,8 @@ function flushStableFitCallbacks(pane: StableFitPane): void {
|
|||
function finishStableFit(pane: StableFitPane, shouldFit: boolean): void {
|
||||
setPendingObservedFitRafId(pane, null)
|
||||
if (shouldFit) {
|
||||
safeFit(pane)
|
||||
safeFitAndThen(pane, 'stable-pane-fit', () => flushStableFitCallbacks(pane))
|
||||
return
|
||||
}
|
||||
flushStableFitCallbacks(pane)
|
||||
}
|
||||
|
|
@ -164,4 +165,5 @@ export function detachPaneFitResizeObserver(pane: ManagedPaneInternal): void {
|
|||
setPendingObservedFitRafId(pane, null)
|
||||
}
|
||||
stableFitCallbacks.delete(pane)
|
||||
cancelPendingSafeFitContinuations(pane)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,257 @@
|
|||
import type { ManagedPane, ManagedPaneInternal, ScrollState } from './pane-manager-types'
|
||||
import { getFitOverrideForPty } from './mobile-fit-overrides'
|
||||
import {
|
||||
captureTerminalStructuralScrollIntent,
|
||||
isTerminalStructuralScrollIntentCurrent,
|
||||
markTerminalPinnedViewport,
|
||||
restoreTerminalStructuralScrollIntent
|
||||
} from './terminal-scroll-intent'
|
||||
import {
|
||||
captureScrollState,
|
||||
releaseScrollStateMarker,
|
||||
restoreScrollStateAfterFit,
|
||||
resumePendingFitScrollRestoreAfterFit
|
||||
} from './pane-scroll'
|
||||
import {
|
||||
deferTerminalGeometryMutationDuringRebuild,
|
||||
isTerminalScrollIntentRebuildInFlight
|
||||
} from './terminal-scroll-intent-rebuild'
|
||||
|
||||
const MIN_PANE_FIT_WIDTH_PX = 48
|
||||
const MIN_PANE_FIT_HEIGHT_PX = 24
|
||||
const MIN_PANE_FIT_COLS = 8
|
||||
const MIN_PANE_FIT_ROWS = 4
|
||||
|
||||
export type SafeFitContinuationHandle = {
|
||||
completion: Promise<boolean>
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
type PendingSafeFitContinuation = {
|
||||
continuation: () => void
|
||||
shouldContinue: () => boolean
|
||||
resolve: (completed: boolean) => void
|
||||
}
|
||||
|
||||
const pendingSafeFitContinuations = new WeakMap<
|
||||
ManagedPane,
|
||||
Map<string, PendingSafeFitContinuation>
|
||||
>()
|
||||
|
||||
function getProposedDimensions(pane: ManagedPane): { cols: number; rows: number } | null {
|
||||
try {
|
||||
return pane.fitAddon.proposeDimensions() ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function canMeasurePaneForFit(pane: ManagedPane): boolean {
|
||||
const measure = pane.container?.getBoundingClientRect
|
||||
if (typeof measure === 'function') {
|
||||
const rect = measure.call(pane.container)
|
||||
if (rect.width < MIN_PANE_FIT_WIDTH_PX || rect.height < MIN_PANE_FIT_HEIGHT_PX) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const dims = getProposedDimensions(pane)
|
||||
if (!dims) {
|
||||
return false
|
||||
}
|
||||
// Why: worktree switches can briefly measure a near-zero overlay before
|
||||
// fallback positioning lands. Fitting there pins the PTY at ~2 cols.
|
||||
return dims.cols >= MIN_PANE_FIT_COLS && dims.rows >= MIN_PANE_FIT_ROWS
|
||||
}
|
||||
|
||||
function canPreserveScrollIntentForFit(pane: ManagedPane): boolean {
|
||||
// Why: split reparent has its own delayed restore; restoring here can fight that timer.
|
||||
return !(
|
||||
'pendingSplitScrollState' in pane && (pane as ManagedPaneInternal).pendingSplitScrollState
|
||||
)
|
||||
}
|
||||
|
||||
function performSafeFit(pane: ManagedPane): boolean {
|
||||
if (deferTerminalGeometryMutationDuringRebuild(pane.terminal, 'safe-fit', () => safeFit(pane))) {
|
||||
return false
|
||||
}
|
||||
if (!canMeasurePaneForFit(pane)) {
|
||||
return false
|
||||
}
|
||||
let scrollIntent = null as ReturnType<typeof captureTerminalStructuralScrollIntent>
|
||||
let pinnedScrollState: ScrollState | null = null
|
||||
let shouldRestoreScroll = false
|
||||
const captureScrollForFit = (): void => {
|
||||
scrollIntent = captureTerminalStructuralScrollIntent(pane.terminal)
|
||||
// Why: fit can reflow and renumber every buffer row; a marker tracks the
|
||||
// pinned content itself, while a numeric line would point elsewhere after.
|
||||
pinnedScrollState =
|
||||
scrollIntent?.kind === 'pinnedViewport' ? captureScrollState(pane.terminal) : null
|
||||
shouldRestoreScroll = true
|
||||
}
|
||||
try {
|
||||
// Why: a mobile-owned PTY must stay at its phone grid on passive desktop panes.
|
||||
const ptyId = pane.container?.dataset?.ptyId
|
||||
const override = ptyId ? getFitOverrideForPty(ptyId) : null
|
||||
if (override) {
|
||||
if (pane.terminal.cols !== override.cols || pane.terminal.rows !== override.rows) {
|
||||
if (canPreserveScrollIntentForFit(pane)) {
|
||||
captureScrollForFit()
|
||||
}
|
||||
pane.terminal.resize(override.cols, override.rows)
|
||||
} else {
|
||||
resumePendingFitScrollRestoreAfterFit(pane.terminal)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const dims = getProposedDimensions(pane)
|
||||
if (dims && dims.cols === pane.terminal.cols && dims.rows === pane.terminal.rows) {
|
||||
// Why: divider drags often stay within one cell; avoid needless clear/refresh churn.
|
||||
resumePendingFitScrollRestoreAfterFit(pane.terminal)
|
||||
return true
|
||||
}
|
||||
if (canPreserveScrollIntentForFit(pane)) {
|
||||
captureScrollForFit()
|
||||
}
|
||||
pane.fitAddon.fit()
|
||||
return true
|
||||
} catch {
|
||||
// Container may not have dimensions yet.
|
||||
return false
|
||||
} finally {
|
||||
if (shouldRestoreScroll) {
|
||||
try {
|
||||
if (resumePendingFitScrollRestoreAfterFit(pane.terminal)) {
|
||||
} else if (pinnedScrollState) {
|
||||
const state: ScrollState = pinnedScrollState
|
||||
pinnedScrollState = null
|
||||
restoreScrollStateAfterFit(pane.terminal, state, {
|
||||
onRestored: () => {
|
||||
// Why: do not replace a durable pre-replay pin with transient 0/0 geometry.
|
||||
if (!state.wasAtBottom) {
|
||||
markTerminalPinnedViewport(pane.terminal)
|
||||
}
|
||||
},
|
||||
shouldRestore: () =>
|
||||
!isTerminalScrollIntentRebuildInFlight(pane.terminal) &&
|
||||
isTerminalStructuralScrollIntentCurrent(pane.terminal, scrollIntent)
|
||||
})
|
||||
} else {
|
||||
restoreTerminalStructuralScrollIntent(pane.terminal, scrollIntent)
|
||||
}
|
||||
} catch {
|
||||
// Why: SSH reattach can briefly expose xterm without renderer dimensions.
|
||||
} finally {
|
||||
if (pinnedScrollState) {
|
||||
releaseScrollStateMarker(pinnedScrollState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function settlePendingSafeFitContinuation(
|
||||
pane: ManagedPane,
|
||||
operationKey: string,
|
||||
pending: PendingSafeFitContinuation,
|
||||
completed: boolean
|
||||
): void {
|
||||
const operations = pendingSafeFitContinuations.get(pane)
|
||||
if (operations?.get(operationKey) !== pending) {
|
||||
return
|
||||
}
|
||||
operations.delete(operationKey)
|
||||
if (operations.size === 0) {
|
||||
pendingSafeFitContinuations.delete(pane)
|
||||
}
|
||||
pending.resolve(completed)
|
||||
}
|
||||
|
||||
function flushPendingSafeFitContinuations(pane: ManagedPane): void {
|
||||
const operations = pendingSafeFitContinuations.get(pane)
|
||||
if (!operations) {
|
||||
return
|
||||
}
|
||||
for (const [operationKey, pending] of operations) {
|
||||
if (!pending.shouldContinue()) {
|
||||
settlePendingSafeFitContinuation(pane, operationKey, pending, false)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
pending.continuation()
|
||||
settlePendingSafeFitContinuation(pane, operationKey, pending, true)
|
||||
} catch {
|
||||
settlePendingSafeFitContinuation(pane, operationKey, pending, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function safeFit(pane: ManagedPane): boolean {
|
||||
const completed = performSafeFit(pane)
|
||||
if (completed) {
|
||||
// Why: replay transactions may be waiting for renderer dimensions; any
|
||||
// successful ordinary fit is the event that makes their PTY grid authoritative.
|
||||
flushPendingSafeFitContinuations(pane)
|
||||
}
|
||||
return completed
|
||||
}
|
||||
|
||||
export function cancelPendingSafeFitContinuations(pane: ManagedPane): void {
|
||||
const operations = pendingSafeFitContinuations.get(pane)
|
||||
if (!operations) {
|
||||
return
|
||||
}
|
||||
pendingSafeFitContinuations.delete(pane)
|
||||
for (const pending of operations.values()) {
|
||||
pending.resolve(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: callers that forward xterm's grid to a PTY must wait for a measurable
|
||||
// fit or explicit lifecycle cancellation instead of observing replay dimensions.
|
||||
export function safeFitAndThen(
|
||||
pane: ManagedPane,
|
||||
operationKey: string,
|
||||
continuation: () => void,
|
||||
options: { shouldContinue?: () => boolean } = {}
|
||||
): SafeFitContinuationHandle {
|
||||
const operations = pendingSafeFitContinuations.get(pane) ?? new Map()
|
||||
const replaced = operations.get(operationKey)
|
||||
if (replaced) {
|
||||
settlePendingSafeFitContinuation(pane, operationKey, replaced, false)
|
||||
}
|
||||
let resolveCompletion = (_completed: boolean): void => {}
|
||||
const completion = new Promise<boolean>((resolve) => {
|
||||
resolveCompletion = resolve
|
||||
})
|
||||
const pending: PendingSafeFitContinuation = {
|
||||
continuation,
|
||||
shouldContinue: options.shouldContinue ?? (() => true),
|
||||
resolve: resolveCompletion
|
||||
}
|
||||
const currentOperations = pendingSafeFitContinuations.get(pane) ?? operations
|
||||
currentOperations.set(operationKey, pending)
|
||||
pendingSafeFitContinuations.set(pane, currentOperations)
|
||||
const cancel = (): void => {
|
||||
settlePendingSafeFitContinuation(pane, operationKey, pending, false)
|
||||
}
|
||||
if (!pending.shouldContinue()) {
|
||||
cancel()
|
||||
return { completion, cancel }
|
||||
}
|
||||
if (
|
||||
deferTerminalGeometryMutationDuringRebuild(
|
||||
pane.terminal,
|
||||
`safe-fit-and-then:${operationKey}`,
|
||||
() => {
|
||||
if (pendingSafeFitContinuations.get(pane)?.get(operationKey) === pending) {
|
||||
safeFit(pane)
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
return { completion, cancel }
|
||||
}
|
||||
safeFit(pane)
|
||||
return { completion, cancel }
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ManagedPaneInternal } from './pane-manager-types'
|
||||
import { disposePane } from './pane-lifecycle'
|
||||
import { restoreScrollStateAfterFit } from './pane-scroll'
|
||||
|
||||
function createPane(pendingInitialFitRafId: number | null): ManagedPaneInternal {
|
||||
const leafId = '11111111-1111-4111-8111-111111111111' as never
|
||||
|
|
@ -54,4 +55,31 @@ describe('pane initial fit lifecycle', () => {
|
|||
expect(pane.pendingInitialFitRafId).toBeNull()
|
||||
expect(panes.has(pane.id)).toBe(false)
|
||||
})
|
||||
|
||||
it('cancels pending fit scroll restoration before terminal disposal', () => {
|
||||
const cancelAnimationFrame = vi.fn()
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
vi.fn(() => 23)
|
||||
)
|
||||
vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrame)
|
||||
const pane = createPane(null)
|
||||
const marker = { line: 42, isDisposed: false, dispose: vi.fn() }
|
||||
|
||||
restoreScrollStateAfterFit(
|
||||
pane.terminal,
|
||||
{
|
||||
bufferType: 'normal',
|
||||
wasAtBottom: false,
|
||||
viewportY: 42,
|
||||
baseY: 100,
|
||||
firstVisibleLineMarker: marker as never
|
||||
},
|
||||
{ onRestored: vi.fn(), shouldRestore: () => true }
|
||||
)
|
||||
disposePane(pane, new Map([[pane.id, pane]]))
|
||||
|
||||
expect(cancelAnimationFrame).toHaveBeenCalledWith(23)
|
||||
expect(marker.dispose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ function createPane(): ManagedPaneInternal {
|
|||
loadAddon: vi.fn(),
|
||||
attachCustomWheelEventHandler: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
cols: 80,
|
||||
rows: 24
|
||||
} as never,
|
||||
container: {} as never,
|
||||
|
|
@ -57,7 +58,8 @@ function createPane(): ManagedPaneInternal {
|
|||
webglDisabledAfterContextLoss: false,
|
||||
hasComplexScriptOutput: false,
|
||||
fitAddon: {
|
||||
fit: vi.fn()
|
||||
fit: vi.fn(),
|
||||
proposeDimensions: vi.fn(() => ({ cols: 80, rows: 23 }))
|
||||
} as never,
|
||||
fitResizeObserver: null,
|
||||
pendingObservedFitRafId: null,
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ import {
|
|||
detachPaneFitResizeObserver
|
||||
} from './pane-fit-resize-observer'
|
||||
import { clearPendingSplitScrollRestore } from './pane-split-scroll'
|
||||
import { cancelDeferredScrollRestore } from './pane-scroll'
|
||||
import { activateOrcaTerminalUnicodeProvider } from '../../../../shared/terminal-unicode-provider'
|
||||
import { attachTerminalMouseWheelMultiplier } from './pane-terminal-mouse-wheel'
|
||||
import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent'
|
||||
import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent-dom-tracking'
|
||||
import { attachDomRendererFocusClassSync } from './pane-dom-focus-class-sync'
|
||||
import { attachWebgl, cancelPendingWebglRefresh, disposeWebgl } from './pane-webgl-renderer'
|
||||
import { configureLazyArabicShapingJoiner } from './terminal-arabic-shaping-joiner'
|
||||
|
|
@ -244,6 +245,13 @@ export function disposePane(
|
|||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
// Why: fit retries own xterm markers and frame callbacks independently of
|
||||
// split restoration; both must be released before terminal disposal.
|
||||
cancelDeferredScrollRestore(pane.terminal)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
pane.ligaturesAddon?.dispose()
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -124,6 +124,8 @@ export type ScrollState = {
|
|||
viewportY: number
|
||||
baseY: number
|
||||
firstVisibleLineMarker?: IMarker
|
||||
firstVisibleLogicalLineMarker?: IMarker
|
||||
firstVisibleLogicalCellOffset?: number
|
||||
}
|
||||
|
||||
export type ManagedPaneInternal = {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Terminal as HeadlessTerminal } from '@xterm/headless'
|
||||
import type { IMarker, Terminal } from '@xterm/xterm'
|
||||
import {
|
||||
captureScrollState,
|
||||
getTerminalOutputEpoch,
|
||||
recordTerminalOutput,
|
||||
restoreScrollState,
|
||||
restoreScrollStateAfterFit,
|
||||
restoreScrollStateAfterLayout
|
||||
} from './pane-scroll'
|
||||
import type { ScrollState } from './pane-manager-types'
|
||||
|
|
@ -59,6 +61,24 @@ function setMarkerLine(marker: IMarker, line: number): void {
|
|||
mutableMarker.line = line
|
||||
}
|
||||
|
||||
function writeHeadless(terminal: HeadlessTerminal, data: string): Promise<void> {
|
||||
return new Promise((resolve) => terminal.write(data, resolve))
|
||||
}
|
||||
|
||||
function findBufferLineContaining(terminal: HeadlessTerminal, text: string): number {
|
||||
for (let lineY = 0; lineY < terminal.buffer.active.length; lineY += 1) {
|
||||
if (terminal.buffer.active.getLine(lineY)?.translateToString(true).includes(text)) {
|
||||
return lineY
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function makeHeadlessRestorable(terminal: HeadlessTerminal): Terminal {
|
||||
Object.defineProperty(terminal, 'element', { configurable: true, value: {} })
|
||||
return terminal as unknown as Terminal
|
||||
}
|
||||
|
||||
describe('scroll state', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
|
|
@ -286,6 +306,61 @@ describe('scroll state', () => {
|
|||
expect(terminal.buffer.active.viewportY).toBe(30)
|
||||
})
|
||||
|
||||
it('releases fit markers when restoration throws an unexpected error', () => {
|
||||
const terminal = createTerminal({ viewportY: 10, baseY: 100 })
|
||||
const marker = createMarker(42)
|
||||
vi.mocked(terminal.scrollToLine).mockImplementation(() => {
|
||||
throw new Error('unexpected renderer failure')
|
||||
})
|
||||
const state: ScrollState = {
|
||||
bufferType: 'normal',
|
||||
wasAtBottom: false,
|
||||
viewportY: 42,
|
||||
baseY: 100,
|
||||
firstVisibleLineMarker: marker
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
restoreScrollStateAfterFit(terminal, state, {
|
||||
onRestored: vi.fn(),
|
||||
shouldRestore: () => true
|
||||
})
|
||||
).toThrow('unexpected renderer failure')
|
||||
expect(marker.isDisposed).toBe(true)
|
||||
})
|
||||
|
||||
it('releases fit markers when an asynchronous retry throws', () => {
|
||||
const frameCallbacks: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frameCallbacks.push(callback)
|
||||
return frameCallbacks.length
|
||||
})
|
||||
const terminal = createTerminal({ viewportY: 10, baseY: 100 })
|
||||
const marker = createMarker(42)
|
||||
vi.mocked(terminal.scrollToLine)
|
||||
.mockImplementationOnce(() => {
|
||||
throw new TypeError("Cannot read properties of undefined (reading 'dimensions')")
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('unexpected asynchronous renderer failure')
|
||||
})
|
||||
|
||||
restoreScrollStateAfterFit(
|
||||
terminal,
|
||||
{
|
||||
bufferType: 'normal',
|
||||
wasAtBottom: false,
|
||||
viewportY: 42,
|
||||
baseY: 100,
|
||||
firstVisibleLineMarker: marker
|
||||
},
|
||||
{ onRestored: vi.fn(), shouldRestore: () => true }
|
||||
)
|
||||
|
||||
expect(() => frameCallbacks.shift()?.(0)).toThrow('unexpected asynchronous renderer failure')
|
||||
expect(marker.isDisposed).toBe(true)
|
||||
})
|
||||
|
||||
it('scrolls to the current bottom when the pane was previously at bottom', () => {
|
||||
const terminal = createTerminal({ viewportY: 10, baseY: 250 })
|
||||
const state: ScrollState = {
|
||||
|
|
@ -316,4 +391,142 @@ describe('scroll state', () => {
|
|||
expect(terminal.scrollToLine).not.toHaveBeenCalled()
|
||||
expect(terminal.buffer.active.viewportY).toBe(10)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
fromCols: 10,
|
||||
toCols: 20,
|
||||
pinnedText: 'abcdefghij',
|
||||
expectedTop: 'ABCDEFGHIJabcdefghij'
|
||||
},
|
||||
{ fromCols: 20, toCols: 7, pinnedText: 'KLMNOPQRSTuvwxyz', expectedTop: 'efghijK' }
|
||||
])(
|
||||
'restores the same logical cells through real xterm reflow ($fromCols->$toCols)',
|
||||
async ({ fromCols, toCols, pinnedText, expectedTop }) => {
|
||||
const headless = new HeadlessTerminal({
|
||||
cols: fromCols,
|
||||
rows: 5,
|
||||
scrollback: 1000,
|
||||
allowProposedApi: true
|
||||
})
|
||||
try {
|
||||
await writeHeadless(headless, 'prefix\r\n')
|
||||
await writeHeadless(headless, 'ABCDEFGHIJabcdefghijKLMNOPQRSTuvwxyz\r\n')
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
await writeHeadless(headless, `tail-${index}\r\n`)
|
||||
}
|
||||
const pinnedLine = findBufferLineContaining(headless, pinnedText)
|
||||
expect(pinnedLine).toBeGreaterThan(0)
|
||||
headless.scrollToLine(pinnedLine)
|
||||
const terminal = makeHeadlessRestorable(headless)
|
||||
const state = captureScrollState(terminal)
|
||||
|
||||
headless.resize(toCols, 5)
|
||||
expect(state.firstVisibleLogicalLineMarker?.isDisposed).toBe(false)
|
||||
expect(restoreScrollState(terminal, state)).toBe(true)
|
||||
|
||||
expect(
|
||||
headless.buffer.active.getLine(headless.buffer.active.viewportY)?.translateToString(true)
|
||||
).toBe(expectedTop)
|
||||
} finally {
|
||||
headless.dispose()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('uses a logical marker for backend-only ConPTY compatibility', async () => {
|
||||
const headless = new HeadlessTerminal({
|
||||
cols: 10,
|
||||
rows: 5,
|
||||
scrollback: 1000,
|
||||
allowProposedApi: true,
|
||||
windowsPty: { backend: 'conpty' }
|
||||
})
|
||||
try {
|
||||
await writeHeadless(headless, 'prefix\r\n')
|
||||
await writeHeadless(headless, 'ABCDEFGHIJabcdefghijKLMNOPQRSTuvwxyz\r\n')
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
await writeHeadless(headless, `tail-${index}\r\n`)
|
||||
}
|
||||
const pinnedLine = findBufferLineContaining(headless, 'abcdefghij')
|
||||
headless.scrollToLine(pinnedLine)
|
||||
const terminal = makeHeadlessRestorable(headless)
|
||||
const state = captureScrollState(terminal)
|
||||
|
||||
expect(state.firstVisibleLogicalLineMarker).toBeDefined()
|
||||
headless.resize(20, 5)
|
||||
expect(restoreScrollState(terminal, state)).toBe(true)
|
||||
expect(
|
||||
headless.buffer.active.getLine(headless.buffer.active.viewportY)?.translateToString(true)
|
||||
).toBe('ABCDEFGHIJabcdefghij')
|
||||
} finally {
|
||||
headless.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a physical marker for the default non-reflowing cursor line', async () => {
|
||||
const headless = new HeadlessTerminal({
|
||||
cols: 10,
|
||||
rows: 3,
|
||||
scrollback: 100,
|
||||
allowProposedApi: true
|
||||
})
|
||||
try {
|
||||
await writeHeadless(headless, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
|
||||
const terminal = makeHeadlessRestorable(headless)
|
||||
headless.scrollLines(-1)
|
||||
const state = captureScrollState(terminal)
|
||||
|
||||
expect(state.firstVisibleLineMarker).toBeDefined()
|
||||
expect(state.firstVisibleLogicalLineMarker).toBeUndefined()
|
||||
headless.resize(20, 3)
|
||||
expect(restoreScrollState(terminal, state)).toBe(true)
|
||||
} finally {
|
||||
headless.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses physical markers for legacy ConPTY and logical markers for modern ConPTY', async () => {
|
||||
const captureForBuild = async (buildNumber: number): Promise<ScrollState> => {
|
||||
const headless = new HeadlessTerminal({
|
||||
cols: 10,
|
||||
rows: 3,
|
||||
scrollback: 100,
|
||||
allowProposedApi: true,
|
||||
windowsPty: { backend: 'conpty', buildNumber }
|
||||
})
|
||||
await writeHeadless(headless, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\r\n')
|
||||
await writeHeadless(headless, 'tail-1\r\ntail-2\r\ntail-3\r\n')
|
||||
const pinnedLine = findBufferLineContaining(headless, 'KLMNOPQRST')
|
||||
headless.scrollToLine(pinnedLine)
|
||||
const state = captureScrollState(makeHeadlessRestorable(headless))
|
||||
headless.dispose()
|
||||
return state
|
||||
}
|
||||
|
||||
const legacy = await captureForBuild(19045)
|
||||
const modern = await captureForBuild(26100)
|
||||
|
||||
expect(legacy.firstVisibleLogicalLineMarker).toBeUndefined()
|
||||
expect(modern.firstVisibleLogicalLineMarker).toBeDefined()
|
||||
})
|
||||
|
||||
it('counts a wide glyph wrap placeholder as zero logical cells', async () => {
|
||||
const headless = new HeadlessTerminal({
|
||||
cols: 10,
|
||||
rows: 3,
|
||||
scrollback: 100,
|
||||
allowProposedApi: true
|
||||
})
|
||||
try {
|
||||
await writeHeadless(headless, '123456789界abcdefghij\r\ntail-1\r\ntail-2\r\ntail-3\r\n')
|
||||
const pinnedLine = findBufferLineContaining(headless, '界')
|
||||
headless.scrollToLine(pinnedLine)
|
||||
const state = captureScrollState(makeHeadlessRestorable(headless))
|
||||
|
||||
expect(state.firstVisibleLogicalCellOffset).toBe(9)
|
||||
} finally {
|
||||
headless.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
import type { Terminal } from '@xterm/xterm'
|
||||
import type { ScrollState } from './pane-manager-types'
|
||||
import {
|
||||
captureLogicalLineAnchor,
|
||||
resolveLogicalCellOffsetLine
|
||||
} from './terminal-reflow-scroll-anchor'
|
||||
import { forceTerminalViewportScrollbarSync } from './terminal-viewport-scrollbar-sync'
|
||||
|
||||
const terminalOutputEpochs = new WeakMap<Terminal, number>()
|
||||
const deferredScrollRestores = new WeakMap<
|
||||
Terminal,
|
||||
object,
|
||||
{
|
||||
cancelled: boolean
|
||||
rafIds: number[]
|
||||
|
|
@ -11,6 +16,19 @@ const deferredScrollRestores = new WeakMap<
|
|||
timeoutIds: ReturnType<typeof setTimeout>[]
|
||||
}
|
||||
>()
|
||||
const pendingFitScrollRestores = new WeakMap<
|
||||
object,
|
||||
{
|
||||
cancelled: boolean
|
||||
rafId: number | null
|
||||
retryAfterFit: () => boolean
|
||||
shouldRestore: () => boolean
|
||||
state: ScrollState
|
||||
}
|
||||
>()
|
||||
const FIT_SCROLL_RESTORE_MAX_FRAMES = 2
|
||||
|
||||
type ScrollRestoreResult = 'restored' | 'retry' | 'skipped'
|
||||
|
||||
export function recordTerminalOutput(terminal: Terminal): void {
|
||||
terminalOutputEpochs.set(terminal, getTerminalOutputEpoch(terminal) + 1)
|
||||
|
|
@ -20,7 +38,8 @@ export function getTerminalOutputEpoch(terminal: Terminal): number {
|
|||
return terminalOutputEpochs.get(terminal) ?? 0
|
||||
}
|
||||
|
||||
export function cancelDeferredScrollRestore(terminal: Terminal): void {
|
||||
export function cancelDeferredScrollRestore(terminal: object): void {
|
||||
cancelPendingFitScrollRestore(terminal)
|
||||
const pending = deferredScrollRestores.get(terminal)
|
||||
if (!pending) {
|
||||
return
|
||||
|
|
@ -42,24 +61,138 @@ export function captureScrollState(terminal: Terminal): ScrollState {
|
|||
const buf = terminal.buffer.active
|
||||
const viewportY = buf.viewportY
|
||||
const wasAtBottom = viewportY >= buf.baseY
|
||||
const logicalAnchor =
|
||||
!wasAtBottom && buf.type === 'normal'
|
||||
? captureLogicalLineAnchor(terminal, viewportY)
|
||||
: undefined
|
||||
const firstVisibleLineMarker =
|
||||
!wasAtBottom && buf.type === 'normal'
|
||||
? terminal.registerMarker?.(viewportY - (buf.baseY + buf.cursorY))
|
||||
: undefined
|
||||
return {
|
||||
bufferType: buf.type,
|
||||
wasAtBottom,
|
||||
viewportY,
|
||||
baseY: buf.baseY,
|
||||
// Why: xterm markers track the same buffer line through resize reflow;
|
||||
// a numeric viewport line alone can point at different content afterward.
|
||||
firstVisibleLineMarker:
|
||||
!wasAtBottom && buf.type === 'normal'
|
||||
? terminal.registerMarker?.(viewportY - (buf.baseY + buf.cursorY))
|
||||
: undefined
|
||||
// Why: continuation-row markers can be deleted or drift during reflow.
|
||||
// Keep the physical marker for no-reflow ConPTY/cursor-line cases, and
|
||||
// anchor reflowing content at the logical line's stable first row.
|
||||
firstVisibleLineMarker,
|
||||
firstVisibleLogicalLineMarker:
|
||||
logicalAnchor?.lineY === viewportY
|
||||
? firstVisibleLineMarker
|
||||
: logicalAnchor
|
||||
? terminal.registerMarker?.(logicalAnchor.lineY - (buf.baseY + buf.cursorY))
|
||||
: undefined,
|
||||
firstVisibleLogicalCellOffset: logicalAnchor?.cellOffset
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreScrollState(terminal: Terminal, state: ScrollState): void {
|
||||
export function restoreScrollState(terminal: Terminal, state: ScrollState): boolean {
|
||||
cancelDeferredScrollRestore(terminal)
|
||||
restoreScrollStateNow(terminal, state)
|
||||
releaseScrollStateMarker(state)
|
||||
try {
|
||||
return restoreScrollStateNow(terminal, state) === 'restored'
|
||||
} finally {
|
||||
releaseScrollStateMarker(state)
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreScrollStateAfterFit(
|
||||
terminal: Terminal,
|
||||
state: ScrollState,
|
||||
options: { onRestored: () => void; shouldRestore: () => boolean }
|
||||
): void {
|
||||
cancelDeferredScrollRestore(terminal)
|
||||
if (!options.shouldRestore()) {
|
||||
releaseScrollStateMarker(state)
|
||||
return
|
||||
}
|
||||
let initialResult: ScrollRestoreResult
|
||||
try {
|
||||
initialResult = restoreScrollStateNow(terminal, state)
|
||||
} catch (error) {
|
||||
releaseScrollStateMarker(state)
|
||||
throw error
|
||||
}
|
||||
if (initialResult !== 'retry' || typeof requestAnimationFrame !== 'function') {
|
||||
releaseScrollStateMarker(state)
|
||||
if (initialResult === 'restored') {
|
||||
options.onRestored()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const pending = {
|
||||
cancelled: false,
|
||||
rafId: null as number | null,
|
||||
retryAfterFit: (): boolean => false,
|
||||
shouldRestore: options.shouldRestore,
|
||||
state
|
||||
}
|
||||
let remainingFrames = FIT_SCROLL_RESTORE_MAX_FRAMES
|
||||
const finish = (restored: boolean): void => {
|
||||
if (pending.cancelled) {
|
||||
return
|
||||
}
|
||||
pending.cancelled = true
|
||||
pendingFitScrollRestores.delete(terminal)
|
||||
releaseScrollStateMarker(state)
|
||||
if (restored && options.shouldRestore()) {
|
||||
options.onRestored()
|
||||
}
|
||||
}
|
||||
const retry = (): boolean => {
|
||||
pending.rafId = null
|
||||
if (pending.cancelled || !options.shouldRestore()) {
|
||||
finish(false)
|
||||
return false
|
||||
}
|
||||
let result: ScrollRestoreResult
|
||||
try {
|
||||
result = restoreScrollStateNow(terminal, state)
|
||||
} catch (error) {
|
||||
finish(false)
|
||||
throw error
|
||||
}
|
||||
if (result === 'restored') {
|
||||
finish(true)
|
||||
return true
|
||||
}
|
||||
remainingFrames -= 1
|
||||
if (result !== 'retry') {
|
||||
finish(false)
|
||||
return false
|
||||
}
|
||||
if (remainingFrames <= 0) {
|
||||
// Why: background/WebGL teardown can outlast a bounded frame retry.
|
||||
// Keep the content marker parked for the next real fit/reveal.
|
||||
return true
|
||||
}
|
||||
pending.rafId = requestAnimationFrame(retry)
|
||||
return true
|
||||
}
|
||||
pending.retryAfterFit = () => {
|
||||
if (pending.rafId !== null && typeof cancelAnimationFrame === 'function') {
|
||||
cancelAnimationFrame(pending.rafId)
|
||||
pending.rafId = null
|
||||
}
|
||||
remainingFrames = FIT_SCROLL_RESTORE_MAX_FRAMES + 1
|
||||
return retry()
|
||||
}
|
||||
pendingFitScrollRestores.set(terminal, pending)
|
||||
pending.rafId = requestAnimationFrame(retry)
|
||||
}
|
||||
|
||||
export function resumePendingFitScrollRestoreAfterFit(terminal: Terminal): boolean {
|
||||
const pending = pendingFitScrollRestores.get(terminal)
|
||||
if (!pending) {
|
||||
return false
|
||||
}
|
||||
if (!pending.shouldRestore()) {
|
||||
cancelPendingFitScrollRestore(terminal)
|
||||
return false
|
||||
}
|
||||
return pending.retryAfterFit()
|
||||
}
|
||||
|
||||
export function restoreScrollStateAfterLayout(terminal: Terminal, state: ScrollState): void {
|
||||
|
|
@ -114,13 +247,13 @@ export function restoreScrollStateAfterLayout(terminal: Terminal, state: ScrollS
|
|||
deferredScrollRestores.set(terminal, pending)
|
||||
}
|
||||
|
||||
function restoreScrollStateNow(terminal: Terminal, state: ScrollState): void {
|
||||
function restoreScrollStateNow(terminal: Terminal, state: ScrollState): ScrollRestoreResult {
|
||||
if (!terminal.element) {
|
||||
return
|
||||
return 'retry'
|
||||
}
|
||||
const buf = terminal.buffer.active
|
||||
if (state.bufferType === 'alternate' || buf.type !== state.bufferType) {
|
||||
return
|
||||
return 'skipped'
|
||||
}
|
||||
|
||||
// Why: WebGL suspend disposes xterm's render service while leaving
|
||||
|
|
@ -129,24 +262,42 @@ function restoreScrollStateNow(terminal: Terminal, state: ScrollState): void {
|
|||
// window quietly — the next visibility flip re-fits and re-restores.
|
||||
if (state.wasAtBottom) {
|
||||
if (safeScrollCall(() => terminal.scrollToBottom())) {
|
||||
forceViewportScrollbarSync(terminal)
|
||||
forceTerminalViewportScrollbarSync(terminal)
|
||||
return 'restored'
|
||||
}
|
||||
return
|
||||
return 'retry'
|
||||
}
|
||||
|
||||
const logicalMarkerLine =
|
||||
state.firstVisibleLogicalLineMarker && !state.firstVisibleLogicalLineMarker.isDisposed
|
||||
? state.firstVisibleLogicalLineMarker.line
|
||||
: -1
|
||||
const markerLine =
|
||||
state.firstVisibleLineMarker && !state.firstVisibleLineMarker.isDisposed
|
||||
? state.firstVisibleLineMarker.line
|
||||
: -1
|
||||
const targetLine = Math.min(markerLine >= 0 ? markerLine : state.viewportY, buf.baseY)
|
||||
const logicalTargetLine =
|
||||
logicalMarkerLine >= 0 && state.firstVisibleLogicalCellOffset !== undefined
|
||||
? resolveLogicalCellOffsetLine(
|
||||
terminal,
|
||||
logicalMarkerLine,
|
||||
state.firstVisibleLogicalCellOffset
|
||||
)
|
||||
: null
|
||||
const targetLine = Math.min(
|
||||
logicalTargetLine ?? (markerLine >= 0 ? markerLine : state.viewportY),
|
||||
buf.baseY
|
||||
)
|
||||
state.viewportY = targetLine
|
||||
// Why: deferred rAF/timeout restores re-invoke this function after xterm
|
||||
// reflow settles; keep the marker alive so each call consults the live
|
||||
// line. Callers (restoreScrollState, the timeout in
|
||||
// restoreScrollStateAfterLayout, cancelDeferredScrollRestore) own disposal.
|
||||
if (safeScrollCall(() => terminal.scrollToLine(targetLine))) {
|
||||
forceViewportScrollbarSync(terminal)
|
||||
forceTerminalViewportScrollbarSync(terminal)
|
||||
return 'restored'
|
||||
}
|
||||
return 'retry'
|
||||
}
|
||||
|
||||
function safeScrollCall(fn: () => void): boolean {
|
||||
|
|
@ -166,23 +317,21 @@ function safeScrollCall(fn: () => void): boolean {
|
|||
|
||||
export function releaseScrollStateMarker(state: ScrollState): void {
|
||||
state.firstVisibleLineMarker?.dispose()
|
||||
state.firstVisibleLineMarker = undefined
|
||||
if (state.firstVisibleLogicalLineMarker !== state.firstVisibleLineMarker) {
|
||||
state.firstVisibleLogicalLineMarker?.dispose()
|
||||
}
|
||||
state.firstVisibleLineMarker = state.firstVisibleLogicalLineMarker = undefined
|
||||
}
|
||||
|
||||
// Why: xterm 6 can leave its scrollbar thumb stale when ydisp is unchanged.
|
||||
// A synchronous one-line jiggle updates the scrollbar without a visible paint.
|
||||
function forceViewportScrollbarSync(terminal: Terminal): void {
|
||||
const buf = terminal.buffer.active
|
||||
if (buf.viewportY >= buf.baseY) {
|
||||
// Why: jiggle-scrolling at bottom makes xterm stop following active output
|
||||
// after split-pane resizes; scrollToBottom already places the thumb there.
|
||||
function cancelPendingFitScrollRestore(terminal: object): void {
|
||||
const pending = pendingFitScrollRestores.get(terminal)
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
if (buf.viewportY > 0) {
|
||||
safeScrollCall(() => terminal.scrollLines(-1))
|
||||
safeScrollCall(() => terminal.scrollLines(1))
|
||||
} else if (buf.viewportY < buf.baseY) {
|
||||
safeScrollCall(() => terminal.scrollLines(1))
|
||||
safeScrollCall(() => terminal.scrollLines(-1))
|
||||
pending.cancelled = true
|
||||
if (pending.rafId !== null && typeof cancelAnimationFrame === 'function') {
|
||||
cancelAnimationFrame(pending.rafId)
|
||||
}
|
||||
releaseScrollStateMarker(pending.state)
|
||||
pendingFitScrollRestores.delete(terminal)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,6 @@ import {
|
|||
writeForegroundTerminalChunk,
|
||||
type ForegroundTerminalOutputTarget
|
||||
} from './pane-terminal-foreground-render-settle'
|
||||
import {
|
||||
captureTerminalWriteScrollIntent,
|
||||
enforceTerminalWriteScrollIntent
|
||||
} from './terminal-scroll-intent'
|
||||
import { runGuardedWriteCompletionStep } from './xterm-write-callback-guard'
|
||||
import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder'
|
||||
import {
|
||||
|
|
@ -833,6 +829,9 @@ function hasDrainableBacklog(): boolean {
|
|||
return false
|
||||
}
|
||||
|
||||
// Why no per-write scroll enforcement: xterm's BufferService.isUserScrolling
|
||||
// natively owns live follow/pin semantics. App-side intent enforcement is
|
||||
// limited to structural operations xterm cannot identify, such as replay.
|
||||
function writeBackgroundTerminalChunk(
|
||||
terminal: TerminalOutputTarget,
|
||||
data: string,
|
||||
|
|
@ -848,29 +847,13 @@ function writeBackgroundTerminalChunk(
|
|||
const runOnWriteFailure = onWriteFailure
|
||||
? (): void => runGuardedWriteCompletionStep('background-on-write-failure', onWriteFailure)
|
||||
: undefined
|
||||
const scrollIntent = captureTerminalWriteScrollIntent(terminal)
|
||||
try {
|
||||
if (!scrollIntent) {
|
||||
if (!runOnParsed || terminal.write.length < 2) {
|
||||
terminal.write(data)
|
||||
runOnParsed?.()
|
||||
return true
|
||||
}
|
||||
terminal.write(data, runOnParsed)
|
||||
return true
|
||||
}
|
||||
const runScrollIntentThenParsed = (): void => {
|
||||
runGuardedWriteCompletionStep('background-scroll-intent', () =>
|
||||
enforceTerminalWriteScrollIntent(terminal, scrollIntent)
|
||||
)
|
||||
runOnParsed?.()
|
||||
}
|
||||
if (terminal.write.length < 2) {
|
||||
if (!runOnParsed || terminal.write.length < 2) {
|
||||
terminal.write(data)
|
||||
runScrollIntentThenParsed()
|
||||
runOnParsed?.()
|
||||
return true
|
||||
}
|
||||
terminal.write(data, runScrollIntentThenParsed)
|
||||
terminal.write(data, runOnParsed)
|
||||
return true
|
||||
} catch {
|
||||
runOnWriteFailure?.()
|
||||
|
|
@ -878,32 +861,6 @@ function writeBackgroundTerminalChunk(
|
|||
}
|
||||
}
|
||||
|
||||
function writeForegroundTerminalChunkWithIntent(
|
||||
terminal: TerminalOutputTarget,
|
||||
data: string,
|
||||
options: {
|
||||
forceViewportRefresh: boolean
|
||||
followupViewportRefresh: boolean
|
||||
shouldRefreshViewportSynchronously: ForegroundRefreshSyncResolver
|
||||
onParsed?: TerminalOutputParsedCallback
|
||||
onWriteFailure?: () => void
|
||||
}
|
||||
): boolean {
|
||||
const scrollIntent = captureTerminalWriteScrollIntent(terminal)
|
||||
return writeForegroundTerminalChunk(terminal, data, {
|
||||
forceViewportRefresh: options.forceViewportRefresh,
|
||||
followupViewportRefresh: options.followupViewportRefresh,
|
||||
shouldRefreshViewportSynchronously: options.shouldRefreshViewportSynchronously,
|
||||
onParsed: () => {
|
||||
// Why: recovery must repaint from the scrolled buffer state that xterm
|
||||
// will keep, not from a pre-intent-restored viewport snapshot.
|
||||
enforceTerminalWriteScrollIntent(terminal, scrollIntent)
|
||||
options.onParsed?.()
|
||||
},
|
||||
onWriteFailure: options.onWriteFailure
|
||||
})
|
||||
}
|
||||
|
||||
function takeNextDrainableEntry(): QueueEntry | null {
|
||||
let largeBacklogEntry: QueueEntry | null = null
|
||||
for (const entry of queuedByTerminal.values()) {
|
||||
|
|
@ -1015,7 +972,7 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null
|
|||
try {
|
||||
queuedWrite.beforeWrite?.(queuedWrite.data)
|
||||
const writeAccepted = queuedWrite.foreground
|
||||
? writeForegroundTerminalChunkWithIntent(
|
||||
? writeForegroundTerminalChunk(
|
||||
entry.terminal,
|
||||
queuedWrite.stripTransientCursorShows
|
||||
? removeTransientCursorShowSequences(queuedWrite.data)
|
||||
|
|
@ -1052,8 +1009,8 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null
|
|||
return null
|
||||
}
|
||||
} catch {
|
||||
// Why: beforeWrite or pre-write viewport capture can fail before xterm owns
|
||||
// the bytes. Cancel the armed watch without claiming parser failure.
|
||||
// Why: beforeWrite or write setup can fail before xterm owns the bytes.
|
||||
// Cancel the armed watch without claiming parser failure.
|
||||
cancelTerminalWriteStallWatch(entry.terminal)
|
||||
ackCreditsParsed?.()
|
||||
fireQueuedAckCredits(entry)
|
||||
|
|
@ -1305,7 +1262,7 @@ export function writeTerminalOutput(
|
|||
})
|
||||
try {
|
||||
options.beforeWrite?.(data)
|
||||
writeForegroundTerminalChunkWithIntent(
|
||||
writeForegroundTerminalChunk(
|
||||
terminal,
|
||||
options.stripTransientCursorShows ? removeTransientCursorShowSequences(data) : data,
|
||||
{
|
||||
|
|
@ -1399,7 +1356,7 @@ export function flushTerminalOutput(
|
|||
try {
|
||||
queuedWrite.beforeWrite?.(queuedWrite.data)
|
||||
const writeAccepted = queuedWrite.foreground
|
||||
? writeForegroundTerminalChunkWithIntent(
|
||||
? writeForegroundTerminalChunk(
|
||||
terminal,
|
||||
queuedWrite.stripTransientCursorShows
|
||||
? removeTransientCursorShowSequences(queuedWrite.data)
|
||||
|
|
@ -1431,7 +1388,7 @@ export function flushTerminalOutput(
|
|||
return
|
||||
}
|
||||
} catch {
|
||||
// Why: pre-write hooks/capture failed before xterm owned these bytes.
|
||||
// Why: pre-write hooks/setup failed before xterm owned these bytes.
|
||||
// Cancel the watch; consumed + abandoned chunks still credit delivery.
|
||||
cancelTerminalWriteStallWatch(terminal)
|
||||
ackCreditsParsed?.()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,23 @@
|
|||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { equalizePaneSplitSizes, safeFit } from './pane-tree-ops'
|
||||
import {
|
||||
cancelPendingSafeFitContinuations,
|
||||
equalizePaneSplitSizes,
|
||||
safeFit,
|
||||
safeFitAndThen
|
||||
} from './pane-tree-ops'
|
||||
import type { ManagedPaneInternal, ScrollState } from './pane-manager-types'
|
||||
import { setFitOverride, hydrateOverrides } from './mobile-fit-overrides'
|
||||
import {
|
||||
captureTerminalStructuralScrollIntent,
|
||||
enforceTerminalCurrentScrollIntent,
|
||||
markTerminalPinnedViewport,
|
||||
restoreTerminalStructuralScrollIntent
|
||||
} from './terminal-scroll-intent'
|
||||
import {
|
||||
beginTerminalScrollIntentBufferRebuild,
|
||||
cancelTerminalScrollIntentBufferRebuildCompletions,
|
||||
endTerminalScrollIntentBufferRebuild
|
||||
} from './terminal-scroll-intent-rebuild'
|
||||
|
||||
class MockHTMLElement {
|
||||
classList: { contains: (cls: string) => boolean }
|
||||
|
|
@ -21,6 +37,7 @@ beforeAll(() => {
|
|||
|
||||
afterEach(() => {
|
||||
hydrateOverrides([])
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function createPane({
|
||||
|
|
@ -178,6 +195,54 @@ describe('safeFit', () => {
|
|||
expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('coalesces fits until replay parsing and scroll restoration complete', async () => {
|
||||
const pane = createPane({
|
||||
proposedCols: 100,
|
||||
proposedRows: 32,
|
||||
terminalCols: 120,
|
||||
terminalRows: 32
|
||||
})
|
||||
const activeBuffer = pane.terminal.buffer.active as { viewportY: number; baseY: number }
|
||||
activeBuffer.viewportY = 80
|
||||
activeBuffer.baseY = 100
|
||||
markTerminalPinnedViewport(pane.terminal)
|
||||
const intent = captureTerminalStructuralScrollIntent(pane.terminal)
|
||||
beginTerminalScrollIntentBufferRebuild(pane.terminal)
|
||||
activeBuffer.viewportY = 0
|
||||
activeBuffer.baseY = 0
|
||||
|
||||
safeFit(pane)
|
||||
safeFit(pane)
|
||||
expect(pane.fitAddon.fit).not.toHaveBeenCalled()
|
||||
|
||||
activeBuffer.viewportY = 200
|
||||
activeBuffer.baseY = 200
|
||||
vi.mocked(pane.fitAddon.fit).mockImplementation(() => {
|
||||
expect(activeBuffer.viewportY).toBe(180)
|
||||
})
|
||||
endTerminalScrollIntentBufferRebuild(pane.terminal)
|
||||
restoreTerminalStructuralScrollIntent(pane.terminal, intent, { restoreBy: 'bottomOffset' })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('drops a deferred fit when a replay rebuild is canceled', async () => {
|
||||
const pane = createPane({
|
||||
proposedCols: 100,
|
||||
proposedRows: 32,
|
||||
terminalCols: 120,
|
||||
terminalRows: 32
|
||||
})
|
||||
beginTerminalScrollIntentBufferRebuild(pane.terminal)
|
||||
safeFit(pane)
|
||||
cancelTerminalScrollIntentBufferRebuildCompletions(pane.terminal)
|
||||
endTerminalScrollIntentBufferRebuild(pane.terminal)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(pane.fitAddon.fit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores the viewport if fit clobbers it during resize', () => {
|
||||
const pane = createPane({
|
||||
proposedCols: 100,
|
||||
|
|
@ -231,6 +296,66 @@ describe('safeFit', () => {
|
|||
expect(marker.dispose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records the restored post-reflow pin when widening lowers baseY', () => {
|
||||
const pane = createPane({
|
||||
proposedCols: 160,
|
||||
proposedRows: 32,
|
||||
terminalCols: 80,
|
||||
terminalRows: 32
|
||||
})
|
||||
const activeBuffer = pane.terminal.buffer.active as {
|
||||
viewportY: number
|
||||
baseY: number
|
||||
cursorY?: number
|
||||
}
|
||||
activeBuffer.viewportY = 42
|
||||
activeBuffer.baseY = 100
|
||||
activeBuffer.cursorY = 0
|
||||
const marker = { line: 42, isDisposed: false, dispose: vi.fn() }
|
||||
;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi.fn(() => marker)
|
||||
markTerminalPinnedViewport(pane.terminal)
|
||||
vi.mocked(pane.fitAddon.fit).mockImplementation(() => {
|
||||
activeBuffer.baseY = 70
|
||||
activeBuffer.viewportY = 0
|
||||
marker.line = 30
|
||||
})
|
||||
|
||||
safeFit(pane)
|
||||
activeBuffer.viewportY = 0
|
||||
vi.mocked(pane.terminal.scrollToLine).mockClear()
|
||||
enforceTerminalCurrentScrollIntent(pane.terminal)
|
||||
|
||||
expect(pane.terminal.scrollToLine).toHaveBeenLastCalledWith(30)
|
||||
})
|
||||
|
||||
it('preserves a durable pin when the remounted fit buffer is still empty', () => {
|
||||
const pane = createPane({
|
||||
proposedCols: 100,
|
||||
proposedRows: 32,
|
||||
terminalCols: 80,
|
||||
terminalRows: 24
|
||||
})
|
||||
const activeBuffer = pane.terminal.buffer.active as {
|
||||
viewportY: number
|
||||
baseY: number
|
||||
cursorY?: number
|
||||
}
|
||||
activeBuffer.viewportY = 42
|
||||
activeBuffer.baseY = 100
|
||||
activeBuffer.cursorY = 0
|
||||
markTerminalPinnedViewport(pane.terminal)
|
||||
|
||||
activeBuffer.viewportY = 0
|
||||
activeBuffer.baseY = 0
|
||||
safeFit(pane)
|
||||
|
||||
activeBuffer.viewportY = 0
|
||||
activeBuffer.baseY = 80
|
||||
vi.mocked(pane.terminal.scrollToLine).mockClear()
|
||||
enforceTerminalCurrentScrollIntent(pane.terminal)
|
||||
expect(pane.terminal.scrollToLine).toHaveBeenLastCalledWith(22)
|
||||
})
|
||||
|
||||
it('keeps a follow-output pane at the bottom through fit', () => {
|
||||
const pane = createPane({
|
||||
proposedCols: 100,
|
||||
|
|
@ -251,22 +376,236 @@ describe('safeFit', () => {
|
|||
expect(pane.terminal.scrollToBottom).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not throw when xterm rejects scroll restoration during layout', () => {
|
||||
it('retries a transient dimensions failure before recording the post-fit pin', () => {
|
||||
const frameCallbacks: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frameCallbacks.push(callback)
|
||||
return frameCallbacks.length
|
||||
})
|
||||
const pane = createPane({
|
||||
proposedCols: 100,
|
||||
proposedRows: 32,
|
||||
terminalCols: 120,
|
||||
terminalRows: 32
|
||||
})
|
||||
const activeBuffer = pane.terminal.buffer.active as { viewportY: number; baseY: number }
|
||||
const activeBuffer = pane.terminal.buffer.active as {
|
||||
viewportY: number
|
||||
baseY: number
|
||||
cursorY?: number
|
||||
}
|
||||
activeBuffer.viewportY = 42
|
||||
activeBuffer.baseY = 100
|
||||
vi.mocked(pane.terminal.scrollToLine).mockImplementation(() => {
|
||||
throw new TypeError("Cannot read properties of undefined (reading 'dimensions')")
|
||||
activeBuffer.cursorY = 0
|
||||
const marker = { line: 42, isDisposed: false, dispose: vi.fn() }
|
||||
;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi.fn(() => marker)
|
||||
markTerminalPinnedViewport(pane.terminal)
|
||||
vi.mocked(pane.fitAddon.fit).mockImplementation(() => {
|
||||
activeBuffer.baseY = 70
|
||||
activeBuffer.viewportY = 0
|
||||
marker.line = 30
|
||||
})
|
||||
vi.mocked(pane.terminal.scrollToLine)
|
||||
.mockImplementationOnce(() => {
|
||||
throw new TypeError("Cannot read properties of undefined (reading 'dimensions')")
|
||||
})
|
||||
.mockImplementation((line: number) => {
|
||||
activeBuffer.viewportY = line
|
||||
})
|
||||
|
||||
expect(() => safeFit(pane)).not.toThrow()
|
||||
expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1)
|
||||
expect(activeBuffer.viewportY).toBe(0)
|
||||
expect(marker.dispose).not.toHaveBeenCalled()
|
||||
|
||||
frameCallbacks.shift()?.(0)
|
||||
expect(activeBuffer.viewportY).toBe(30)
|
||||
expect(marker.dispose).toHaveBeenCalled()
|
||||
|
||||
activeBuffer.viewportY = 0
|
||||
vi.mocked(pane.terminal.scrollToLine).mockClear()
|
||||
enforceTerminalCurrentScrollIntent(pane.terminal)
|
||||
expect(pane.terminal.scrollToLine).toHaveBeenLastCalledWith(30)
|
||||
})
|
||||
|
||||
it('cancels a pending fit retry when snapshot replay starts', () => {
|
||||
const frameCallbacks: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frameCallbacks.push(callback)
|
||||
return frameCallbacks.length
|
||||
})
|
||||
const pane = createPane({
|
||||
proposedCols: 100,
|
||||
proposedRows: 32,
|
||||
terminalCols: 120,
|
||||
terminalRows: 32
|
||||
})
|
||||
const activeBuffer = pane.terminal.buffer.active as {
|
||||
viewportY: number
|
||||
baseY: number
|
||||
cursorY?: number
|
||||
}
|
||||
activeBuffer.viewportY = 42
|
||||
activeBuffer.baseY = 100
|
||||
activeBuffer.cursorY = 0
|
||||
const marker = { line: 30, isDisposed: false, dispose: vi.fn() }
|
||||
;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi.fn(() => marker)
|
||||
markTerminalPinnedViewport(pane.terminal)
|
||||
vi.mocked(pane.fitAddon.fit).mockImplementation(() => {
|
||||
activeBuffer.baseY = 70
|
||||
activeBuffer.viewportY = 0
|
||||
})
|
||||
vi.mocked(pane.terminal.scrollToLine).mockImplementationOnce(() => {
|
||||
throw new TypeError("Cannot read properties of undefined (reading 'dimensions')")
|
||||
})
|
||||
|
||||
safeFit(pane)
|
||||
beginTerminalScrollIntentBufferRebuild(pane.terminal)
|
||||
frameCallbacks.shift()?.(0)
|
||||
|
||||
expect(pane.terminal.scrollToLine).toHaveBeenCalledTimes(1)
|
||||
expect(marker.dispose).toHaveBeenCalledTimes(1)
|
||||
endTerminalScrollIntentBufferRebuild(pane.terminal)
|
||||
})
|
||||
|
||||
it('carries the original fit marker across another fit before retry', () => {
|
||||
const frameCallbacks: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frameCallbacks.push(callback)
|
||||
return frameCallbacks.length
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn())
|
||||
const pane = createPane({
|
||||
proposedCols: 100,
|
||||
proposedRows: 32,
|
||||
terminalCols: 120,
|
||||
terminalRows: 32
|
||||
})
|
||||
const activeBuffer = pane.terminal.buffer.active as {
|
||||
viewportY: number
|
||||
baseY: number
|
||||
cursorY?: number
|
||||
}
|
||||
activeBuffer.viewportY = 42
|
||||
activeBuffer.baseY = 100
|
||||
activeBuffer.cursorY = 0
|
||||
const originalMarker = { line: 30, isDisposed: false, dispose: vi.fn() }
|
||||
const replacementMarker = { line: 5, isDisposed: false, dispose: vi.fn() }
|
||||
;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(originalMarker)
|
||||
.mockReturnValueOnce(replacementMarker)
|
||||
vi.mocked(pane.fitAddon.fit).mockImplementation(() => {
|
||||
activeBuffer.baseY = 70
|
||||
activeBuffer.viewportY = 0
|
||||
})
|
||||
vi.mocked(pane.terminal.scrollToLine)
|
||||
.mockImplementationOnce(() => {
|
||||
throw new TypeError("Cannot read properties of undefined (reading 'dimensions')")
|
||||
})
|
||||
.mockImplementation((line: number) => {
|
||||
activeBuffer.viewportY = line
|
||||
})
|
||||
markTerminalPinnedViewport(pane.terminal)
|
||||
|
||||
safeFit(pane)
|
||||
safeFit(pane)
|
||||
|
||||
expect(activeBuffer.viewportY).toBe(30)
|
||||
expect(originalMarker.dispose).toHaveBeenCalledTimes(1)
|
||||
expect(replacementMarker.dispose).toHaveBeenCalledTimes(1)
|
||||
expect(frameCallbacks).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('resumes an exhausted dimensions retry on a same-grid reveal fit', () => {
|
||||
const frameCallbacks: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frameCallbacks.push(callback)
|
||||
return frameCallbacks.length
|
||||
})
|
||||
const pane = createPane({
|
||||
proposedCols: 100,
|
||||
proposedRows: 32,
|
||||
terminalCols: 120,
|
||||
terminalRows: 32
|
||||
})
|
||||
const activeBuffer = pane.terminal.buffer.active as {
|
||||
viewportY: number
|
||||
baseY: number
|
||||
cursorY?: number
|
||||
}
|
||||
activeBuffer.viewportY = 42
|
||||
activeBuffer.baseY = 100
|
||||
activeBuffer.cursorY = 0
|
||||
const marker = { line: 30, isDisposed: false, dispose: vi.fn() }
|
||||
;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi.fn(() => marker)
|
||||
vi.mocked(pane.fitAddon.fit).mockImplementation(() => {
|
||||
activeBuffer.baseY = 70
|
||||
activeBuffer.viewportY = 0
|
||||
})
|
||||
vi.mocked(pane.terminal.scrollToLine).mockImplementation(() => {
|
||||
throw new TypeError("Cannot read properties of undefined (reading 'dimensions')")
|
||||
})
|
||||
markTerminalPinnedViewport(pane.terminal)
|
||||
|
||||
safeFit(pane)
|
||||
frameCallbacks.shift()?.(0)
|
||||
frameCallbacks.shift()?.(0)
|
||||
expect(marker.dispose).not.toHaveBeenCalled()
|
||||
|
||||
vi.mocked(pane.terminal.scrollToLine).mockImplementation((line: number) => {
|
||||
activeBuffer.viewportY = line
|
||||
})
|
||||
;(pane.terminal as unknown as { cols: number }).cols = 100
|
||||
safeFit(pane)
|
||||
expect(activeBuffer.viewportY).toBe(30)
|
||||
expect(marker.dispose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('releases a replacement marker when a resumed retry throws', () => {
|
||||
const frameCallbacks: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frameCallbacks.push(callback)
|
||||
return frameCallbacks.length
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn())
|
||||
const pane = createPane({
|
||||
proposedCols: 100,
|
||||
proposedRows: 32,
|
||||
terminalCols: 120,
|
||||
terminalRows: 32
|
||||
})
|
||||
const activeBuffer = pane.terminal.buffer.active as {
|
||||
viewportY: number
|
||||
baseY: number
|
||||
cursorY?: number
|
||||
}
|
||||
activeBuffer.viewportY = 42
|
||||
activeBuffer.baseY = 100
|
||||
activeBuffer.cursorY = 0
|
||||
const originalMarker = { line: 30, isDisposed: false, dispose: vi.fn() }
|
||||
const replacementMarker = { line: 5, isDisposed: false, dispose: vi.fn() }
|
||||
;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(originalMarker)
|
||||
.mockReturnValueOnce(replacementMarker)
|
||||
vi.mocked(pane.fitAddon.fit).mockImplementation(() => {
|
||||
activeBuffer.baseY = 70
|
||||
activeBuffer.viewportY = 0
|
||||
})
|
||||
vi.mocked(pane.terminal.scrollToLine)
|
||||
.mockImplementationOnce(() => {
|
||||
throw new TypeError("Cannot read properties of undefined (reading 'dimensions')")
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('unexpected resumed restore failure')
|
||||
})
|
||||
markTerminalPinnedViewport(pane.terminal)
|
||||
|
||||
safeFit(pane)
|
||||
expect(() => safeFit(pane)).not.toThrow()
|
||||
|
||||
expect(originalMarker.dispose).toHaveBeenCalledTimes(1)
|
||||
expect(replacementMarker.dispose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('still refits when a split-scroll lock is active and the grid changed', () => {
|
||||
|
|
@ -396,6 +735,73 @@ describe('safeFit', () => {
|
|||
expect(paneB.fitAddon.fit).toHaveBeenCalledTimes(1)
|
||||
expect(paneB.terminal.resize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs an authoritative continuation only after a deferred replay fit', async () => {
|
||||
const pane = createPane({
|
||||
proposedCols: 100,
|
||||
proposedRows: 32,
|
||||
terminalCols: 80,
|
||||
terminalRows: 24
|
||||
})
|
||||
vi.mocked(pane.fitAddon.fit).mockImplementation(() => {
|
||||
pane.terminal.resize(100, 32)
|
||||
})
|
||||
const observedDimensions: { cols: number; rows: number }[] = []
|
||||
beginTerminalScrollIntentBufferRebuild(pane.terminal)
|
||||
|
||||
safeFitAndThen(pane, 'pty-resize', () => {
|
||||
observedDimensions.push({ cols: pane.terminal.cols, rows: pane.terminal.rows })
|
||||
})
|
||||
|
||||
expect(pane.fitAddon.fit).not.toHaveBeenCalled()
|
||||
expect(observedDimensions).toEqual([])
|
||||
endTerminalScrollIntentBufferRebuild(pane.terminal)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1)
|
||||
expect(observedDimensions).toEqual([{ cols: 100, rows: 32 }])
|
||||
})
|
||||
|
||||
it('retains an authoritative continuation until a later measurable fit succeeds', async () => {
|
||||
const pane = createPane({
|
||||
proposedCols: 100,
|
||||
proposedRows: 32,
|
||||
terminalCols: 80,
|
||||
terminalRows: 24
|
||||
})
|
||||
vi.mocked(pane.fitAddon.proposeDimensions).mockReturnValue(undefined)
|
||||
const continuation = vi.fn()
|
||||
|
||||
const pending = safeFitAndThen(pane, 'pty-resize', continuation)
|
||||
|
||||
expect(continuation).not.toHaveBeenCalled()
|
||||
vi.mocked(pane.fitAddon.proposeDimensions).mockReturnValue({ cols: 100, rows: 32 })
|
||||
safeFit(pane)
|
||||
|
||||
await expect(pending.completion).resolves.toBe(true)
|
||||
expect(continuation).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('cancels an authoritative fit continuation disposed before its post-replay microtask', async () => {
|
||||
const pane = createPane({
|
||||
proposedCols: 100,
|
||||
proposedRows: 32,
|
||||
terminalCols: 80,
|
||||
terminalRows: 24
|
||||
})
|
||||
const continuation = vi.fn()
|
||||
beginTerminalScrollIntentBufferRebuild(pane.terminal)
|
||||
const pending = safeFitAndThen(pane, 'pty-resize', continuation)
|
||||
|
||||
endTerminalScrollIntentBufferRebuild(pane.terminal)
|
||||
cancelTerminalScrollIntentBufferRebuildCompletions(pane.terminal)
|
||||
cancelPendingSafeFitContinuations(pane)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(pane.fitAddon.fit).not.toHaveBeenCalled()
|
||||
expect(continuation).not.toHaveBeenCalled()
|
||||
await expect(pending.completion).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('equalizePaneSplitSizes', () => {
|
||||
|
|
|
|||
|
|
@ -6,16 +6,15 @@ import type {
|
|||
PaneStyleOptions
|
||||
} from './pane-manager-types'
|
||||
import { createDivider, disposeDivider } from './pane-divider'
|
||||
import { getFitOverrideForPty } from './mobile-fit-overrides'
|
||||
import { disposeWebgl, attachWebgl } from './pane-webgl-renderer'
|
||||
import {
|
||||
captureTerminalWriteScrollIntent,
|
||||
enforceTerminalWriteScrollIntent,
|
||||
syncTerminalScrollIntentFromViewport
|
||||
} from './terminal-scroll-intent'
|
||||
import { captureScrollState, releaseScrollStateMarker, restoreScrollState } from './pane-scroll'
|
||||
import type { ScrollState } from './pane-manager-types'
|
||||
import { safeFit } from './pane-fit'
|
||||
|
||||
export {
|
||||
cancelPendingSafeFitContinuations,
|
||||
safeFit,
|
||||
safeFitAndThen,
|
||||
type SafeFitContinuationHandle
|
||||
} from './pane-fit'
|
||||
export { captureScrollState, restoreScrollState } from './pane-scroll'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -33,111 +32,6 @@ type TreeOpsCallbacks = {
|
|||
requestPaneReparentFrame?: (callback: FrameRequestCallback) => void
|
||||
}
|
||||
|
||||
const MIN_PANE_FIT_WIDTH_PX = 48
|
||||
const MIN_PANE_FIT_HEIGHT_PX = 24
|
||||
const MIN_PANE_FIT_COLS = 8
|
||||
const MIN_PANE_FIT_ROWS = 4
|
||||
|
||||
function getProposedDimensions(pane: ManagedPane): { cols: number; rows: number } | null {
|
||||
try {
|
||||
return pane.fitAddon.proposeDimensions() ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function canMeasurePaneForFit(pane: ManagedPane): boolean {
|
||||
const measure = pane.container.getBoundingClientRect
|
||||
if (typeof measure === 'function') {
|
||||
const rect = measure.call(pane.container)
|
||||
if (rect.width < MIN_PANE_FIT_WIDTH_PX || rect.height < MIN_PANE_FIT_HEIGHT_PX) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const dims = getProposedDimensions(pane)
|
||||
if (!dims) {
|
||||
return false
|
||||
}
|
||||
// Why: worktree switches can briefly measure a near-zero overlay before
|
||||
// fallback positioning lands. Fitting there pins the PTY at ~2 cols until
|
||||
// the next user-driven resize.
|
||||
return dims.cols >= MIN_PANE_FIT_COLS && dims.rows >= MIN_PANE_FIT_ROWS
|
||||
}
|
||||
|
||||
function canPreserveScrollIntentForFit(pane: ManagedPane): boolean {
|
||||
// Why: split reparent has its own delayed restore; restoring here can fight that timer.
|
||||
return !(
|
||||
'pendingSplitScrollState' in pane && (pane as ManagedPaneInternal).pendingSplitScrollState
|
||||
)
|
||||
}
|
||||
|
||||
export function safeFit(pane: ManagedPane): void {
|
||||
if (!canMeasurePaneForFit(pane)) {
|
||||
return
|
||||
}
|
||||
let scrollIntent = null as ReturnType<typeof captureTerminalWriteScrollIntent>
|
||||
let pinnedScrollState: ScrollState | null = null
|
||||
let shouldRestoreScroll = false
|
||||
const captureScrollForFit = (): void => {
|
||||
scrollIntent = captureTerminalWriteScrollIntent(pane.terminal)
|
||||
// Why: fit can reflow and renumber every buffer row; a marker tracks the
|
||||
// pinned content itself, while a numeric line would point elsewhere after.
|
||||
pinnedScrollState =
|
||||
scrollIntent?.kind === 'pinnedViewport' ? captureScrollState(pane.terminal) : null
|
||||
shouldRestoreScroll = true
|
||||
}
|
||||
try {
|
||||
// Why: when a mobile client has resized this PTY to phone dimensions,
|
||||
// the desktop must keep xterm at those dimensions instead of fitting to
|
||||
// the desktop pane geometry. This prevents desktop auto-fit from undoing
|
||||
// the mobile resize. Uses data-pty-id (set by bindPanePtyId) to look up
|
||||
// the override by ptyId directly, avoiding pane ID collisions across tabs.
|
||||
const ptyId = pane.container.dataset.ptyId
|
||||
const override = ptyId ? getFitOverrideForPty(ptyId) : null
|
||||
if (override) {
|
||||
if (pane.terminal.cols !== override.cols || pane.terminal.rows !== override.rows) {
|
||||
if (canPreserveScrollIntentForFit(pane)) {
|
||||
captureScrollForFit()
|
||||
}
|
||||
pane.terminal.resize(override.cols, override.rows)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const dims = getProposedDimensions(pane)
|
||||
if (dims && dims.cols === pane.terminal.cols && dims.rows === pane.terminal.rows) {
|
||||
// Why: divider drags fire refits every frame, but most frames do not
|
||||
// cross a cell boundary. Skipping those avoids FitAddon.clear()+refresh()
|
||||
// churn, which was causing visible terminal blinking while resizing.
|
||||
return
|
||||
}
|
||||
if (canPreserveScrollIntentForFit(pane)) {
|
||||
captureScrollForFit()
|
||||
}
|
||||
pane.fitAddon.fit()
|
||||
} catch {
|
||||
// Container may not have dimensions yet
|
||||
} finally {
|
||||
if (shouldRestoreScroll) {
|
||||
try {
|
||||
if (pinnedScrollState) {
|
||||
restoreScrollState(pane.terminal, pinnedScrollState)
|
||||
syncTerminalScrollIntentFromViewport(pane.terminal)
|
||||
} else {
|
||||
enforceTerminalWriteScrollIntent(pane.terminal, scrollIntent)
|
||||
}
|
||||
} catch {
|
||||
// Why: xterm can temporarily expose a terminal whose renderer has not
|
||||
// initialized dimensions yet during SSH reattach/layout. Fit is best-effort.
|
||||
} finally {
|
||||
if (pinnedScrollState) {
|
||||
releaseScrollStateMarker(pinnedScrollState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function fitAllPanesInternal(panes: Map<number, ManagedPaneInternal>): void {
|
||||
for (const pane of panes.values()) {
|
||||
safeFit(pane)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@ import type { ManagedPaneInternal } from './pane-manager-types'
|
|||
import { disposePane } from './pane-lifecycle'
|
||||
import { suspendPaneRendering } from './pane-rendering-control'
|
||||
import { disposeWebgl } from './pane-webgl-renderer'
|
||||
import {
|
||||
beginTerminalScrollIntentBufferRebuild,
|
||||
endTerminalScrollIntentBufferRebuild
|
||||
} from './terminal-scroll-intent-rebuild'
|
||||
|
||||
function createPane(
|
||||
overrides: Partial<Pick<ManagedPaneInternal, 'pendingWebglRefreshRafId' | 'webglAddon'>> = {}
|
||||
|
|
@ -14,11 +18,17 @@ function createPane(
|
|||
stablePaneId: leafId,
|
||||
terminal: {
|
||||
element: null,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
buffer: { active: { type: 'normal', viewportY: 0, baseY: 0 } },
|
||||
refresh: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
} as never,
|
||||
container: {} as never,
|
||||
container: {
|
||||
dataset: {},
|
||||
getBoundingClientRect: () => ({ width: 800, height: 600 })
|
||||
} as never,
|
||||
xtermContainer: {} as never,
|
||||
linkTooltip: {} as never,
|
||||
terminalGpuAcceleration: 'off',
|
||||
|
|
@ -28,6 +38,7 @@ function createPane(
|
|||
hasComplexScriptOutput: false,
|
||||
fitAddon: {
|
||||
fit: vi.fn(),
|
||||
proposeDimensions: vi.fn(() => ({ cols: 100, rows: 24 })),
|
||||
dispose: vi.fn()
|
||||
} as never,
|
||||
fitResizeObserver: null,
|
||||
|
|
@ -66,6 +77,26 @@ describe('pane WebGL refresh lifecycle', () => {
|
|||
expect(pane.pendingWebglRefreshRafId).toBe(29)
|
||||
})
|
||||
|
||||
it('defers the DOM-renderer refit until structural replay completes', async () => {
|
||||
const refreshFrame: { current: FrameRequestCallback | null } = { current: null }
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
refreshFrame.current = callback
|
||||
return 29
|
||||
})
|
||||
const pane = createPane()
|
||||
beginTerminalScrollIntentBufferRebuild(pane.terminal)
|
||||
|
||||
disposeWebgl(pane, { refreshDimensions: true })
|
||||
refreshFrame.current?.(0)
|
||||
expect(pane.fitAddon.fit).not.toHaveBeenCalled()
|
||||
expect(pane.terminal.refresh).not.toHaveBeenCalled()
|
||||
|
||||
endTerminalScrollIntentBufferRebuild(pane.terminal)
|
||||
await Promise.resolve()
|
||||
expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1)
|
||||
expect(pane.terminal.refresh).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('actively releases the xterm WebGL context before disposing the addon', () => {
|
||||
const loseContext = vi.fn()
|
||||
const canvas = { width: 120, height: 40 }
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
getTerminalWebglAutoDecision,
|
||||
resetTerminalWebglAutoDecision
|
||||
} from './terminal-webgl-auto-policy'
|
||||
import { safeFitAndThen } from './pane-fit'
|
||||
|
||||
export const ENABLE_WEBGL_RENDERER = true
|
||||
let suggestedRendererType: 'dom' | undefined
|
||||
|
|
@ -90,8 +91,11 @@ export function disposeWebgl(
|
|||
pane.pendingWebglRefreshRafId = requestAnimationFrame(() => {
|
||||
pane.pendingWebglRefreshRafId = null
|
||||
try {
|
||||
pane.fitAddon.fit()
|
||||
pane.terminal.refresh(0, pane.terminal.rows - 1)
|
||||
// Why: context loss can coincide with snapshot parsing; refresh only
|
||||
// after the replay-aware fit has authoritative renderer dimensions.
|
||||
safeFitAndThen(pane, 'webgl-fallback-refresh', () => {
|
||||
pane.terminal.refresh(0, pane.terminal.rows - 1)
|
||||
})
|
||||
} catch {
|
||||
/* ignore — pane may have been disposed in the meantime */
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
import type { Terminal } from '@xterm/xterm'
|
||||
|
||||
type ReflowLineReader = {
|
||||
getCellMetrics: (lineY: number, column: number) => { code: number; width: number } | undefined
|
||||
isWrapped: (lineY: number) => boolean
|
||||
}
|
||||
|
||||
type TerminalWithInternalBufferLines = Terminal & {
|
||||
_core?: {
|
||||
_bufferService?: {
|
||||
buffer?: {
|
||||
lines?: {
|
||||
get: (lineY: number) =>
|
||||
| {
|
||||
getCodePoint: (column: number) => number
|
||||
getWidth: (column: number) => number
|
||||
isWrapped: boolean
|
||||
length: number
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function captureLogicalLineAnchor(
|
||||
terminal: Terminal,
|
||||
viewportY: number
|
||||
): { cellOffset: number; lineY: number } | undefined {
|
||||
const buf = terminal.buffer.active
|
||||
if (typeof buf.getLine !== 'function' || shouldKeepPhysicalResizeAnchor(terminal)) {
|
||||
return undefined
|
||||
}
|
||||
const lines = createReflowLineReader(terminal)
|
||||
let lineY = viewportY
|
||||
while (lineY > 0 && lines.isWrapped(lineY)) {
|
||||
lineY -= 1
|
||||
}
|
||||
const cursorLineY = buf.baseY + buf.cursorY
|
||||
if (terminal.options?.reflowCursorLine !== true && lineContainsLine(lines, lineY, cursorLineY)) {
|
||||
return undefined
|
||||
}
|
||||
let cellOffset = 0
|
||||
for (let currentLineY = lineY; currentLineY < viewportY; currentLineY += 1) {
|
||||
cellOffset += readReflowedRowCellCount(terminal, lines, currentLineY)
|
||||
}
|
||||
return { cellOffset, lineY }
|
||||
}
|
||||
|
||||
function shouldKeepPhysicalResizeAnchor(terminal: Terminal): boolean {
|
||||
const windowsPty = terminal.options?.windowsPty
|
||||
if (!windowsPty?.buildNumber) {
|
||||
return false
|
||||
}
|
||||
// Why: xterm disables reflow only when an explicit legacy build is present;
|
||||
// Orca's backend-only fallback for an unknown Windows build still reflows.
|
||||
return windowsPty.backend !== 'conpty' || windowsPty.buildNumber < 21376
|
||||
}
|
||||
|
||||
function lineContainsLine(
|
||||
lines: ReflowLineReader,
|
||||
logicalStartY: number,
|
||||
targetY: number
|
||||
): boolean {
|
||||
if (targetY < logicalStartY) {
|
||||
return false
|
||||
}
|
||||
for (let lineY = logicalStartY + 1; lineY <= targetY; lineY += 1) {
|
||||
if (!lines.isWrapped(lineY)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function resolveLogicalCellOffsetLine(
|
||||
terminal: Terminal,
|
||||
logicalStartY: number,
|
||||
cellOffset: number
|
||||
): number {
|
||||
const buf = terminal.buffer.active
|
||||
const lines = createReflowLineReader(terminal)
|
||||
let lineY = logicalStartY
|
||||
let remainingCells = cellOffset
|
||||
while (lineY < buf.baseY && lines.isWrapped(lineY + 1)) {
|
||||
const rowCells = readReflowedRowCellCount(terminal, lines, lineY)
|
||||
if (remainingCells < rowCells) {
|
||||
break
|
||||
}
|
||||
remainingCells -= rowCells
|
||||
lineY += 1
|
||||
}
|
||||
return lineY
|
||||
}
|
||||
|
||||
function readReflowedRowCellCount(
|
||||
terminal: Terminal,
|
||||
lines: ReflowLineReader,
|
||||
lineY: number
|
||||
): number {
|
||||
const cols = Math.max(terminal.cols, 1)
|
||||
const lastCell = lines.getCellMetrics(lineY, cols - 1)
|
||||
const nextFirstCell = lines.getCellMetrics(lineY + 1, 0)
|
||||
// Why: xterm wraps a width-2 glyph one cell early when only the last column
|
||||
// remains. That placeholder is not part of the logical cell offset.
|
||||
return lastCell?.code === 0 && lastCell.width === 1 && nextFirstCell?.width === 2
|
||||
? cols - 1
|
||||
: cols
|
||||
}
|
||||
|
||||
function createReflowLineReader(terminal: Terminal): ReflowLineReader {
|
||||
const internalLines = (terminal as TerminalWithInternalBufferLines)._core?._bufferService?.buffer
|
||||
?.lines
|
||||
if (internalLines) {
|
||||
// Why: public getLine/getCell allocate wrapper objects per row/cell. The
|
||||
// pinned xterm core exposes the same active lines without resize-path GC.
|
||||
return {
|
||||
isWrapped: (lineY) => internalLines.get(lineY)?.isWrapped ?? false,
|
||||
getCellMetrics: (lineY, column) => {
|
||||
const line = internalLines.get(lineY)
|
||||
if (!line || column < 0 || column >= line.length) {
|
||||
return undefined
|
||||
}
|
||||
return { code: line.getCodePoint(column), width: line.getWidth(column) }
|
||||
}
|
||||
}
|
||||
}
|
||||
const buffer = terminal.buffer.active
|
||||
return {
|
||||
isWrapped: (lineY) => buffer.getLine(lineY)?.isWrapped ?? false,
|
||||
getCellMetrics: (lineY, column) => {
|
||||
const cell = buffer.getLine(lineY)?.getCell(column)
|
||||
return cell ? { code: cell.getCode(), width: cell.getWidth() } : undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
export type TerminalScrollBufferType = 'normal' | 'alternate'
|
||||
|
||||
export type TerminalScrollBufferTarget = {
|
||||
buffer?: {
|
||||
active?: {
|
||||
type?: string
|
||||
viewportY?: number
|
||||
baseY?: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type TerminalScrollBufferSnapshot = {
|
||||
bufferType: TerminalScrollBufferType
|
||||
viewportY: number
|
||||
baseY: number
|
||||
}
|
||||
|
||||
export function readTerminalScrollBufferSnapshot(
|
||||
terminal: TerminalScrollBufferTarget
|
||||
): TerminalScrollBufferSnapshot | null {
|
||||
const buffer = terminal.buffer?.active
|
||||
const viewportY = buffer?.viewportY
|
||||
const baseY = buffer?.baseY
|
||||
if (typeof viewportY !== 'number' || typeof baseY !== 'number') {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
bufferType: buffer?.type === 'alternate' ? 'alternate' : 'normal',
|
||||
viewportY,
|
||||
baseY
|
||||
}
|
||||
}
|
||||
|
||||
export function isTerminalViewportAtBottom(viewportY: number, baseY: number): boolean {
|
||||
return viewportY >= baseY
|
||||
}
|
||||
|
||||
export function clampTerminalViewportY(viewportY: number, baseY: number): number {
|
||||
return Math.max(0, Math.min(viewportY, baseY))
|
||||
}
|
||||
|
||||
export function safeTerminalScrollCall(scroll: () => void): boolean {
|
||||
try {
|
||||
scroll()
|
||||
return true
|
||||
} catch (err) {
|
||||
if (err instanceof TypeError && /dimensions/.test(err.message)) {
|
||||
return false
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
import type { IDisposable } from '@xterm/xterm'
|
||||
import {
|
||||
bindTerminalScrollIntentKey,
|
||||
enforceTerminalCurrentScrollIntent,
|
||||
getTerminalScrollIntentKind,
|
||||
isTerminalScrollIntentKeyBindingCurrent,
|
||||
markTerminalPinnedViewport,
|
||||
syncTerminalScrollIntentFromViewport
|
||||
} from './terminal-scroll-intent'
|
||||
import { syncTerminalScrollIntentSoon } from './terminal-scroll-intent-settle'
|
||||
import type { TerminalScrollIntentKey, TerminalScrollIntentTarget } from './terminal-scroll-intent'
|
||||
import {
|
||||
isTerminalScrollIntentRebuildInFlight,
|
||||
onTerminalScrollIntentBufferRebuildComplete
|
||||
} from './terminal-scroll-intent-rebuild'
|
||||
|
||||
const XTERM_SCROLL_INTENT_POINTER_TARGET_CLASSES = [
|
||||
'xterm-viewport',
|
||||
'xterm-scrollbar',
|
||||
'xterm-slider'
|
||||
] as const
|
||||
const XTERM_SCROLL_INTENT_POINTER_TARGET_SELECTOR = XTERM_SCROLL_INTENT_POINTER_TARGET_CLASSES.map(
|
||||
(className) => `.${className}`
|
||||
).join(',')
|
||||
|
||||
function isTerminalScrollIntentPointerTarget(target: EventTarget | null): target is Element {
|
||||
if (typeof Element === 'undefined' || !(target instanceof Element)) {
|
||||
return false
|
||||
}
|
||||
// xterm's custom scrollbar uses separate thumb/track nodes from the viewport.
|
||||
return target.closest(XTERM_SCROLL_INTENT_POINTER_TARGET_SELECTOR) !== null
|
||||
}
|
||||
|
||||
type TerminalWithOnData = {
|
||||
onData?: (listener: (data: string) => void) => { dispose?: unknown } | undefined
|
||||
_core?: {
|
||||
coreService?: {
|
||||
onUserInput?: (listener: () => void) => { dispose?: unknown } | undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mouse reports (SGR "\x1b[<b;x;yM" and X10 "\x1b[M...") stream at pointer
|
||||
// frequency and are the one input kind whose native scroll-to-bottom must NOT
|
||||
// reclassify a pin: converting it would permanently drop a reading position
|
||||
// on a mere mouse-move over a mouse-tracking app.
|
||||
function isMouseReportInput(data: string): boolean {
|
||||
return (
|
||||
data.charCodeAt(0) === 0x1b &&
|
||||
data.charAt(1) === '[' &&
|
||||
(data.charAt(2) === '<' || data.charAt(2) === 'M')
|
||||
)
|
||||
}
|
||||
|
||||
// Why: typing/pasting scrolls the terminal to the bottom (xterm
|
||||
// scrollOnUserInput) without going through any wheel/pointer path this module
|
||||
// tracks. Without a resync, a stored pin goes stale and a later
|
||||
// workspace-switch restore yanks the user back to the old reading position.
|
||||
// onData also carries parser auto-replies (DSR/CPR, focus reports), so pinned
|
||||
// xterm's core onUserInput signal identifies which emissions were truly user-driven.
|
||||
function subscribeScrollIntentUserInputResync(
|
||||
terminal: TerminalScrollIntentTarget,
|
||||
isActive: () => boolean,
|
||||
captureInteractionRevision: () => number,
|
||||
resyncUserInput: (interactionRevision: number) => void
|
||||
): { dispose: () => void } | null {
|
||||
const terminalWithInput = terminal as TerminalWithOnData
|
||||
const onData = terminalWithInput.onData
|
||||
if (typeof onData !== 'function') {
|
||||
return null
|
||||
}
|
||||
const onUserInput = terminalWithInput._core?.coreService?.onUserInput
|
||||
let pendingUserInputRevision: number | null = null
|
||||
try {
|
||||
const dataSubscription = onData((data: string) => {
|
||||
if (isMouseReportInput(data)) {
|
||||
pendingUserInputRevision = null
|
||||
if (isActive() && getTerminalScrollIntentKind(terminal) === 'pinnedViewport') {
|
||||
// Why: xterm treats mouse reports as user input and scrolls bottom
|
||||
// before onData. Restore the reading position before output follows.
|
||||
enforceTerminalCurrentScrollIntent(terminal)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (typeof onUserInput === 'function') {
|
||||
const interactionRevision = pendingUserInputRevision
|
||||
pendingUserInputRevision = null
|
||||
if (interactionRevision !== null && isActive()) {
|
||||
resyncUserInput(interactionRevision)
|
||||
}
|
||||
} else if (isActive()) {
|
||||
// Compatibility fallback for test doubles or an unexpected xterm
|
||||
// shape; pinned production xterm uses onUserInput below.
|
||||
resyncUserInput(captureInteractionRevision())
|
||||
}
|
||||
})
|
||||
const userInputSubscription = onUserInput?.(() => {
|
||||
// Why: xterm emits onUserInput immediately before its matching onData.
|
||||
// Reserve order here, then let onData classify typing versus mouse.
|
||||
pendingUserInputRevision = captureInteractionRevision()
|
||||
})
|
||||
return {
|
||||
dispose: () => {
|
||||
if (dataSubscription && typeof dataSubscription.dispose === 'function') {
|
||||
dataSubscription.dispose()
|
||||
}
|
||||
if (userInputSubscription && typeof userInputSubscription.dispose === 'function') {
|
||||
userInputSubscription.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Wires the user-driven scroll signals (wheel, scrollbar pointer drags) that
|
||||
* are allowed to change a terminal's scroll intent. Output-driven scroll
|
||||
* events deliberately do not update intent (see terminal-scroll-intent.ts). */
|
||||
export function attachTerminalScrollIntentTracking(
|
||||
terminal: TerminalScrollIntentTarget,
|
||||
host: HTMLElement,
|
||||
intentKey?: TerminalScrollIntentKey
|
||||
): IDisposable {
|
||||
if (!bindTerminalScrollIntentKey(terminal, intentKey)) {
|
||||
syncTerminalScrollIntentFromViewport(terminal)
|
||||
}
|
||||
let disposed = false
|
||||
const isActive = (): boolean => !disposed
|
||||
let pointerScrollActive = false
|
||||
let cancelPostRebuildSync: (() => void) | null = null
|
||||
let nextInteractionRevision = 0
|
||||
let latestCommittedInteractionRevision = 0
|
||||
let postRebuildSync: { revision: number; mode: 'sample' | 'preservePinnedAtBottom' } | null = null
|
||||
const captureInteractionRevision = (): number => (nextInteractionRevision += 1)
|
||||
|
||||
const syncFromViewportOrAfterRebuild = (
|
||||
mode: 'sample' | 'preservePinnedAtBottom' = 'sample',
|
||||
interactionRevision = captureInteractionRevision()
|
||||
): boolean => {
|
||||
if (interactionRevision < latestCommittedInteractionRevision) {
|
||||
return false
|
||||
}
|
||||
latestCommittedInteractionRevision = interactionRevision
|
||||
if (!isTerminalScrollIntentRebuildInFlight(terminal)) {
|
||||
syncTerminalScrollIntentFromViewport(terminal, { allowBufferShrink: true })
|
||||
return true
|
||||
}
|
||||
postRebuildSync = { revision: interactionRevision, mode }
|
||||
if (!cancelPostRebuildSync) {
|
||||
cancelPostRebuildSync = onTerminalScrollIntentBufferRebuildComplete(terminal, (completed) => {
|
||||
cancelPostRebuildSync = null
|
||||
const pendingSync = postRebuildSync
|
||||
postRebuildSync = null
|
||||
if (
|
||||
completed &&
|
||||
isActive() &&
|
||||
pendingSync &&
|
||||
pendingSync.revision === latestCommittedInteractionRevision
|
||||
) {
|
||||
// Why: wheel/scrollbar movement during replay must be sampled from
|
||||
// the completed buffer, never from its transient cleared rows.
|
||||
const preservePinnedAtBottom = pendingSync.mode === 'preservePinnedAtBottom'
|
||||
if (
|
||||
preservePinnedAtBottom &&
|
||||
getTerminalScrollIntentKind(terminal) !== 'pinnedViewport'
|
||||
) {
|
||||
markTerminalPinnedViewport(terminal)
|
||||
}
|
||||
syncTerminalScrollIntentFromViewport(terminal, {
|
||||
allowBufferShrink: true,
|
||||
preservePinnedAtBottom
|
||||
})
|
||||
if (preservePinnedAtBottom) {
|
||||
// Why: an upward wheel or scrollbar gesture against the cleared 0/0
|
||||
// buffer must not erase the durable pin. Settle after restoration so
|
||||
// a real move wins and a no-op gesture can still return to follow.
|
||||
syncTerminalScrollIntentSoon(terminal, {
|
||||
allowBufferShrink: true,
|
||||
preservePinnedAtBottom: true,
|
||||
shouldSync: isActive
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
const userInputResync = subscribeScrollIntentUserInputResync(
|
||||
terminal,
|
||||
isActive,
|
||||
captureInteractionRevision,
|
||||
(interactionRevision) => syncFromViewportOrAfterRebuild('sample', interactionRevision)
|
||||
)
|
||||
|
||||
const onWheel = (event: WheelEvent): void => {
|
||||
if (!syncFromViewportOrAfterRebuild(event.deltaY < 0 ? 'preservePinnedAtBottom' : 'sample')) {
|
||||
return
|
||||
}
|
||||
if (event.deltaY < 0) {
|
||||
markTerminalPinnedViewport(terminal)
|
||||
syncTerminalScrollIntentSoon(terminal, {
|
||||
preservePinnedAtBottom: true,
|
||||
shouldSync: isActive
|
||||
})
|
||||
return
|
||||
}
|
||||
syncTerminalScrollIntentSoon(terminal, { shouldSync: isActive })
|
||||
}
|
||||
|
||||
const onPointerDown = (event: PointerEvent): void => {
|
||||
pointerScrollActive = isTerminalScrollIntentPointerTarget(event.target)
|
||||
}
|
||||
|
||||
const onPointerDone = (): void => {
|
||||
if (!pointerScrollActive) {
|
||||
return
|
||||
}
|
||||
pointerScrollActive = false
|
||||
syncFromViewportOrAfterRebuild('preservePinnedAtBottom')
|
||||
}
|
||||
|
||||
const onScroll = (): void => {
|
||||
if (pointerScrollActive) {
|
||||
syncFromViewportOrAfterRebuild('preservePinnedAtBottom')
|
||||
}
|
||||
}
|
||||
|
||||
host.addEventListener('wheel', onWheel, { capture: true, passive: true })
|
||||
host.addEventListener('pointerdown', onPointerDown, true)
|
||||
host.addEventListener('scroll', onScroll, true)
|
||||
globalThis.addEventListener?.('pointerup', onPointerDone, true)
|
||||
globalThis.addEventListener?.('pointercancel', onPointerDone, true)
|
||||
return {
|
||||
dispose: () => {
|
||||
// Why: native pinned output can grow baseY without a DOM scroll event;
|
||||
// persist that geometry before remount, but never let an old instance
|
||||
// overwrite a successor already bound to the same leaf key.
|
||||
if (isTerminalScrollIntentKeyBindingCurrent(terminal)) {
|
||||
syncTerminalScrollIntentFromViewport(terminal)
|
||||
}
|
||||
disposed = true
|
||||
cancelPostRebuildSync?.()
|
||||
cancelPostRebuildSync = null
|
||||
postRebuildSync = null
|
||||
userInputResync?.dispose()
|
||||
host.removeEventListener('wheel', onWheel, true)
|
||||
host.removeEventListener('pointerdown', onPointerDown, true)
|
||||
host.removeEventListener('scroll', onScroll, true)
|
||||
globalThis.removeEventListener?.('pointerup', onPointerDone, true)
|
||||
globalThis.removeEventListener?.('pointercancel', onPointerDone, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,298 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent-dom-tracking'
|
||||
import {
|
||||
captureTerminalStructuralScrollIntent,
|
||||
enforceTerminalCurrentScrollIntent,
|
||||
getTerminalScrollIntentKind,
|
||||
markTerminalPinnedViewport,
|
||||
restoreTerminalStructuralScrollIntent
|
||||
} from './terminal-scroll-intent'
|
||||
import {
|
||||
beginTerminalScrollIntentBufferRebuild,
|
||||
endTerminalScrollIntentBufferRebuild
|
||||
} from './terminal-scroll-intent-rebuild'
|
||||
|
||||
function createTerminal(viewportY: number, baseY: number) {
|
||||
const terminal = {
|
||||
buffer: { active: { type: 'normal' as const, viewportY, baseY } },
|
||||
scrollToBottom: vi.fn(() => {
|
||||
terminal.buffer.active.viewportY = terminal.buffer.active.baseY
|
||||
}),
|
||||
scrollToLine: vi.fn((line: number) => {
|
||||
terminal.buffer.active.viewportY = line
|
||||
}),
|
||||
onData: undefined as
|
||||
| ((listener: (data: string) => void) => { dispose: () => void })
|
||||
| undefined,
|
||||
_core: undefined as
|
||||
| { coreService: { onUserInput: (listener: () => void) => { dispose: () => void } } }
|
||||
| undefined
|
||||
}
|
||||
return terminal
|
||||
}
|
||||
|
||||
class TestElement extends EventTarget {
|
||||
parentElement: TestElement | null = null
|
||||
readonly classList = {
|
||||
contains: (className: string): boolean => this.className.split(/\s+/).includes(className)
|
||||
}
|
||||
|
||||
constructor(public className = '') {
|
||||
super()
|
||||
}
|
||||
|
||||
closest(selector: string): TestElement | null {
|
||||
for (const candidate of selector.split(',')) {
|
||||
const trimmed = candidate.trim()
|
||||
if (trimmed.startsWith('.') && this.classList.contains(trimmed.slice(1))) {
|
||||
return this
|
||||
}
|
||||
}
|
||||
return this.parentElement?.closest(selector) ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function createTerminalWithInputCapture(viewportY: number, baseY: number) {
|
||||
const capturedInput: { listener: ((data: string) => void) | null } = { listener: null }
|
||||
const capturedUserInput: { listener: (() => void) | null } = { listener: null }
|
||||
const terminal = createTerminal(viewportY, baseY)
|
||||
terminal.onData = (listener: (data: string) => void) => {
|
||||
capturedInput.listener = listener
|
||||
return { dispose: vi.fn() }
|
||||
}
|
||||
terminal._core = {
|
||||
coreService: {
|
||||
onUserInput: (listener: () => void) => {
|
||||
capturedUserInput.listener = listener
|
||||
return { dispose: vi.fn() }
|
||||
}
|
||||
}
|
||||
}
|
||||
return { terminal, capturedInput, capturedUserInput }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('terminal scroll-intent input resync', () => {
|
||||
it('heals a stale pin when typing scrolls the terminal to the bottom', () => {
|
||||
vi.stubGlobal('requestAnimationFrame', () => 0)
|
||||
vi.stubGlobal('Element', TestElement)
|
||||
const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100)
|
||||
const host = new TestElement() as unknown as HTMLElement
|
||||
const disposable = attachTerminalScrollIntentTracking(terminal, host)
|
||||
|
||||
markTerminalPinnedViewport(terminal)
|
||||
terminal.buffer.active.viewportY = terminal.buffer.active.baseY
|
||||
capturedUserInput.listener?.()
|
||||
capturedInput.listener?.('a')
|
||||
|
||||
expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput')
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('heals a stale pre-reflow pin when typing reaches a shorter buffer bottom', () => {
|
||||
vi.stubGlobal('requestAnimationFrame', () => 0)
|
||||
vi.stubGlobal('Element', TestElement)
|
||||
const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100)
|
||||
const host = new TestElement() as unknown as HTMLElement
|
||||
const disposable = attachTerminalScrollIntentTracking(terminal, host)
|
||||
|
||||
markTerminalPinnedViewport(terminal)
|
||||
terminal.buffer.active.baseY = 70
|
||||
terminal.buffer.active.viewportY = 70
|
||||
capturedUserInput.listener?.()
|
||||
capturedInput.listener?.('a')
|
||||
|
||||
expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput')
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('keeps a real pin when app-consumed input does not move the viewport', () => {
|
||||
vi.stubGlobal('requestAnimationFrame', () => 0)
|
||||
vi.stubGlobal('Element', TestElement)
|
||||
const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100)
|
||||
const host = new TestElement() as unknown as HTMLElement
|
||||
const disposable = attachTerminalScrollIntentTracking(terminal, host)
|
||||
|
||||
markTerminalPinnedViewport(terminal)
|
||||
capturedUserInput.listener?.()
|
||||
capturedInput.listener?.('\x1b[5~')
|
||||
|
||||
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
|
||||
terminal.buffer.active.viewportY = 0
|
||||
enforceTerminalCurrentScrollIntent(terminal)
|
||||
expect(terminal.scrollToLine).toHaveBeenLastCalledWith(42)
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('does not reclassify a pin from mouse reports even when they scroll to bottom', () => {
|
||||
vi.stubGlobal('requestAnimationFrame', () => 0)
|
||||
vi.stubGlobal('Element', TestElement)
|
||||
const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100)
|
||||
const host = new TestElement() as unknown as HTMLElement
|
||||
const disposable = attachTerminalScrollIntentTracking(terminal, host)
|
||||
|
||||
markTerminalPinnedViewport(terminal)
|
||||
terminal.buffer.active.viewportY = terminal.buffer.active.baseY
|
||||
capturedUserInput.listener?.()
|
||||
capturedInput.listener?.('\x1b[<35;10;5M')
|
||||
|
||||
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
|
||||
expect(terminal.buffer.active.viewportY).toBe(42)
|
||||
expect(terminal.scrollToLine).toHaveBeenLastCalledWith(42)
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('does not let a focus reply make a following mouse report reclassify the pin', () => {
|
||||
vi.stubGlobal('requestAnimationFrame', () => 0)
|
||||
vi.stubGlobal('Element', TestElement)
|
||||
const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100)
|
||||
const host = new TestElement() as unknown as HTMLElement
|
||||
const disposable = attachTerminalScrollIntentTracking(terminal, host)
|
||||
|
||||
markTerminalPinnedViewport(terminal)
|
||||
capturedInput.listener?.('\x1b[I')
|
||||
terminal.buffer.active.viewportY = terminal.buffer.active.baseY
|
||||
capturedUserInput.listener?.()
|
||||
capturedInput.listener?.('\x1b[<0;10;5M')
|
||||
|
||||
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('ignores a parser reply that has no matching user-input signal', () => {
|
||||
vi.stubGlobal('requestAnimationFrame', () => 0)
|
||||
vi.stubGlobal('Element', TestElement)
|
||||
const { terminal, capturedInput } = createTerminalWithInputCapture(42, 100)
|
||||
const host = new TestElement() as unknown as HTMLElement
|
||||
const disposable = attachTerminalScrollIntentTracking(terminal, host)
|
||||
|
||||
markTerminalPinnedViewport(terminal)
|
||||
terminal.buffer.active.viewportY = terminal.buffer.active.baseY
|
||||
capturedInput.listener?.('\x1b[1;1R')
|
||||
|
||||
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('does not apply input resync after tracking is disposed', () => {
|
||||
vi.stubGlobal('requestAnimationFrame', () => 0)
|
||||
vi.stubGlobal('Element', TestElement)
|
||||
const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100)
|
||||
const host = new TestElement() as unknown as HTMLElement
|
||||
const disposable = attachTerminalScrollIntentTracking(terminal, host)
|
||||
|
||||
markTerminalPinnedViewport(terminal)
|
||||
capturedUserInput.listener?.()
|
||||
disposable.dispose()
|
||||
terminal.buffer.active.viewportY = terminal.buffer.active.baseY
|
||||
capturedInput.listener?.('a')
|
||||
|
||||
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
|
||||
})
|
||||
|
||||
it('defers real typing intent until a snapshot rebuild completes', () => {
|
||||
vi.stubGlobal('requestAnimationFrame', () => 0)
|
||||
vi.stubGlobal('Element', TestElement)
|
||||
const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100)
|
||||
const host = new TestElement() as unknown as HTMLElement
|
||||
const disposable = attachTerminalScrollIntentTracking(terminal, host)
|
||||
markTerminalPinnedViewport(terminal)
|
||||
const staleIntent = captureTerminalStructuralScrollIntent(terminal)
|
||||
beginTerminalScrollIntentBufferRebuild(terminal)
|
||||
|
||||
terminal.buffer.active.baseY = 5
|
||||
terminal.buffer.active.viewportY = 5
|
||||
capturedUserInput.listener?.()
|
||||
capturedInput.listener?.('a')
|
||||
terminal.buffer.active.baseY = 200
|
||||
terminal.buffer.active.viewportY = 200
|
||||
endTerminalScrollIntentBufferRebuild(terminal)
|
||||
restoreTerminalStructuralScrollIntent(terminal, staleIntent, { restoreBy: 'bottomOffset' })
|
||||
|
||||
expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput')
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('lets later typing supersede a wheel-up pin during snapshot replay', () => {
|
||||
vi.stubGlobal('requestAnimationFrame', () => 0)
|
||||
vi.stubGlobal('Element', TestElement)
|
||||
const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(80, 100)
|
||||
const host = new TestElement() as unknown as HTMLElement
|
||||
const disposable = attachTerminalScrollIntentTracking(terminal, host)
|
||||
markTerminalPinnedViewport(terminal)
|
||||
const staleIntent = captureTerminalStructuralScrollIntent(terminal)
|
||||
beginTerminalScrollIntentBufferRebuild(terminal)
|
||||
|
||||
terminal.buffer.active.viewportY = 0
|
||||
terminal.buffer.active.baseY = 0
|
||||
const wheel = new Event('wheel') as WheelEvent
|
||||
Object.defineProperty(wheel, 'deltaY', { value: -10 })
|
||||
host.dispatchEvent(wheel)
|
||||
capturedUserInput.listener?.()
|
||||
capturedInput.listener?.('a')
|
||||
terminal.buffer.active.viewportY = 200
|
||||
terminal.buffer.active.baseY = 200
|
||||
endTerminalScrollIntentBufferRebuild(terminal)
|
||||
restoreTerminalStructuralScrollIntent(terminal, staleIntent, { restoreBy: 'bottomOffset' })
|
||||
|
||||
expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput')
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('ignores parser auto-replies while a snapshot rebuild is partial', () => {
|
||||
vi.stubGlobal('requestAnimationFrame', () => 0)
|
||||
vi.stubGlobal('Element', TestElement)
|
||||
const { terminal, capturedInput } = createTerminalWithInputCapture(42, 100)
|
||||
const host = new TestElement() as unknown as HTMLElement
|
||||
const disposable = attachTerminalScrollIntentTracking(terminal, host)
|
||||
markTerminalPinnedViewport(terminal)
|
||||
const intent = captureTerminalStructuralScrollIntent(terminal)
|
||||
beginTerminalScrollIntentBufferRebuild(terminal)
|
||||
|
||||
terminal.buffer.active.baseY = 5
|
||||
terminal.buffer.active.viewportY = 5
|
||||
capturedInput.listener?.('\x1b[1;1R')
|
||||
terminal.buffer.active.baseY = 200
|
||||
terminal.buffer.active.viewportY = 200
|
||||
endTerminalScrollIntentBufferRebuild(terminal)
|
||||
restoreTerminalStructuralScrollIntent(terminal, intent, { restoreBy: 'bottomOffset' })
|
||||
|
||||
expect(terminal.scrollToLine).toHaveBeenLastCalledWith(142)
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('supports bottom-offset restore for structural buffer rebuilds', () => {
|
||||
const terminal = createTerminal(550, 600)
|
||||
markTerminalPinnedViewport(terminal)
|
||||
const snapshot = captureTerminalStructuralScrollIntent(terminal)
|
||||
terminal.buffer.active.baseY = 80
|
||||
terminal.buffer.active.viewportY = 80
|
||||
|
||||
restoreTerminalStructuralScrollIntent(terminal, snapshot, { restoreBy: 'bottomOffset' })
|
||||
|
||||
expect(terminal.scrollToLine).toHaveBeenLastCalledWith(30)
|
||||
expect(terminal.buffer.active.viewportY).toBe(30)
|
||||
})
|
||||
|
||||
it('retains the intended bottom-offset pin when renderer dimensions reject restore', () => {
|
||||
const terminal = createTerminal(80, 100)
|
||||
markTerminalPinnedViewport(terminal)
|
||||
const snapshot = captureTerminalStructuralScrollIntent(terminal)
|
||||
terminal.buffer.active.baseY = 200
|
||||
terminal.buffer.active.viewportY = 200
|
||||
terminal.scrollToLine.mockImplementationOnce(() => {
|
||||
throw new TypeError("Cannot read properties of undefined (reading 'dimensions')")
|
||||
})
|
||||
|
||||
restoreTerminalStructuralScrollIntent(terminal, snapshot, { restoreBy: 'bottomOffset' })
|
||||
expect(terminal.buffer.active.viewportY).toBe(200)
|
||||
|
||||
terminal.buffer.active.viewportY = 0
|
||||
enforceTerminalCurrentScrollIntent(terminal)
|
||||
expect(terminal.scrollToLine).toHaveBeenLastCalledWith(180)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
// Why: buffer rebuilds (snapshot replay clear + rewrite) parse asynchronously.
|
||||
// Until the rebuild's bytes have parsed, viewportY/baseY describe a transient
|
||||
// half-cleared buffer; any intent capture/enforce latched from it pins the
|
||||
// terminal at line 0. Callers bracket the rebuild and re-apply intent once
|
||||
// after parse (see terminal-scroll-intent.ts).
|
||||
const terminalScrollIntentRebuilds = new WeakMap<object, number>()
|
||||
const terminalScrollIntentRebuildCompletions = new WeakMap<
|
||||
object,
|
||||
Set<(completed: boolean) => void>
|
||||
>()
|
||||
const deferredTerminalGeometryMutations = new WeakMap<
|
||||
object,
|
||||
{
|
||||
mutations: Map<string, () => void>
|
||||
}
|
||||
>()
|
||||
|
||||
function notifyRebuildCompletions(
|
||||
completions: Set<(completed: boolean) => void> | undefined,
|
||||
completed: boolean
|
||||
): void {
|
||||
for (const completion of completions ?? []) {
|
||||
try {
|
||||
completion(completed)
|
||||
} catch (error) {
|
||||
// Why: one optional observer must not strand the rebuild or prevent the
|
||||
// coordinator from restoring the authoritative viewport.
|
||||
console.error('[terminal] scroll-intent rebuild completion failed', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function beginTerminalScrollIntentBufferRebuild(terminal: object): void {
|
||||
terminalScrollIntentRebuilds.set(terminal, (terminalScrollIntentRebuilds.get(terminal) ?? 0) + 1)
|
||||
}
|
||||
|
||||
export function endTerminalScrollIntentBufferRebuild(terminal: object): void {
|
||||
const count = terminalScrollIntentRebuilds.get(terminal) ?? 0
|
||||
if (count <= 1) {
|
||||
terminalScrollIntentRebuilds.delete(terminal)
|
||||
const completions = terminalScrollIntentRebuildCompletions.get(terminal)
|
||||
terminalScrollIntentRebuildCompletions.delete(terminal)
|
||||
notifyRebuildCompletions(completions, true)
|
||||
return
|
||||
}
|
||||
terminalScrollIntentRebuilds.set(terminal, count - 1)
|
||||
}
|
||||
|
||||
export function isTerminalScrollIntentRebuildInFlight(terminal: object): boolean {
|
||||
return (terminalScrollIntentRebuilds.get(terminal) ?? 0) > 0
|
||||
}
|
||||
|
||||
export function onTerminalScrollIntentBufferRebuildComplete(
|
||||
terminal: object,
|
||||
completion: (completed: boolean) => void
|
||||
): () => void {
|
||||
if (!isTerminalScrollIntentRebuildInFlight(terminal)) {
|
||||
completion(true)
|
||||
return () => {}
|
||||
}
|
||||
let completions = terminalScrollIntentRebuildCompletions.get(terminal)
|
||||
if (!completions) {
|
||||
completions = new Set()
|
||||
terminalScrollIntentRebuildCompletions.set(terminal, completions)
|
||||
}
|
||||
completions.add(completion)
|
||||
return () => {
|
||||
completions?.delete(completion)
|
||||
if (completions?.size === 0) {
|
||||
terminalScrollIntentRebuildCompletions.delete(terminal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: source-dimension replay must finish and restore its viewport before
|
||||
// unrelated fit/resize work is allowed to reflow the rebuilt buffer.
|
||||
export function deferTerminalGeometryMutationDuringRebuild(
|
||||
terminal: object,
|
||||
operationKey: string,
|
||||
mutation: () => void
|
||||
): boolean {
|
||||
if (!isTerminalScrollIntentRebuildInFlight(terminal)) {
|
||||
return false
|
||||
}
|
||||
const existing = deferredTerminalGeometryMutations.get(terminal)
|
||||
if (existing) {
|
||||
existing.mutations.set(operationKey, mutation)
|
||||
return true
|
||||
}
|
||||
const mutations = new Map([[operationKey, mutation]])
|
||||
const deferred = { mutations }
|
||||
deferredTerminalGeometryMutations.set(terminal, deferred)
|
||||
onTerminalScrollIntentBufferRebuildComplete(terminal, (completed) => {
|
||||
if (deferredTerminalGeometryMutations.get(terminal) !== deferred) {
|
||||
return
|
||||
}
|
||||
if (!completed) {
|
||||
deferredTerminalGeometryMutations.delete(terminal)
|
||||
return
|
||||
}
|
||||
// Why: rebuild completion listeners run before the coordinator restores
|
||||
// intent; the microtask makes every geometry mutation post-restore.
|
||||
queueMicrotask(() => {
|
||||
if (deferredTerminalGeometryMutations.get(terminal) !== deferred) {
|
||||
return
|
||||
}
|
||||
// Keep the entry cancellable until execution begins; disposal may land
|
||||
// after rebuild completion but before this post-restore microtask.
|
||||
deferredTerminalGeometryMutations.delete(terminal)
|
||||
for (const [key, pendingMutation] of mutations) {
|
||||
if (!deferTerminalGeometryMutationDuringRebuild(terminal, key, pendingMutation)) {
|
||||
pendingMutation()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
export function cancelTerminalScrollIntentBufferRebuildCompletions(terminal: object): void {
|
||||
const completions = terminalScrollIntentRebuildCompletions.get(terminal)
|
||||
terminalScrollIntentRebuildCompletions.delete(terminal)
|
||||
notifyRebuildCompletions(completions, false)
|
||||
deferredTerminalGeometryMutations.delete(terminal)
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import {
|
||||
syncTerminalScrollIntentFromViewport,
|
||||
type TerminalScrollIntentTarget
|
||||
} from './terminal-scroll-intent'
|
||||
|
||||
export function syncTerminalScrollIntentSoon(
|
||||
terminal: TerminalScrollIntentTarget,
|
||||
options: {
|
||||
allowBufferShrink?: boolean
|
||||
preservePinnedAtBottom?: boolean
|
||||
shouldSync?: () => boolean
|
||||
} = {}
|
||||
): void {
|
||||
const sync = (): void => {
|
||||
if (options.shouldSync?.() === false) {
|
||||
return
|
||||
}
|
||||
syncTerminalScrollIntentFromViewport(terminal, options)
|
||||
}
|
||||
queueMicrotask(sync)
|
||||
requestAnimationFrame(sync)
|
||||
requestAnimationFrame(() => requestAnimationFrame(sync))
|
||||
// Why: preservePinnedAtBottom only bridges xterm's async scroll application.
|
||||
// The settle tick must reclassify from the real viewport, otherwise a wheel
|
||||
// the viewport never followed latches a phantom pin at the bottom.
|
||||
setTimeout(() => {
|
||||
if (options.shouldSync?.() !== false) {
|
||||
syncTerminalScrollIntentFromViewport(terminal, {
|
||||
allowBufferShrink: options.allowBufferShrink
|
||||
})
|
||||
}
|
||||
}, 80)
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
bindTerminalScrollIntentKey,
|
||||
captureTerminalStructuralScrollIntent,
|
||||
markTerminalFollowOutput,
|
||||
markTerminalPinnedViewport,
|
||||
restoreTerminalStructuralScrollIntent
|
||||
} from './terminal-scroll-intent'
|
||||
|
||||
type BufferType = 'normal' | 'alternate'
|
||||
|
||||
function createTerminal(viewportY: number, baseY: number, type: BufferType = 'normal') {
|
||||
const terminal = {
|
||||
buffer: { active: { type, viewportY, baseY } },
|
||||
scrollToBottom: vi.fn(() => {
|
||||
terminal.buffer.active.viewportY = terminal.buffer.active.baseY
|
||||
}),
|
||||
scrollToLine: vi.fn((line: number) => {
|
||||
terminal.buffer.active.viewportY = line
|
||||
})
|
||||
}
|
||||
return terminal
|
||||
}
|
||||
|
||||
describe('terminal structural scroll-intent transitions', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'live pinned growth',
|
||||
storedKind: 'pinnedViewport' as const,
|
||||
live: { viewportY: 76, baseY: 120, type: 'normal' as const },
|
||||
expected: { kind: 'pinnedViewport', viewportY: 76, baseY: 120, bufferType: 'normal' }
|
||||
},
|
||||
{
|
||||
name: 'empty pinned remount',
|
||||
storedKind: 'pinnedViewport' as const,
|
||||
live: { viewportY: 0, baseY: 0, type: 'normal' as const },
|
||||
expected: { kind: 'pinnedViewport', viewportY: 76, baseY: 100, bufferType: 'normal' }
|
||||
},
|
||||
{
|
||||
name: 'shorter pinned remount',
|
||||
storedKind: 'pinnedViewport' as const,
|
||||
live: { viewportY: 20, baseY: 30, type: 'normal' as const },
|
||||
expected: { kind: 'pinnedViewport', viewportY: 76, baseY: 100, bufferType: 'normal' }
|
||||
},
|
||||
{
|
||||
name: 'alternate buffer entered from a normal-buffer pin',
|
||||
storedKind: 'pinnedViewport' as const,
|
||||
live: { viewportY: 0, baseY: 0, type: 'alternate' as const },
|
||||
expected: { kind: 'pinnedViewport', viewportY: 76, baseY: 100, bufferType: 'normal' }
|
||||
},
|
||||
{
|
||||
name: 'untracked return to bottom',
|
||||
storedKind: 'pinnedViewport' as const,
|
||||
live: { viewportY: 100, baseY: 100, type: 'normal' as const },
|
||||
expected: { kind: 'followOutput', viewportY: 100, baseY: 100, bufferType: 'normal' }
|
||||
},
|
||||
{
|
||||
name: 'empty follow-output remount',
|
||||
storedKind: 'followOutput' as const,
|
||||
live: { viewportY: 0, baseY: 0, type: 'normal' as const },
|
||||
expected: { kind: 'followOutput', viewportY: 0, baseY: 0, bufferType: 'normal' }
|
||||
}
|
||||
])('captures the authoritative coordinates for $name', ({ storedKind, live, expected }) => {
|
||||
const key = `structural-${storedKind}-${live.type}-${live.viewportY}-${live.baseY}`
|
||||
const original = createTerminal(76, 100)
|
||||
bindTerminalScrollIntentKey(original, key)
|
||||
if (storedKind === 'pinnedViewport') {
|
||||
markTerminalPinnedViewport(original)
|
||||
} else {
|
||||
original.buffer.active.viewportY = original.buffer.active.baseY
|
||||
markTerminalFollowOutput(original)
|
||||
}
|
||||
|
||||
const current = createTerminal(live.viewportY, live.baseY, live.type)
|
||||
bindTerminalScrollIntentKey(current, key)
|
||||
|
||||
expect(captureTerminalStructuralScrollIntent(current)).toMatchObject(expected)
|
||||
})
|
||||
|
||||
it('restores a durable remount pin by bottom offset without overwriting newer intent', () => {
|
||||
const original = createTerminal(76, 100)
|
||||
bindTerminalScrollIntentKey(original, 'structural-remount-revision')
|
||||
markTerminalPinnedViewport(original)
|
||||
const remounted = createTerminal(0, 0)
|
||||
bindTerminalScrollIntentKey(remounted, 'structural-remount-revision')
|
||||
const staleIntent = captureTerminalStructuralScrollIntent(remounted)
|
||||
|
||||
remounted.buffer.active.viewportY = 200
|
||||
remounted.buffer.active.baseY = 200
|
||||
markTerminalFollowOutput(remounted)
|
||||
restoreTerminalStructuralScrollIntent(remounted, staleIntent, { restoreBy: 'bottomOffset' })
|
||||
|
||||
expect(remounted.scrollToLine).not.toHaveBeenCalled()
|
||||
expect(remounted.buffer.active.viewportY).toBe(200)
|
||||
})
|
||||
|
||||
it('keeps a normal-buffer pin dormant while replay restores an alternate buffer', () => {
|
||||
const original = createTerminal(76, 100)
|
||||
bindTerminalScrollIntentKey(original, 'structural-buffer-switch')
|
||||
markTerminalPinnedViewport(original)
|
||||
const remounted = createTerminal(0, 0, 'alternate')
|
||||
bindTerminalScrollIntentKey(remounted, 'structural-buffer-switch')
|
||||
const intent = captureTerminalStructuralScrollIntent(remounted)
|
||||
|
||||
remounted.buffer.active.baseY = 40
|
||||
remounted.buffer.active.viewportY = 40
|
||||
restoreTerminalStructuralScrollIntent(remounted, intent, { restoreBy: 'bottomOffset' })
|
||||
expect(remounted.scrollToLine).not.toHaveBeenCalled()
|
||||
|
||||
remounted.buffer.active.type = 'normal'
|
||||
remounted.buffer.active.baseY = 140
|
||||
remounted.buffer.active.viewportY = 140
|
||||
restoreTerminalStructuralScrollIntent(remounted, intent, { restoreBy: 'bottomOffset' })
|
||||
expect(remounted.scrollToLine).toHaveBeenLastCalledWith(116)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,15 +1,22 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
attachTerminalScrollIntentTracking,
|
||||
captureTerminalWriteScrollIntent,
|
||||
bindTerminalScrollIntentKey,
|
||||
captureTerminalStructuralScrollIntent,
|
||||
enforceTerminalCurrentScrollIntent,
|
||||
enforceTerminalWriteScrollIntent,
|
||||
getTerminalScrollIntentKind,
|
||||
markTerminalFollowOutput,
|
||||
markTerminalPinnedViewport,
|
||||
syncTerminalScrollIntentFromViewport,
|
||||
syncTerminalScrollIntentSoon
|
||||
restoreTerminalStructuralScrollIntent
|
||||
} from './terminal-scroll-intent'
|
||||
import { syncTerminalScrollIntentSoon } from './terminal-scroll-intent-settle'
|
||||
import { clearTerminalScrollbackAndFollowOutput } from './terminal-scrollback-clear'
|
||||
import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent-dom-tracking'
|
||||
import {
|
||||
beginTerminalScrollIntentBufferRebuild,
|
||||
cancelTerminalScrollIntentBufferRebuildCompletions,
|
||||
endTerminalScrollIntentBufferRebuild
|
||||
} from './terminal-scroll-intent-rebuild'
|
||||
|
||||
function createTerminal({
|
||||
viewportY,
|
||||
|
|
@ -99,14 +106,39 @@ describe('terminal scroll intent', () => {
|
|||
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
|
||||
})
|
||||
|
||||
it('treats a viewport exactly one row above bottom as pinned', () => {
|
||||
const terminal = createTerminal({ viewportY: 99, baseY: 100 })
|
||||
|
||||
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
|
||||
syncTerminalScrollIntentFromViewport(terminal)
|
||||
expect(captureTerminalStructuralScrollIntent(terminal)?.kind).toBe('pinnedViewport')
|
||||
})
|
||||
|
||||
it('clears a pinned scrollback into follow-output state', () => {
|
||||
const terminal = {
|
||||
...createTerminal({ viewportY: 42, baseY: 100 }),
|
||||
clear: vi.fn()
|
||||
}
|
||||
markTerminalPinnedViewport(terminal)
|
||||
|
||||
clearTerminalScrollbackAndFollowOutput(terminal)
|
||||
|
||||
expect(terminal.clear).toHaveBeenCalledOnce()
|
||||
expect(terminal.scrollToBottom).toHaveBeenCalledOnce()
|
||||
expect(terminal.clear.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
terminal.scrollToBottom.mock.invocationCallOrder[0]
|
||||
)
|
||||
expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput')
|
||||
})
|
||||
|
||||
it('preserves a pinned viewport after output moves xterm to bottom', () => {
|
||||
const terminal = createTerminal({ viewportY: 42, baseY: 100 })
|
||||
markTerminalPinnedViewport(terminal)
|
||||
const snapshot = captureTerminalWriteScrollIntent(terminal)
|
||||
const snapshot = captureTerminalStructuralScrollIntent(terminal)
|
||||
|
||||
terminal.buffer.active.baseY = 125
|
||||
terminal.buffer.active.viewportY = 125
|
||||
enforceTerminalWriteScrollIntent(terminal, snapshot)
|
||||
restoreTerminalStructuralScrollIntent(terminal, snapshot)
|
||||
|
||||
expect(terminal.scrollToLine).toHaveBeenCalledWith(42)
|
||||
expect(terminal.buffer.active.viewportY).toBe(42)
|
||||
|
|
@ -116,11 +148,11 @@ describe('terminal scroll intent', () => {
|
|||
it('follows output after output advances while following', () => {
|
||||
const terminal = createTerminal({ viewportY: 100, baseY: 100 })
|
||||
markTerminalFollowOutput(terminal)
|
||||
const snapshot = captureTerminalWriteScrollIntent(terminal)
|
||||
const snapshot = captureTerminalStructuralScrollIntent(terminal)
|
||||
|
||||
terminal.buffer.active.baseY = 125
|
||||
terminal.buffer.active.viewportY = 0
|
||||
enforceTerminalWriteScrollIntent(terminal, snapshot)
|
||||
restoreTerminalStructuralScrollIntent(terminal, snapshot)
|
||||
|
||||
expect(terminal.scrollToBottom).toHaveBeenCalledTimes(1)
|
||||
expect(terminal.buffer.active.viewportY).toBe(125)
|
||||
|
|
@ -129,16 +161,30 @@ describe('terminal scroll intent', () => {
|
|||
it('does not preserve across buffer type changes', () => {
|
||||
const terminal = createTerminal({ viewportY: 42, baseY: 100 })
|
||||
markTerminalPinnedViewport(terminal)
|
||||
const snapshot = captureTerminalWriteScrollIntent(terminal)
|
||||
const snapshot = captureTerminalStructuralScrollIntent(terminal)
|
||||
|
||||
terminal.buffer.active.type = 'alternate'
|
||||
terminal.buffer.active.viewportY = 0
|
||||
enforceTerminalWriteScrollIntent(terminal, snapshot)
|
||||
restoreTerminalStructuralScrollIntent(terminal, snapshot)
|
||||
|
||||
expect(terminal.scrollToLine).not.toHaveBeenCalled()
|
||||
expect(terminal.buffer.active.viewportY).toBe(0)
|
||||
})
|
||||
|
||||
it('does not enforce a captured intent after newer user intent supersedes it', () => {
|
||||
const terminal = createTerminal({ viewportY: 42, baseY: 100 })
|
||||
markTerminalPinnedViewport(terminal)
|
||||
const staleSnapshot = captureTerminalStructuralScrollIntent(terminal)
|
||||
|
||||
terminal.buffer.active.viewportY = terminal.buffer.active.baseY
|
||||
markTerminalFollowOutput(terminal)
|
||||
terminal.buffer.active.baseY = 125
|
||||
restoreTerminalStructuralScrollIntent(terminal, staleSnapshot)
|
||||
|
||||
expect(terminal.scrollToLine).not.toHaveBeenCalled()
|
||||
expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput')
|
||||
})
|
||||
|
||||
it('syncs intent from the current viewport after user scroll settles', () => {
|
||||
const terminal = createTerminal({ viewportY: 100, baseY: 100 })
|
||||
|
||||
|
|
@ -148,6 +194,20 @@ describe('terminal scroll intent', () => {
|
|||
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
|
||||
})
|
||||
|
||||
it('records xterm native scrollback-trim movement before structural enforcement', () => {
|
||||
const terminal = createTerminal({ viewportY: 10, baseY: 20 })
|
||||
markTerminalPinnedViewport(terminal)
|
||||
|
||||
// At scrollback capacity xterm keeps baseY fixed and walks viewportY up
|
||||
// as old rows trim, preserving the visible content without app help.
|
||||
terminal.buffer.active.viewportY = 5
|
||||
syncTerminalScrollIntentFromViewport(terminal)
|
||||
terminal.buffer.active.viewportY = 0
|
||||
enforceTerminalCurrentScrollIntent(terminal)
|
||||
|
||||
expect(terminal.scrollToLine).toHaveBeenLastCalledWith(5)
|
||||
})
|
||||
|
||||
it('tracks upward wheel immediately and records the settled viewport', async () => {
|
||||
const frameCallbacks: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
|
|
@ -253,6 +313,131 @@ describe('terminal scroll intent', () => {
|
|||
remountedDisposable.dispose()
|
||||
})
|
||||
|
||||
it('captures durable pinned coordinates before replaying into an empty remount', () => {
|
||||
vi.stubGlobal('Element', TestElement)
|
||||
const firstTerminal = createTerminal({ viewportY: 76, baseY: 100 })
|
||||
const firstHost = new TestElement() as unknown as HTMLElement
|
||||
const firstDisposable = attachTerminalScrollIntentTracking(
|
||||
firstTerminal,
|
||||
firstHost,
|
||||
'leaf-remount-replay'
|
||||
)
|
||||
markTerminalPinnedViewport(firstTerminal)
|
||||
|
||||
const remountedTerminal = createTerminal({ viewportY: 0, baseY: 0 })
|
||||
const remountedHost = new TestElement() as unknown as HTMLElement
|
||||
const remountedDisposable = attachTerminalScrollIntentTracking(
|
||||
remountedTerminal,
|
||||
remountedHost,
|
||||
'leaf-remount-replay'
|
||||
)
|
||||
const intent = captureTerminalStructuralScrollIntent(remountedTerminal)
|
||||
|
||||
expect(intent).toMatchObject({
|
||||
kind: 'pinnedViewport',
|
||||
viewportY: 76,
|
||||
baseY: 100
|
||||
})
|
||||
remountedTerminal.buffer.active.viewportY = 100
|
||||
remountedTerminal.buffer.active.baseY = 100
|
||||
restoreTerminalStructuralScrollIntent(remountedTerminal, intent, {
|
||||
restoreBy: 'bottomOffset'
|
||||
})
|
||||
|
||||
expect(remountedTerminal.scrollToLine).toHaveBeenLastCalledWith(76)
|
||||
expect(remountedTerminal.buffer.active.viewportY).toBe(76)
|
||||
firstDisposable.dispose()
|
||||
remountedDisposable.dispose()
|
||||
})
|
||||
|
||||
it('refreshes pinned base geometry before a keyed empty remount', () => {
|
||||
vi.stubGlobal('Element', TestElement)
|
||||
const firstTerminal = createTerminal({ viewportY: 10, baseY: 20 })
|
||||
const firstHost = new TestElement() as unknown as HTMLElement
|
||||
const firstDisposable = attachTerminalScrollIntentTracking(
|
||||
firstTerminal,
|
||||
firstHost,
|
||||
'leaf-growing-pin'
|
||||
)
|
||||
markTerminalPinnedViewport(firstTerminal)
|
||||
|
||||
firstTerminal.buffer.active.baseY = 30
|
||||
syncTerminalScrollIntentFromViewport(firstTerminal)
|
||||
const remountedTerminal = createTerminal({ viewportY: 0, baseY: 0 })
|
||||
const remountedHost = new TestElement() as unknown as HTMLElement
|
||||
const remountedDisposable = attachTerminalScrollIntentTracking(
|
||||
remountedTerminal,
|
||||
remountedHost,
|
||||
'leaf-growing-pin'
|
||||
)
|
||||
const intent = captureTerminalStructuralScrollIntent(remountedTerminal)
|
||||
remountedTerminal.buffer.active.viewportY = 30
|
||||
remountedTerminal.buffer.active.baseY = 30
|
||||
restoreTerminalStructuralScrollIntent(remountedTerminal, intent, {
|
||||
restoreBy: 'bottomOffset'
|
||||
})
|
||||
|
||||
expect(intent).toMatchObject({ viewportY: 10, baseY: 30 })
|
||||
expect(remountedTerminal.scrollToLine).toHaveBeenLastCalledWith(10)
|
||||
firstDisposable.dispose()
|
||||
remountedDisposable.dispose()
|
||||
})
|
||||
|
||||
it('persists native pinned growth on disposal for the next keyed replay', () => {
|
||||
vi.stubGlobal('Element', TestElement)
|
||||
const firstTerminal = createTerminal({ viewportY: 76, baseY: 100 })
|
||||
const firstDisposable = attachTerminalScrollIntentTracking(
|
||||
firstTerminal,
|
||||
new TestElement() as unknown as HTMLElement,
|
||||
'leaf-dispose-growth'
|
||||
)
|
||||
markTerminalPinnedViewport(firstTerminal)
|
||||
firstTerminal.buffer.active.baseY = 120
|
||||
|
||||
firstDisposable.dispose()
|
||||
|
||||
const remountedTerminal = createTerminal({ viewportY: 0, baseY: 0 })
|
||||
const remountedDisposable = attachTerminalScrollIntentTracking(
|
||||
remountedTerminal,
|
||||
new TestElement() as unknown as HTMLElement,
|
||||
'leaf-dispose-growth'
|
||||
)
|
||||
const intent = captureTerminalStructuralScrollIntent(remountedTerminal)
|
||||
remountedTerminal.buffer.active.viewportY = 200
|
||||
remountedTerminal.buffer.active.baseY = 200
|
||||
restoreTerminalStructuralScrollIntent(remountedTerminal, intent, {
|
||||
restoreBy: 'bottomOffset'
|
||||
})
|
||||
|
||||
expect(intent).toMatchObject({ viewportY: 76, baseY: 120 })
|
||||
expect(remountedTerminal.scrollToLine).toHaveBeenLastCalledWith(156)
|
||||
remountedDisposable.dispose()
|
||||
})
|
||||
|
||||
it('does not let an old terminal disposal overwrite its keyed successor', () => {
|
||||
vi.stubGlobal('Element', TestElement)
|
||||
const firstTerminal = createTerminal({ viewportY: 76, baseY: 100 })
|
||||
const firstDisposable = attachTerminalScrollIntentTracking(
|
||||
firstTerminal,
|
||||
new TestElement() as unknown as HTMLElement,
|
||||
'leaf-dispose-successor'
|
||||
)
|
||||
markTerminalPinnedViewport(firstTerminal)
|
||||
|
||||
const successor = createTerminal({ viewportY: 100, baseY: 100 })
|
||||
const successorDisposable = attachTerminalScrollIntentTracking(
|
||||
successor,
|
||||
new TestElement() as unknown as HTMLElement,
|
||||
'leaf-dispose-successor'
|
||||
)
|
||||
markTerminalFollowOutput(successor)
|
||||
firstTerminal.buffer.active.baseY = 150
|
||||
firstDisposable.dispose()
|
||||
|
||||
expect(getTerminalScrollIntentKind(successor)).toBe('followOutput')
|
||||
successorDisposable.dispose()
|
||||
})
|
||||
|
||||
it('tracks pointer-driven scrollbar scrolls without using output scroll as intent', () => {
|
||||
vi.stubGlobal('Element', TestElement)
|
||||
const terminal = createTerminal({ viewportY: 100, baseY: 100 })
|
||||
|
|
@ -358,6 +543,33 @@ describe('terminal scroll intent', () => {
|
|||
expect(terminal.scrollToLine).toHaveBeenLastCalledWith(75)
|
||||
})
|
||||
|
||||
it('does not let a stale key-settle callback overwrite a remounted terminal', async () => {
|
||||
const frameCallbacks: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
frameCallbacks.push(callback)
|
||||
return frameCallbacks.length
|
||||
})
|
||||
vi.useFakeTimers({ toFake: ['setTimeout'] })
|
||||
let firstTerminalIsCurrent = true
|
||||
const first = createTerminal({ viewportY: 100, baseY: 100 })
|
||||
bindTerminalScrollIntentKey(first, 'key-settle-remount')
|
||||
markTerminalPinnedViewport(first)
|
||||
first.buffer.active.viewportY = 50
|
||||
syncTerminalScrollIntentSoon(first, { shouldSync: () => firstTerminalIsCurrent })
|
||||
|
||||
const replacement = createTerminal({ viewportY: 100, baseY: 100 })
|
||||
bindTerminalScrollIntentKey(replacement, 'key-settle-remount')
|
||||
markTerminalFollowOutput(replacement)
|
||||
firstTerminalIsCurrent = false
|
||||
await Promise.resolve()
|
||||
while (frameCallbacks.length > 0) {
|
||||
frameCallbacks.shift()?.(16)
|
||||
}
|
||||
vi.advanceTimersByTime(80)
|
||||
|
||||
expect(getTerminalScrollIntentKind(replacement)).toBe('followOutput')
|
||||
})
|
||||
|
||||
it('enforces current intent once for visibility resume', () => {
|
||||
const terminal = createTerminal({ viewportY: 40, baseY: 100 })
|
||||
markTerminalPinnedViewport(terminal)
|
||||
|
|
@ -434,11 +646,11 @@ describe('terminal scroll intent', () => {
|
|||
markTerminalPinnedViewport(terminal)
|
||||
|
||||
for (let batch = 1; batch <= 2; batch += 1) {
|
||||
const snapshot = captureTerminalWriteScrollIntent(terminal)
|
||||
const snapshot = captureTerminalStructuralScrollIntent(terminal)
|
||||
// xterm follows output during the write because the viewport was at bottom.
|
||||
terminal.buffer.active.baseY += 25
|
||||
terminal.buffer.active.viewportY = terminal.buffer.active.baseY
|
||||
enforceTerminalWriteScrollIntent(terminal, snapshot)
|
||||
restoreTerminalStructuralScrollIntent(terminal, snapshot)
|
||||
expect(terminal.buffer.active.viewportY).toBe(terminal.buffer.active.baseY)
|
||||
}
|
||||
expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput')
|
||||
|
|
@ -470,16 +682,205 @@ describe('terminal scroll intent', () => {
|
|||
expect(terminal.buffer.active.viewportY).toBe(150)
|
||||
})
|
||||
|
||||
it('supports bottom-offset restore for buffer-rebuild write paths', () => {
|
||||
const terminal = createTerminal({ viewportY: 550, baseY: 600 })
|
||||
it('does not re-latch a pinned intent from a transiently shorter rebuilt buffer', () => {
|
||||
const terminal = createTerminal({ viewportY: 248, baseY: 254 })
|
||||
markTerminalPinnedViewport(terminal)
|
||||
const snapshot = captureTerminalWriteScrollIntent(terminal)
|
||||
const snapshot = captureTerminalStructuralScrollIntent(terminal)
|
||||
|
||||
terminal.buffer.active.baseY = 80
|
||||
terminal.buffer.active.viewportY = 80
|
||||
enforceTerminalWriteScrollIntent(terminal, snapshot, { restoreBy: 'bottomOffset' })
|
||||
// Snapshot replay cleared the buffer; enforcement races the async parse.
|
||||
terminal.buffer.active.baseY = 0
|
||||
terminal.buffer.active.viewportY = 0
|
||||
restoreTerminalStructuralScrollIntent(terminal, snapshot)
|
||||
|
||||
expect(terminal.scrollToLine).toHaveBeenLastCalledWith(30)
|
||||
expect(terminal.buffer.active.viewportY).toBe(30)
|
||||
// The replay finishes parsing and the scrollback regrows past the pin.
|
||||
terminal.buffer.active.baseY = 284
|
||||
terminal.buffer.active.viewportY = 284
|
||||
enforceTerminalCurrentScrollIntent(terminal)
|
||||
|
||||
expect(terminal.scrollToLine).toHaveBeenLastCalledWith(248)
|
||||
expect(terminal.buffer.active.viewportY).toBe(248)
|
||||
})
|
||||
|
||||
it('keeps a pinned intent when capture races a cleared unparsed buffer', () => {
|
||||
const terminal = createTerminal({ viewportY: 248, baseY: 254 })
|
||||
markTerminalPinnedViewport(terminal)
|
||||
|
||||
// A structural capture sees the rebuilt buffer while it is still empty;
|
||||
// the at-bottom(0/0) reading is transient and must not convert the pin.
|
||||
terminal.buffer.active.baseY = 0
|
||||
terminal.buffer.active.viewportY = 0
|
||||
const snapshot = captureTerminalStructuralScrollIntent(terminal)
|
||||
expect(snapshot?.kind).toBe('pinnedViewport')
|
||||
})
|
||||
|
||||
it('suspends intent capture and enforcement while a buffer rebuild is in flight', () => {
|
||||
const terminal = createTerminal({ viewportY: 248, baseY: 254 })
|
||||
markTerminalPinnedViewport(terminal)
|
||||
const preReplay = captureTerminalStructuralScrollIntent(terminal)
|
||||
beginTerminalScrollIntentBufferRebuild(terminal)
|
||||
|
||||
terminal.buffer.active.baseY = 0
|
||||
terminal.buffer.active.viewportY = 0
|
||||
expect(captureTerminalStructuralScrollIntent(terminal)).toBeNull()
|
||||
enforceTerminalCurrentScrollIntent(terminal)
|
||||
|
||||
// A live streaming batch lands while the replay is partially parsed.
|
||||
terminal.buffer.active.baseY = 284
|
||||
terminal.buffer.active.viewportY = 0
|
||||
restoreTerminalStructuralScrollIntent(terminal, preReplay)
|
||||
expect(terminal.scrollToLine).not.toHaveBeenCalled()
|
||||
expect(terminal.scrollToBottom).not.toHaveBeenCalled()
|
||||
|
||||
terminal.buffer.active.viewportY = 284
|
||||
endTerminalScrollIntentBufferRebuild(terminal)
|
||||
restoreTerminalStructuralScrollIntent(terminal, preReplay, { restoreBy: 'bottomOffset' })
|
||||
expect(terminal.scrollToLine).toHaveBeenLastCalledWith(278)
|
||||
expect(terminal.buffer.active.viewportY).toBe(278)
|
||||
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ deltaY: -10, finalViewportY: 150, expectedKind: 'pinnedViewport', expectedLine: 150 },
|
||||
{ deltaY: 10, finalViewportY: 200, expectedKind: 'followOutput', expectedLine: null }
|
||||
])(
|
||||
'resyncs a $expectedKind wheel intent after a delayed rebuild',
|
||||
async ({ deltaY, finalViewportY, expectedKind, expectedLine }) => {
|
||||
vi.stubGlobal('requestAnimationFrame', () => 0)
|
||||
vi.stubGlobal('Element', TestElement)
|
||||
const terminal = createTerminal({ viewportY: 42, baseY: 100 })
|
||||
const host = new TestElement() as unknown as HTMLElement
|
||||
const disposable = attachTerminalScrollIntentTracking(terminal, host)
|
||||
markTerminalPinnedViewport(terminal)
|
||||
const staleIntent = captureTerminalStructuralScrollIntent(terminal)
|
||||
beginTerminalScrollIntentBufferRebuild(terminal)
|
||||
|
||||
terminal.buffer.active.viewportY = 0
|
||||
terminal.buffer.active.baseY = 0
|
||||
const wheel = new Event('wheel') as WheelEvent
|
||||
Object.defineProperty(wheel, 'deltaY', { value: deltaY })
|
||||
host.dispatchEvent(wheel)
|
||||
terminal.buffer.active.viewportY = finalViewportY
|
||||
terminal.buffer.active.baseY = 200
|
||||
endTerminalScrollIntentBufferRebuild(terminal)
|
||||
restoreTerminalStructuralScrollIntent(terminal, staleIntent, {
|
||||
restoreBy: 'bottomOffset'
|
||||
})
|
||||
|
||||
expect(getTerminalScrollIntentKind(terminal)).toBe(expectedKind)
|
||||
terminal.buffer.active.viewportY = 0
|
||||
terminal.scrollToLine.mockClear()
|
||||
enforceTerminalCurrentScrollIntent(terminal)
|
||||
if (expectedLine === null) {
|
||||
expect(terminal.scrollToBottom).toHaveBeenCalled()
|
||||
} else {
|
||||
expect(terminal.scrollToLine).toHaveBeenLastCalledWith(expectedLine)
|
||||
}
|
||||
disposable.dispose()
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves a durable pin when wheel-up lands on the cleared replay buffer', async () => {
|
||||
vi.stubGlobal('requestAnimationFrame', () => 0)
|
||||
vi.stubGlobal('Element', TestElement)
|
||||
const terminal = createTerminal({ viewportY: 80, baseY: 100 })
|
||||
const host = new TestElement() as unknown as HTMLElement
|
||||
const disposable = attachTerminalScrollIntentTracking(terminal, host)
|
||||
markTerminalPinnedViewport(terminal)
|
||||
const staleIntent = captureTerminalStructuralScrollIntent(terminal)
|
||||
beginTerminalScrollIntentBufferRebuild(terminal)
|
||||
|
||||
terminal.buffer.active.viewportY = 0
|
||||
terminal.buffer.active.baseY = 0
|
||||
const wheel = new Event('wheel') as WheelEvent
|
||||
Object.defineProperty(wheel, 'deltaY', { value: -10 })
|
||||
host.dispatchEvent(wheel)
|
||||
terminal.buffer.active.viewportY = 200
|
||||
terminal.buffer.active.baseY = 200
|
||||
endTerminalScrollIntentBufferRebuild(terminal)
|
||||
restoreTerminalStructuralScrollIntent(terminal, staleIntent, { restoreBy: 'bottomOffset' })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(terminal.scrollToLine).toHaveBeenLastCalledWith(180)
|
||||
expect(terminal.buffer.active.viewportY).toBe(180)
|
||||
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('keeps rebuild wheel intent when xterm classifies the same event as mouse input', async () => {
|
||||
vi.stubGlobal('requestAnimationFrame', () => 0)
|
||||
vi.stubGlobal('Element', TestElement)
|
||||
const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture({
|
||||
viewportY: 80,
|
||||
baseY: 100
|
||||
})
|
||||
const host = new TestElement() as unknown as HTMLElement
|
||||
const disposable = attachTerminalScrollIntentTracking(terminal, host)
|
||||
markTerminalPinnedViewport(terminal)
|
||||
const staleIntent = captureTerminalStructuralScrollIntent(terminal)
|
||||
beginTerminalScrollIntentBufferRebuild(terminal)
|
||||
|
||||
terminal.buffer.active.viewportY = 0
|
||||
terminal.buffer.active.baseY = 0
|
||||
const wheel = new Event('wheel') as WheelEvent
|
||||
Object.defineProperty(wheel, 'deltaY', { value: -10 })
|
||||
host.dispatchEvent(wheel)
|
||||
capturedUserInput.listener?.()
|
||||
capturedInput.listener?.('\x1b[<64;10;5M')
|
||||
await Promise.resolve()
|
||||
terminal.buffer.active.viewportY = 200
|
||||
terminal.buffer.active.baseY = 200
|
||||
endTerminalScrollIntentBufferRebuild(terminal)
|
||||
restoreTerminalStructuralScrollIntent(terminal, staleIntent, { restoreBy: 'bottomOffset' })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(terminal.scrollToLine).toHaveBeenLastCalledWith(180)
|
||||
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('does not resync deferred wheel intent from a canceled partial rebuild', () => {
|
||||
vi.stubGlobal('requestAnimationFrame', () => 0)
|
||||
vi.stubGlobal('Element', TestElement)
|
||||
const terminal = createTerminal({ viewportY: 42, baseY: 100 })
|
||||
const host = new TestElement() as unknown as HTMLElement
|
||||
const disposable = attachTerminalScrollIntentTracking(terminal, host)
|
||||
markTerminalPinnedViewport(terminal)
|
||||
beginTerminalScrollIntentBufferRebuild(terminal)
|
||||
|
||||
terminal.buffer.active.viewportY = 0
|
||||
terminal.buffer.active.baseY = 0
|
||||
const wheel = new Event('wheel') as WheelEvent
|
||||
Object.defineProperty(wheel, 'deltaY', { value: -10 })
|
||||
host.dispatchEvent(wheel)
|
||||
cancelTerminalScrollIntentBufferRebuildCompletions(terminal)
|
||||
endTerminalScrollIntentBufferRebuild(terminal)
|
||||
|
||||
terminal.buffer.active.viewportY = 0
|
||||
terminal.buffer.active.baseY = 100
|
||||
enforceTerminalCurrentScrollIntent(terminal)
|
||||
expect(terminal.scrollToLine).toHaveBeenLastCalledWith(42)
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
function createTerminalWithInputCapture(args: { viewportY: number; baseY: number }) {
|
||||
const capturedInput: { listener: ((data: string) => void) | null } = { listener: null }
|
||||
const capturedUserInput: { listener: (() => void) | null } = { listener: null }
|
||||
const terminal = createTerminal(args) as ReturnType<typeof createTerminal> & {
|
||||
onData?: (listener: (data: string) => void) => { dispose: () => void }
|
||||
_core?: { coreService: { onUserInput: (listener: () => void) => { dispose: () => void } } }
|
||||
}
|
||||
terminal.onData = (listener: (data: string) => void) => {
|
||||
capturedInput.listener = listener
|
||||
return { dispose: vi.fn() }
|
||||
}
|
||||
terminal._core = {
|
||||
coreService: {
|
||||
onUserInput: (listener: () => void) => {
|
||||
capturedUserInput.listener = listener
|
||||
return { dispose: vi.fn() }
|
||||
}
|
||||
}
|
||||
}
|
||||
return { terminal, capturedInput, capturedUserInput }
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,35 +1,36 @@
|
|||
import type { IDisposable } from '@xterm/xterm'
|
||||
import { isTerminalScrollIntentRebuildInFlight } from './terminal-scroll-intent-rebuild'
|
||||
import {
|
||||
clampTerminalViewportY,
|
||||
isTerminalViewportAtBottom,
|
||||
readTerminalScrollBufferSnapshot,
|
||||
safeTerminalScrollCall,
|
||||
type TerminalScrollBufferType
|
||||
} from './terminal-scroll-buffer-snapshot'
|
||||
|
||||
type TerminalScrollIntentKind = 'followOutput' | 'pinnedViewport'
|
||||
|
||||
type BufferType = 'normal' | 'alternate'
|
||||
|
||||
type TerminalScrollIntentTarget = {
|
||||
buffer?: {
|
||||
active?: {
|
||||
type?: string
|
||||
viewportY?: number
|
||||
baseY?: number
|
||||
}
|
||||
}
|
||||
export type TerminalScrollIntentTarget = {
|
||||
buffer?: Parameters<typeof readTerminalScrollBufferSnapshot>[0]['buffer']
|
||||
scrollToBottom?: () => void
|
||||
scrollToLine?: (line: number) => void
|
||||
}
|
||||
|
||||
type TerminalScrollIntentKey = string
|
||||
export type TerminalScrollIntentKey = string
|
||||
|
||||
type TerminalScrollIntent = {
|
||||
kind: TerminalScrollIntentKind
|
||||
bufferType: BufferType
|
||||
bufferType: TerminalScrollBufferType
|
||||
viewportY: number
|
||||
baseY: number
|
||||
revision: number
|
||||
}
|
||||
|
||||
type TerminalScrollIntentWriteSnapshot = {
|
||||
export type TerminalStructuralScrollIntentSnapshot = {
|
||||
kind: TerminalScrollIntentKind
|
||||
bufferType: BufferType
|
||||
bufferType: TerminalScrollBufferType
|
||||
viewportY: number
|
||||
baseY: number
|
||||
revision: number
|
||||
}
|
||||
|
||||
type TerminalScrollIntentEnforceOptions = {
|
||||
|
|
@ -47,47 +48,31 @@ const terminalScrollIntentKeyByTerminal = new WeakMap<
|
|||
TerminalScrollIntentTarget,
|
||||
TerminalScrollIntentKey
|
||||
>()
|
||||
const terminalScrollIntentKeyBindingByTerminal = new WeakMap<TerminalScrollIntentTarget, number>()
|
||||
const terminalScrollIntentByKey = new Map<TerminalScrollIntentKey, TerminalScrollIntent>()
|
||||
const terminalScrollIntentBindingByKey = new Map<TerminalScrollIntentKey, number>()
|
||||
|
||||
const BOTTOM_TOLERANCE_ROWS = 1
|
||||
const XTERM_SCROLL_INTENT_POINTER_TARGET_CLASSES = [
|
||||
'xterm-viewport',
|
||||
'xterm-scrollbar',
|
||||
'xterm-slider'
|
||||
] as const
|
||||
const XTERM_SCROLL_INTENT_POINTER_TARGET_SELECTOR = XTERM_SCROLL_INTENT_POINTER_TARGET_CLASSES.map(
|
||||
(className) => `.${className}`
|
||||
).join(',')
|
||||
|
||||
function readBufferSnapshot(
|
||||
terminal: TerminalScrollIntentTarget
|
||||
): { bufferType: BufferType; viewportY: number; baseY: number } | null {
|
||||
const buffer = terminal.buffer?.active
|
||||
const viewportY = buffer?.viewportY
|
||||
const baseY = buffer?.baseY
|
||||
if (typeof viewportY !== 'number' || typeof baseY !== 'number') {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
bufferType: buffer?.type === 'alternate' ? 'alternate' : 'normal',
|
||||
viewportY,
|
||||
baseY
|
||||
}
|
||||
}
|
||||
|
||||
function isAtBottom(viewportY: number, baseY: number): boolean {
|
||||
return viewportY >= baseY - BOTTOM_TOLERANCE_ROWS
|
||||
}
|
||||
let nextTerminalScrollIntentRevision = 1
|
||||
let nextTerminalScrollIntentKeyBinding = 1
|
||||
|
||||
function writeIntent(
|
||||
terminal: TerminalScrollIntentTarget,
|
||||
kind: TerminalScrollIntentKind
|
||||
): TerminalScrollIntent | null {
|
||||
const snapshot = readBufferSnapshot(terminal)
|
||||
const snapshot = readTerminalScrollBufferSnapshot(terminal)
|
||||
if (!snapshot) {
|
||||
return null
|
||||
}
|
||||
const intent = { kind, ...snapshot }
|
||||
return writeIntentSnapshot(terminal, kind, snapshot)
|
||||
}
|
||||
|
||||
function writeIntentSnapshot(
|
||||
terminal: TerminalScrollIntentTarget,
|
||||
kind: TerminalScrollIntentKind,
|
||||
snapshot: { bufferType: TerminalScrollBufferType; viewportY: number; baseY: number }
|
||||
): TerminalScrollIntent {
|
||||
const intent = { kind, ...snapshot, revision: nextTerminalScrollIntentRevision }
|
||||
nextTerminalScrollIntentRevision += 1
|
||||
terminalScrollIntentByTerminal.set(terminal, intent)
|
||||
const key = terminalScrollIntentKeyByTerminal.get(terminal)
|
||||
if (key) {
|
||||
|
|
@ -105,7 +90,7 @@ function readStoredIntent(terminal: TerminalScrollIntentTarget): TerminalScrollI
|
|||
return key ? terminalScrollIntentByKey.get(key) : undefined
|
||||
}
|
||||
|
||||
function bindTerminalScrollIntentKey(
|
||||
export function bindTerminalScrollIntentKey(
|
||||
terminal: TerminalScrollIntentTarget,
|
||||
key: TerminalScrollIntentKey | undefined
|
||||
): TerminalScrollIntent | undefined {
|
||||
|
|
@ -113,6 +98,10 @@ function bindTerminalScrollIntentKey(
|
|||
return terminalScrollIntentByTerminal.get(terminal)
|
||||
}
|
||||
terminalScrollIntentKeyByTerminal.set(terminal, key)
|
||||
const binding = nextTerminalScrollIntentKeyBinding
|
||||
nextTerminalScrollIntentKeyBinding += 1
|
||||
terminalScrollIntentKeyBindingByTerminal.set(terminal, binding)
|
||||
terminalScrollIntentBindingByKey.set(key, binding)
|
||||
const existing = terminalScrollIntentByKey.get(key)
|
||||
if (existing) {
|
||||
terminalScrollIntentByTerminal.set(terminal, existing)
|
||||
|
|
@ -120,28 +109,17 @@ function bindTerminalScrollIntentKey(
|
|||
return existing
|
||||
}
|
||||
|
||||
function clampViewportY(viewportY: number, baseY: number): number {
|
||||
return Math.max(0, Math.min(viewportY, baseY))
|
||||
}
|
||||
|
||||
function safeScrollCall(fn: () => void): boolean {
|
||||
try {
|
||||
fn()
|
||||
export function isTerminalScrollIntentKeyBindingCurrent(
|
||||
terminal: TerminalScrollIntentTarget
|
||||
): boolean {
|
||||
const key = terminalScrollIntentKeyByTerminal.get(terminal)
|
||||
if (!key) {
|
||||
return true
|
||||
} catch (err) {
|
||||
if (err instanceof TypeError && /dimensions/.test(err.message)) {
|
||||
return false
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function isTerminalScrollIntentPointerTarget(target: EventTarget | null): target is Element {
|
||||
if (typeof Element === 'undefined' || !(target instanceof Element)) {
|
||||
return false
|
||||
}
|
||||
// xterm's custom scrollbar uses separate thumb/track nodes from the viewport.
|
||||
return target.closest(XTERM_SCROLL_INTENT_POINTER_TARGET_SELECTOR) !== null
|
||||
return (
|
||||
terminalScrollIntentKeyBindingByTerminal.get(terminal) ===
|
||||
terminalScrollIntentBindingByKey.get(key)
|
||||
)
|
||||
}
|
||||
|
||||
export function markTerminalFollowOutput(terminal: TerminalScrollIntentTarget): void {
|
||||
|
|
@ -154,45 +132,53 @@ export function markTerminalPinnedViewport(terminal: TerminalScrollIntentTarget)
|
|||
|
||||
export function syncTerminalScrollIntentFromViewport(
|
||||
terminal: TerminalScrollIntentTarget,
|
||||
options: { preservePinnedAtBottom?: boolean } = {}
|
||||
options: { allowBufferShrink?: boolean; preservePinnedAtBottom?: boolean } = {}
|
||||
): void {
|
||||
const snapshot = readBufferSnapshot(terminal)
|
||||
if (isTerminalScrollIntentRebuildInFlight(terminal)) {
|
||||
return
|
||||
}
|
||||
const snapshot = readTerminalScrollBufferSnapshot(terminal)
|
||||
if (!snapshot) {
|
||||
return
|
||||
}
|
||||
const existing = readStoredIntent(terminal)
|
||||
// Why: a remounted/replayed terminal can briefly report an empty or shorter
|
||||
// scrollback. That transient state must not erase a durable pinned viewport.
|
||||
if (existing?.kind === 'pinnedViewport' && snapshot.baseY < existing.baseY) {
|
||||
if (
|
||||
!options.allowBufferShrink &&
|
||||
existing?.kind === 'pinnedViewport' &&
|
||||
snapshot.baseY < existing.baseY
|
||||
) {
|
||||
terminalScrollIntentByTerminal.set(terminal, existing)
|
||||
return
|
||||
}
|
||||
if (
|
||||
options.preservePinnedAtBottom &&
|
||||
existing?.kind === 'pinnedViewport' &&
|
||||
isAtBottom(snapshot.viewportY, snapshot.baseY)
|
||||
isTerminalViewportAtBottom(snapshot.viewportY, snapshot.baseY)
|
||||
) {
|
||||
return
|
||||
}
|
||||
writeIntent(
|
||||
terminal,
|
||||
isAtBottom(snapshot.viewportY, snapshot.baseY) ? 'followOutput' : 'pinnedViewport'
|
||||
)
|
||||
}
|
||||
|
||||
export function syncTerminalScrollIntentSoon(
|
||||
terminal: TerminalScrollIntentTarget,
|
||||
options: { preservePinnedAtBottom?: boolean } = {}
|
||||
): void {
|
||||
const sync = (): void => syncTerminalScrollIntentFromViewport(terminal, options)
|
||||
queueMicrotask(sync)
|
||||
requestAnimationFrame(sync)
|
||||
requestAnimationFrame(() => requestAnimationFrame(sync))
|
||||
// Why: preservePinnedAtBottom only bridges xterm's async scroll application.
|
||||
// The settle tick must reclassify from the real viewport, otherwise a wheel
|
||||
// the viewport never followed (sub-row delta, TUI-consumed mouse report,
|
||||
// plain PageUp/Home sent to the app) latches a phantom pin at the bottom.
|
||||
setTimeout(() => syncTerminalScrollIntentFromViewport(terminal), 80)
|
||||
const kind = isTerminalViewportAtBottom(snapshot.viewportY, snapshot.baseY)
|
||||
? 'followOutput'
|
||||
: 'pinnedViewport'
|
||||
// Why: parser auto-replies and repeated wheel settle samples often observe
|
||||
// no intent change. Avoid manufacturing revisions that can cancel a valid
|
||||
// structural restore or amplify terminal-output bursts.
|
||||
if (
|
||||
existing?.kind === kind &&
|
||||
existing.bufferType === snapshot.bufferType &&
|
||||
(kind === 'followOutput' || existing.viewportY === snapshot.viewportY)
|
||||
) {
|
||||
if (kind === 'pinnedViewport' && existing.baseY !== snapshot.baseY) {
|
||||
// Why: native pinned output can grow baseY without moving viewportY.
|
||||
// Refresh geometry without creating a user-intent revision so a later
|
||||
// keyed remount restores the same content, not the stale bottom offset.
|
||||
Object.assign(existing, snapshot)
|
||||
}
|
||||
return
|
||||
}
|
||||
writeIntent(terminal, kind)
|
||||
}
|
||||
|
||||
export function getTerminalScrollIntentKind(
|
||||
|
|
@ -202,52 +188,82 @@ export function getTerminalScrollIntentKind(
|
|||
if (existing) {
|
||||
return existing.kind
|
||||
}
|
||||
const snapshot = readBufferSnapshot(terminal)
|
||||
const snapshot = readTerminalScrollBufferSnapshot(terminal)
|
||||
if (!snapshot) {
|
||||
return 'followOutput'
|
||||
}
|
||||
return isAtBottom(snapshot.viewportY, snapshot.baseY) ? 'followOutput' : 'pinnedViewport'
|
||||
return isTerminalViewportAtBottom(snapshot.viewportY, snapshot.baseY)
|
||||
? 'followOutput'
|
||||
: 'pinnedViewport'
|
||||
}
|
||||
|
||||
export function captureTerminalWriteScrollIntent(
|
||||
export function captureTerminalStructuralScrollIntent(
|
||||
terminal: TerminalScrollIntentTarget
|
||||
): TerminalScrollIntentWriteSnapshot | null {
|
||||
const snapshot = readBufferSnapshot(terminal)
|
||||
): TerminalStructuralScrollIntentSnapshot | null {
|
||||
if (isTerminalScrollIntentRebuildInFlight(terminal)) {
|
||||
return null
|
||||
}
|
||||
const snapshot = readTerminalScrollBufferSnapshot(terminal)
|
||||
if (!snapshot) {
|
||||
return null
|
||||
}
|
||||
const existing = readStoredIntent(terminal)
|
||||
let kind =
|
||||
existing?.kind ??
|
||||
(isAtBottom(snapshot.viewportY, snapshot.baseY) ? 'followOutput' : 'pinnedViewport')
|
||||
(isTerminalViewportAtBottom(snapshot.viewportY, snapshot.baseY)
|
||||
? 'followOutput'
|
||||
: 'pinnedViewport')
|
||||
// Why: a pinned intent whose live viewport still sits at the bottom is a
|
||||
// phantom pin (the user's scroll never detached the viewport). Enforcing it
|
||||
// would freeze the terminal at the current line on every write batch.
|
||||
if (kind === 'pinnedViewport' && isAtBottom(snapshot.viewportY, snapshot.baseY)) {
|
||||
// phantom pin (the user's scroll never detached the viewport). Restoring it
|
||||
// after a structural operation would freeze the terminal at a stale line.
|
||||
// Only trust the at-bottom reading when the scrollback is at least as long
|
||||
// as the pin's — a shorter one is a cleared buffer awaiting replay.
|
||||
if (
|
||||
kind === 'pinnedViewport' &&
|
||||
isTerminalViewportAtBottom(snapshot.viewportY, snapshot.baseY) &&
|
||||
(!existing || snapshot.baseY >= existing.baseY)
|
||||
) {
|
||||
kind = 'followOutput'
|
||||
}
|
||||
// Why: a keyed remount starts at 0/0 before replay. Preserve the durable
|
||||
// pre-remount coordinates or a bottom-offset restore silently loses the pin.
|
||||
const capturedCoordinates =
|
||||
existing?.kind === 'pinnedViewport' && snapshot.baseY < existing.baseY ? existing : snapshot
|
||||
return {
|
||||
...capturedCoordinates,
|
||||
kind,
|
||||
bufferType: snapshot.bufferType,
|
||||
viewportY: snapshot.viewportY,
|
||||
baseY: snapshot.baseY
|
||||
revision: existing?.revision ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
export function enforceTerminalWriteScrollIntent(
|
||||
export function isTerminalStructuralScrollIntentCurrent(
|
||||
terminal: TerminalScrollIntentTarget,
|
||||
snapshot: TerminalScrollIntentWriteSnapshot | null,
|
||||
snapshot: TerminalStructuralScrollIntentSnapshot | null
|
||||
): boolean {
|
||||
if (!snapshot) {
|
||||
return false
|
||||
}
|
||||
return (readStoredIntent(terminal)?.revision ?? 0) === snapshot.revision
|
||||
}
|
||||
|
||||
export function restoreTerminalStructuralScrollIntent(
|
||||
terminal: TerminalScrollIntentTarget,
|
||||
snapshot: TerminalStructuralScrollIntentSnapshot | null,
|
||||
options: TerminalScrollIntentEnforceOptions = {}
|
||||
): void {
|
||||
if (!snapshot) {
|
||||
if (
|
||||
!snapshot ||
|
||||
!isTerminalStructuralScrollIntentCurrent(terminal, snapshot) ||
|
||||
isTerminalScrollIntentRebuildInFlight(terminal)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const current = readBufferSnapshot(terminal)
|
||||
const current = readTerminalScrollBufferSnapshot(terminal)
|
||||
if (!current || current.bufferType !== snapshot.bufferType) {
|
||||
return
|
||||
}
|
||||
if (snapshot.kind === 'followOutput') {
|
||||
if (safeScrollCall(() => terminal.scrollToBottom?.())) {
|
||||
if (safeTerminalScrollCall(() => terminal.scrollToBottom?.())) {
|
||||
writeIntent(terminal, 'followOutput')
|
||||
}
|
||||
return
|
||||
|
|
@ -256,89 +272,60 @@ export function enforceTerminalWriteScrollIntent(
|
|||
options.restoreBy === 'bottomOffset'
|
||||
? current.baseY - Math.max(0, snapshot.baseY - snapshot.viewportY)
|
||||
: snapshot.viewportY
|
||||
const targetY = clampViewportY(requestedY, current.baseY)
|
||||
const targetY = clampTerminalViewportY(requestedY, current.baseY)
|
||||
if (current.viewportY !== targetY) {
|
||||
safeScrollCall(() => terminal.scrollToLine?.(targetY))
|
||||
if (!safeTerminalScrollCall(() => terminal.scrollToLine?.(targetY))) {
|
||||
// Why: renderer teardown can reject the scroll before xterm changes its
|
||||
// native viewport; retain the intended pin for the next fit/retry rather
|
||||
// than latching the transient current bottom.
|
||||
writeIntentSnapshot(terminal, 'pinnedViewport', {
|
||||
bufferType: current.bufferType,
|
||||
viewportY: targetY,
|
||||
baseY: current.baseY
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
const existing = readStoredIntent(terminal)
|
||||
// Why: a scrollback shorter than the stored pin means the buffer is being
|
||||
// rebuilt; re-latching from it would overwrite the durable line with the
|
||||
// cleared buffer's line 0.
|
||||
if (existing?.kind === 'pinnedViewport' && current.baseY < existing.baseY) {
|
||||
return
|
||||
}
|
||||
writeIntent(terminal, 'pinnedViewport')
|
||||
}
|
||||
|
||||
export function enforceTerminalCurrentScrollIntent(terminal: TerminalScrollIntentTarget): void {
|
||||
if (isTerminalScrollIntentRebuildInFlight(terminal)) {
|
||||
return
|
||||
}
|
||||
const existing = readStoredIntent(terminal)
|
||||
if (!existing) {
|
||||
enforceTerminalWriteScrollIntent(terminal, captureTerminalWriteScrollIntent(terminal))
|
||||
restoreTerminalStructuralScrollIntent(terminal, captureTerminalStructuralScrollIntent(terminal))
|
||||
return
|
||||
}
|
||||
const snapshot = {
|
||||
kind: existing.kind,
|
||||
bufferType: existing.bufferType,
|
||||
viewportY: existing.viewportY,
|
||||
baseY: existing.baseY
|
||||
baseY: existing.baseY,
|
||||
revision: existing.revision
|
||||
}
|
||||
if (snapshot.kind === 'pinnedViewport' && isAtBottom(snapshot.viewportY, snapshot.baseY)) {
|
||||
if (
|
||||
snapshot.kind === 'pinnedViewport' &&
|
||||
isTerminalViewportAtBottom(snapshot.viewportY, snapshot.baseY)
|
||||
) {
|
||||
// Why: a pin recorded at the bottom means the viewport never detached;
|
||||
// resuming must follow live output, not freeze at that stale line.
|
||||
snapshot.kind = 'followOutput'
|
||||
}
|
||||
const current = readBufferSnapshot(terminal)
|
||||
const current = readTerminalScrollBufferSnapshot(terminal)
|
||||
// Why: a shorter live buffer than the stored intent means the buffer was
|
||||
// rebuilt (snapshot replay/remount); absolute lines are renumbered there.
|
||||
const restoreBy =
|
||||
snapshot.kind === 'pinnedViewport' && current && current.baseY < snapshot.baseY
|
||||
? 'bottomOffset'
|
||||
: 'viewportLine'
|
||||
enforceTerminalWriteScrollIntent(terminal, snapshot, { restoreBy })
|
||||
}
|
||||
|
||||
export function attachTerminalScrollIntentTracking(
|
||||
terminal: TerminalScrollIntentTarget,
|
||||
host: HTMLElement,
|
||||
intentKey?: TerminalScrollIntentKey
|
||||
): IDisposable {
|
||||
if (!bindTerminalScrollIntentKey(terminal, intentKey)) {
|
||||
syncTerminalScrollIntentFromViewport(terminal)
|
||||
}
|
||||
let pointerScrollActive = false
|
||||
|
||||
const onWheel = (event: WheelEvent): void => {
|
||||
if (event.deltaY < 0) {
|
||||
markTerminalPinnedViewport(terminal)
|
||||
syncTerminalScrollIntentSoon(terminal, { preservePinnedAtBottom: true })
|
||||
return
|
||||
}
|
||||
syncTerminalScrollIntentSoon(terminal)
|
||||
}
|
||||
|
||||
const onPointerDown = (event: PointerEvent): void => {
|
||||
pointerScrollActive = isTerminalScrollIntentPointerTarget(event.target)
|
||||
}
|
||||
|
||||
const onPointerDone = (): void => {
|
||||
if (!pointerScrollActive) {
|
||||
return
|
||||
}
|
||||
pointerScrollActive = false
|
||||
syncTerminalScrollIntentFromViewport(terminal)
|
||||
}
|
||||
|
||||
const onScroll = (): void => {
|
||||
if (pointerScrollActive) {
|
||||
syncTerminalScrollIntentFromViewport(terminal)
|
||||
}
|
||||
}
|
||||
|
||||
host.addEventListener('wheel', onWheel, { capture: true, passive: true })
|
||||
host.addEventListener('pointerdown', onPointerDown, true)
|
||||
host.addEventListener('scroll', onScroll, true)
|
||||
globalThis.addEventListener?.('pointerup', onPointerDone, true)
|
||||
globalThis.addEventListener?.('pointercancel', onPointerDone, true)
|
||||
return {
|
||||
dispose: () => {
|
||||
host.removeEventListener('wheel', onWheel, true)
|
||||
host.removeEventListener('pointerdown', onPointerDown, true)
|
||||
host.removeEventListener('scroll', onScroll, true)
|
||||
globalThis.removeEventListener?.('pointerup', onPointerDone, true)
|
||||
globalThis.removeEventListener?.('pointercancel', onPointerDone, true)
|
||||
}
|
||||
}
|
||||
restoreTerminalStructuralScrollIntent(terminal, snapshot, { restoreBy })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
import { markTerminalFollowOutput, type TerminalScrollIntentTarget } from './terminal-scroll-intent'
|
||||
|
||||
type TerminalScrollbackClearTarget = TerminalScrollIntentTarget & {
|
||||
clear: () => void
|
||||
scrollToBottom: () => void
|
||||
}
|
||||
|
||||
export function clearTerminalScrollbackAndFollowOutput(
|
||||
terminal: TerminalScrollbackClearTarget
|
||||
): void {
|
||||
terminal.clear()
|
||||
// Why: xterm clear() leaves BufferService.isUserScrolling latched when the
|
||||
// viewport was pinned, so a public zero-distance bottom scroll must reset it.
|
||||
terminal.scrollToBottom()
|
||||
markTerminalFollowOutput(terminal)
|
||||
}
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createTerminalStructuralReplayCoordinator } from './terminal-structural-replay-coordinator'
|
||||
import {
|
||||
markTerminalPinnedViewport,
|
||||
syncTerminalScrollIntentFromViewport
|
||||
} from './terminal-scroll-intent'
|
||||
import {
|
||||
isTerminalScrollIntentRebuildInFlight,
|
||||
onTerminalScrollIntentBufferRebuildComplete
|
||||
} from './terminal-scroll-intent-rebuild'
|
||||
import { restoreScrollStateAfterFit } from './pane-scroll'
|
||||
import type { ScrollState } from './pane-manager-types'
|
||||
|
||||
function createTerminal(viewportY: number, baseY: number) {
|
||||
const active = { type: 'normal', viewportY, baseY }
|
||||
return {
|
||||
buffer: { active },
|
||||
scrollToBottom: vi.fn(() => {
|
||||
active.viewportY = active.baseY
|
||||
}),
|
||||
scrollToLine: vi.fn((line: number) => {
|
||||
active.viewportY = line
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
||||
let resolve = (): void => {}
|
||||
const promise = new Promise<void>((resolvePromise) => {
|
||||
resolve = resolvePromise
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('terminal structural replay coordinator', () => {
|
||||
it('serializes rebuilds and restores a pinned bottom offset after each parse', async () => {
|
||||
const terminal = createTerminal(80, 100)
|
||||
markTerminalPinnedViewport(terminal)
|
||||
const coordinator = createTerminalStructuralReplayCoordinator(terminal)
|
||||
const firstParsed = deferred()
|
||||
const secondParsed = deferred()
|
||||
const starts: string[] = []
|
||||
|
||||
const first = coordinator.run(async () => {
|
||||
starts.push('first')
|
||||
terminal.buffer.active.viewportY = 0
|
||||
terminal.buffer.active.baseY = 0
|
||||
await firstParsed.promise
|
||||
terminal.buffer.active.viewportY = 200
|
||||
terminal.buffer.active.baseY = 200
|
||||
})
|
||||
const second = coordinator.run(async () => {
|
||||
starts.push('second')
|
||||
terminal.buffer.active.viewportY = 0
|
||||
terminal.buffer.active.baseY = 0
|
||||
await secondParsed.promise
|
||||
terminal.buffer.active.viewportY = 300
|
||||
terminal.buffer.active.baseY = 300
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(starts).toEqual(['first'])
|
||||
firstParsed.resolve()
|
||||
await first
|
||||
expect(terminal.buffer.active.viewportY).toBe(180)
|
||||
await Promise.resolve()
|
||||
expect(starts).toEqual(['first', 'second'])
|
||||
secondParsed.resolve()
|
||||
await second
|
||||
expect(terminal.buffer.active.viewportY).toBe(280)
|
||||
})
|
||||
|
||||
it('lets user viewport intent observed during replay supersede the old pin', async () => {
|
||||
const terminal = createTerminal(80, 100)
|
||||
markTerminalPinnedViewport(terminal)
|
||||
const coordinator = createTerminalStructuralReplayCoordinator(terminal)
|
||||
const parsed = deferred()
|
||||
|
||||
const completion = coordinator.run(async () => {
|
||||
terminal.buffer.active.viewportY = 0
|
||||
terminal.buffer.active.baseY = 0
|
||||
onTerminalScrollIntentBufferRebuildComplete(terminal, (completed) => {
|
||||
if (completed) {
|
||||
syncTerminalScrollIntentFromViewport(terminal, { allowBufferShrink: true })
|
||||
}
|
||||
})
|
||||
await parsed.promise
|
||||
terminal.buffer.active.viewportY = 190
|
||||
terminal.buffer.active.baseY = 200
|
||||
})
|
||||
|
||||
parsed.resolve()
|
||||
await completion
|
||||
expect(terminal.buffer.active.viewportY).toBe(190)
|
||||
expect(terminal.scrollToLine).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('releases a rebuild on disposal without restoring a half-parsed buffer', async () => {
|
||||
const terminal = createTerminal(80, 100)
|
||||
markTerminalPinnedViewport(terminal)
|
||||
const coordinator = createTerminalStructuralReplayCoordinator(terminal)
|
||||
const neverParsed = new Promise<void>(() => {})
|
||||
let postRebuildCompleted: boolean | null = null
|
||||
|
||||
const completion = coordinator.run(async () => {
|
||||
terminal.buffer.active.viewportY = 0
|
||||
terminal.buffer.active.baseY = 0
|
||||
onTerminalScrollIntentBufferRebuildComplete(terminal, (completed) => {
|
||||
postRebuildCompleted = completed
|
||||
})
|
||||
await neverParsed
|
||||
})
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
coordinator.dispose()
|
||||
await completion
|
||||
|
||||
expect(postRebuildCompleted).toBe(false)
|
||||
expect(terminal.buffer.active.viewportY).toBe(0)
|
||||
expect(terminal.scrollToLine).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels a pre-existing fit restore before replay can make it stale', async () => {
|
||||
const rafCallbacks: FrameRequestCallback[] = []
|
||||
const cancelAnimationFrame = vi.fn()
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
rafCallbacks.push(callback)
|
||||
return rafCallbacks.length
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrame)
|
||||
const terminal = createTerminal(80, 100) as ReturnType<typeof createTerminal> & {
|
||||
element: object | null
|
||||
}
|
||||
terminal.element = null
|
||||
const staleState: ScrollState = {
|
||||
bufferType: 'normal',
|
||||
wasAtBottom: false,
|
||||
viewportY: 20,
|
||||
baseY: 100
|
||||
}
|
||||
restoreScrollStateAfterFit(terminal as never, staleState, {
|
||||
onRestored: vi.fn(),
|
||||
shouldRestore: () => true
|
||||
})
|
||||
expect(rafCallbacks).toHaveLength(1)
|
||||
|
||||
markTerminalPinnedViewport(terminal)
|
||||
const coordinator = createTerminalStructuralReplayCoordinator(terminal)
|
||||
const parsed = deferred()
|
||||
const completion = coordinator.run(async () => {
|
||||
terminal.buffer.active.viewportY = 0
|
||||
terminal.buffer.active.baseY = 0
|
||||
await parsed.promise
|
||||
terminal.buffer.active.viewportY = 200
|
||||
terminal.buffer.active.baseY = 200
|
||||
})
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(cancelAnimationFrame).toHaveBeenCalledWith(1)
|
||||
|
||||
terminal.element = {}
|
||||
parsed.resolve()
|
||||
await completion
|
||||
expect(terminal.buffer.active.viewportY).toBe(180)
|
||||
rafCallbacks[0]?.(0)
|
||||
expect(terminal.buffer.active.viewportY).toBe(180)
|
||||
})
|
||||
|
||||
it('restores and releases replay when an optional completion listener throws', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const terminal = createTerminal(80, 100)
|
||||
markTerminalPinnedViewport(terminal)
|
||||
const coordinator = createTerminalStructuralReplayCoordinator(terminal)
|
||||
const laterCompletion = vi.fn()
|
||||
|
||||
await coordinator.run(() => {
|
||||
terminal.buffer.active.viewportY = 200
|
||||
terminal.buffer.active.baseY = 200
|
||||
onTerminalScrollIntentBufferRebuildComplete(terminal, () => {
|
||||
throw new Error('optional listener failed')
|
||||
})
|
||||
onTerminalScrollIntentBufferRebuildComplete(terminal, laterCompletion)
|
||||
})
|
||||
|
||||
expect(laterCompletion).toHaveBeenCalledWith(true)
|
||||
expect(terminal.buffer.active.viewportY).toBe(180)
|
||||
expect(isTerminalScrollIntentRebuildInFlight(terminal)).toBe(false)
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'[terminal] scroll-intent rebuild completion failed',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps later replay work serialized behind an asynchronous post-restore fit', async () => {
|
||||
const terminal = createTerminal(80, 100)
|
||||
const coordinator = createTerminalStructuralReplayCoordinator(terminal)
|
||||
const fitCompleted = deferred()
|
||||
const fitStarted = deferred()
|
||||
const events: string[] = []
|
||||
|
||||
const first = coordinator.run(
|
||||
() => {
|
||||
events.push('first-replay')
|
||||
},
|
||||
{
|
||||
afterRestore: async () => {
|
||||
events.push('first-fit')
|
||||
fitStarted.resolve()
|
||||
await fitCompleted.promise
|
||||
}
|
||||
}
|
||||
)
|
||||
const second = coordinator.run(() => {
|
||||
events.push('second-replay')
|
||||
})
|
||||
|
||||
await fitStarted.promise
|
||||
expect(events).toEqual(['first-replay', 'first-fit'])
|
||||
fitCompleted.resolve()
|
||||
await first
|
||||
await second
|
||||
expect(events).toEqual(['first-replay', 'first-fit', 'second-replay'])
|
||||
})
|
||||
|
||||
it('releases an asynchronous post-restore wait when the coordinator is disposed', async () => {
|
||||
const terminal = createTerminal(80, 100)
|
||||
const coordinator = createTerminalStructuralReplayCoordinator(terminal)
|
||||
const fitNeverCompletes = new Promise<void>(() => {})
|
||||
const fitStarted = deferred()
|
||||
const completion = coordinator.run(() => undefined, {
|
||||
afterRestore: async () => {
|
||||
fitStarted.resolve()
|
||||
await fitNeverCompletes
|
||||
}
|
||||
})
|
||||
|
||||
await fitStarted.promise
|
||||
coordinator.dispose()
|
||||
|
||||
await expect(completion).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import {
|
||||
captureTerminalStructuralScrollIntent,
|
||||
restoreTerminalStructuralScrollIntent,
|
||||
type TerminalScrollIntentTarget
|
||||
} from './terminal-scroll-intent'
|
||||
import {
|
||||
beginTerminalScrollIntentBufferRebuild,
|
||||
cancelTerminalScrollIntentBufferRebuildCompletions,
|
||||
endTerminalScrollIntentBufferRebuild
|
||||
} from './terminal-scroll-intent-rebuild'
|
||||
import { cancelDeferredScrollRestore } from './pane-scroll'
|
||||
|
||||
type StructuralReplayTask = () => void | Promise<void>
|
||||
|
||||
type StructuralReplayOptions = {
|
||||
shouldRestore?: () => boolean
|
||||
afterRestore?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export type TerminalStructuralReplayCoordinator = {
|
||||
run: (task: StructuralReplayTask, options?: StructuralReplayOptions) => Promise<void>
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
// Why: clear-and-replay bytes parse later and can overlap. One pane-scoped
|
||||
// queue prevents dimension changes and stale viewport restores from interleaving.
|
||||
export function createTerminalStructuralReplayCoordinator(
|
||||
terminal: TerminalScrollIntentTarget
|
||||
): TerminalStructuralReplayCoordinator {
|
||||
let disposed = false
|
||||
let activeCancellation: (() => void) | null = null
|
||||
let tail = Promise.resolve()
|
||||
|
||||
const run = (
|
||||
task: StructuralReplayTask,
|
||||
options: StructuralReplayOptions = {}
|
||||
): Promise<void> => {
|
||||
const completion = tail
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
const intent = captureTerminalStructuralScrollIntent(terminal)
|
||||
// Why: a pre-replay fit retry can otherwise run after this transaction
|
||||
// and restore a stale marker over the authoritative replay viewport.
|
||||
cancelDeferredScrollRestore(terminal)
|
||||
beginTerminalScrollIntentBufferRebuild(terminal)
|
||||
let cancelTask = (): void => {}
|
||||
const cancellation = new Promise<void>((resolve) => {
|
||||
cancelTask = resolve
|
||||
})
|
||||
activeCancellation = () => {
|
||||
cancelTask()
|
||||
}
|
||||
try {
|
||||
const taskCompletion = Promise.resolve(task())
|
||||
await Promise.race([taskCompletion, cancellation])
|
||||
} finally {
|
||||
endTerminalScrollIntentBufferRebuild(terminal)
|
||||
try {
|
||||
const shouldRestore = !disposed && options.shouldRestore?.() !== false
|
||||
if (shouldRestore) {
|
||||
restoreTerminalStructuralScrollIntent(terminal, intent, { restoreBy: 'bottomOffset' })
|
||||
// Why: live bytes must remain serialized behind replay until any
|
||||
// post-restore fit has produced the authoritative destination grid.
|
||||
await Promise.race([Promise.resolve(options.afterRestore?.()), cancellation])
|
||||
}
|
||||
} finally {
|
||||
activeCancellation = null
|
||||
}
|
||||
}
|
||||
})
|
||||
tail = completion
|
||||
return completion
|
||||
}
|
||||
|
||||
return {
|
||||
run,
|
||||
dispose: () => {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
disposed = true
|
||||
// Why: a torn-down terminal may silently drop write callbacks. Release
|
||||
// the rebuild without sampling its half-parsed buffer into the keyed pin.
|
||||
cancelTerminalScrollIntentBufferRebuildCompletions(terminal)
|
||||
activeCancellation?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import type { Terminal } from '@xterm/xterm'
|
||||
|
||||
// Why: xterm 6 can leave its scrollbar thumb stale when ydisp is unchanged.
|
||||
// A synchronous one-line jiggle updates the scrollbar without a visible paint.
|
||||
export function forceTerminalViewportScrollbarSync(terminal: Terminal): void {
|
||||
const buf = terminal.buffer.active
|
||||
if (buf.viewportY >= buf.baseY) {
|
||||
// Why: jiggle-scrolling at bottom makes xterm stop following active output
|
||||
// after split-pane resizes; scrollToBottom already places the thumb there.
|
||||
return
|
||||
}
|
||||
if (buf.viewportY > 0) {
|
||||
safeScrollCall(() => terminal.scrollLines(-1))
|
||||
safeScrollCall(() => terminal.scrollLines(1))
|
||||
} else if (buf.viewportY < buf.baseY) {
|
||||
safeScrollCall(() => terminal.scrollLines(1))
|
||||
safeScrollCall(() => terminal.scrollLines(-1))
|
||||
}
|
||||
}
|
||||
|
||||
function safeScrollCall(fn: () => void): void {
|
||||
try {
|
||||
fn()
|
||||
} catch (error) {
|
||||
if (!(error instanceof TypeError) || !/dimensions/.test(error.message)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
/**
|
||||
* Contract test for xterm's native user-scrolling ownership (vendored
|
||||
* 6.1.0-beta.287; @xterm/headless shares BufferService with @xterm/xterm).
|
||||
*
|
||||
* Orca's live PTY write path performs NO scroll-intent enforcement — it
|
||||
* relies on xterm core keeping a scrolled-up viewport stable and following
|
||||
* output at the bottom (BufferService.isUserScrolling, consumed atomically
|
||||
* inside scroll()). App-side enforcement is scoped to structural operations
|
||||
* (snapshot replay, remount, fit reflow) in terminal-scroll-intent.ts.
|
||||
*
|
||||
* If an xterm upgrade breaks any assertion here, the live write path loses
|
||||
* its follow/pin semantics silently — fix the write path before bumping.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Terminal } from '@xterm/headless'
|
||||
import packageJson from '../../../../../package.json'
|
||||
import { clearTerminalScrollbackAndFollowOutput } from './terminal-scrollback-clear'
|
||||
|
||||
type TerminalWithBufferService = Terminal & {
|
||||
_core?: {
|
||||
_bufferService?: { isUserScrolling?: boolean }
|
||||
coreService?: { onUserInput?: (listener: () => void) => { dispose: () => void } }
|
||||
}
|
||||
}
|
||||
|
||||
function write(term: Terminal, data: string): Promise<void> {
|
||||
return new Promise((resolve) => term.write(data, resolve))
|
||||
}
|
||||
|
||||
async function writeLines(term: Terminal, count: number, label: string): Promise<void> {
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
await write(term, `${label}${i}\r\n`)
|
||||
}
|
||||
}
|
||||
|
||||
describe('xterm native user-scrolling contract (vendored 6.1.0-beta.287)', () => {
|
||||
it('pins headless and renderer xterm to the same version', () => {
|
||||
expect(packageJson.dependencies['@xterm/headless']).toBe(
|
||||
packageJson.devDependencies['@xterm/xterm']
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a scrolled-up viewport stable while output is written', async () => {
|
||||
const term = new Terminal({ rows: 10, cols: 40, scrollback: 1000, allowProposedApi: true })
|
||||
await writeLines(term, 30, 'line')
|
||||
const buffer = term.buffer.active
|
||||
expect(buffer.viewportY).toBe(buffer.baseY)
|
||||
|
||||
term.scrollLines(-5)
|
||||
const pinnedY = buffer.viewportY
|
||||
expect(pinnedY).toBe(buffer.baseY - 5)
|
||||
|
||||
await writeLines(term, 10, 'more')
|
||||
expect(buffer.viewportY).toBe(pinnedY)
|
||||
expect(buffer.baseY).toBe(pinnedY + 15)
|
||||
})
|
||||
|
||||
it('treats a viewport one row above bottom as user-scrolling through output', async () => {
|
||||
const term = new Terminal({
|
||||
rows: 10,
|
||||
cols: 40,
|
||||
scrollback: 1000,
|
||||
allowProposedApi: true
|
||||
}) as TerminalWithBufferService
|
||||
await writeLines(term, 30, 'line')
|
||||
const buffer = term.buffer.active
|
||||
|
||||
term.scrollLines(-1)
|
||||
const pinnedY = buffer.viewportY
|
||||
expect(pinnedY).toBe(buffer.baseY - 1)
|
||||
expect(term._core?._bufferService?.isUserScrolling).toBe(true)
|
||||
|
||||
await writeLines(term, 5, 'more')
|
||||
expect(buffer.viewportY).toBe(pinnedY)
|
||||
})
|
||||
|
||||
it('follows output at the bottom and re-follows after scrolling back down', async () => {
|
||||
const term = new Terminal({ rows: 10, cols: 40, scrollback: 1000, allowProposedApi: true })
|
||||
await writeLines(term, 30, 'line')
|
||||
const buffer = term.buffer.active
|
||||
|
||||
await writeLines(term, 5, 'tail')
|
||||
expect(buffer.viewportY).toBe(buffer.baseY)
|
||||
|
||||
term.scrollLines(-5)
|
||||
term.scrollToBottom()
|
||||
await writeLines(term, 5, 'after')
|
||||
expect(buffer.viewportY).toBe(buffer.baseY)
|
||||
})
|
||||
|
||||
it('applies scrollOnUserInput before notifying onData listeners', async () => {
|
||||
const term = new Terminal({ rows: 10, cols: 40, scrollback: 1000, allowProposedApi: true })
|
||||
await writeLines(term, 30, 'line')
|
||||
const buffer = term.buffer.active
|
||||
term.scrollLines(-5)
|
||||
let viewportSeenByOnData = -1
|
||||
const subscription = term.onData(() => {
|
||||
viewportSeenByOnData = buffer.viewportY
|
||||
})
|
||||
|
||||
term.input('a', true)
|
||||
|
||||
// Why: Orca resyncs typing intent synchronously from onData, so this
|
||||
// xterm ordering is part of the pinned-version contract.
|
||||
expect(viewportSeenByOnData).toBe(buffer.baseY)
|
||||
subscription.dispose()
|
||||
})
|
||||
|
||||
it('distinguishes real user input from parser auto-replies', async () => {
|
||||
const term = new Terminal({
|
||||
rows: 10,
|
||||
cols: 40,
|
||||
allowProposedApi: true
|
||||
}) as TerminalWithBufferService
|
||||
expect(term._core?.coreService?.onUserInput).toBeTypeOf('function')
|
||||
let userInputCount = 0
|
||||
const subscription = term._core?.coreService?.onUserInput?.(() => {
|
||||
userInputCount += 1
|
||||
})
|
||||
|
||||
term.input('a', true)
|
||||
await write(term, '\x1b[6n')
|
||||
|
||||
expect(userInputCount).toBe(1)
|
||||
subscription?.dispose()
|
||||
})
|
||||
|
||||
it('walks a pinned viewport down content-stably when scrollback trims', async () => {
|
||||
const term = new Terminal({ rows: 5, cols: 20, scrollback: 20, allowProposedApi: true })
|
||||
await writeLines(term, 30, 'x')
|
||||
const buffer = term.buffer.active
|
||||
term.scrollLines(-10)
|
||||
const pinnedY = buffer.viewportY
|
||||
const fullBaseY = buffer.baseY
|
||||
|
||||
await writeLines(term, 10, 'trim')
|
||||
// Buffer is at capacity: baseY stays put while each trimmed line shifts
|
||||
// the pinned viewport up by one so the visible content does not move.
|
||||
expect(buffer.baseY).toBe(fullBaseY)
|
||||
expect(buffer.viewportY).toBe(Math.max(0, pinnedY - 10))
|
||||
})
|
||||
|
||||
it('exposes the isUserScrolling flag the structural restore paths depend on', async () => {
|
||||
const term = new Terminal({
|
||||
rows: 10,
|
||||
cols: 40,
|
||||
scrollback: 1000,
|
||||
allowProposedApi: true
|
||||
}) as TerminalWithBufferService
|
||||
await writeLines(term, 30, 'line')
|
||||
const bufferService = term._core?._bufferService
|
||||
expect(typeof bufferService?.isUserScrolling).toBe('boolean')
|
||||
|
||||
// scrollLines/scrollToBottom self-manage the flag, so Orca's programmatic
|
||||
// scroll restores inherit xterm's native live-output ownership.
|
||||
expect(bufferService?.isUserScrolling).toBe(false)
|
||||
term.scrollLines(-5)
|
||||
expect(bufferService?.isUserScrolling).toBe(true)
|
||||
term.scrollToBottom()
|
||||
expect(bufferService?.isUserScrolling).toBe(false)
|
||||
})
|
||||
|
||||
it('resets native user-scrolling when a pinned scrollback is cleared', async () => {
|
||||
const term = new Terminal({
|
||||
rows: 10,
|
||||
cols: 40,
|
||||
scrollback: 1000,
|
||||
allowProposedApi: true
|
||||
}) as TerminalWithBufferService
|
||||
await writeLines(term, 30, 'line')
|
||||
term.scrollLines(-5)
|
||||
expect(term._core?._bufferService?.isUserScrolling).toBe(true)
|
||||
|
||||
clearTerminalScrollbackAndFollowOutput(term)
|
||||
expect(term.buffer.active.viewportY).toBe(0)
|
||||
expect(term.buffer.active.baseY).toBe(0)
|
||||
expect(term._core?._bufferService?.isUserScrolling).toBe(false)
|
||||
|
||||
await writeLines(term, 15, 'after-clear')
|
||||
expect(term.buffer.active.viewportY).toBe(term.buffer.active.baseY)
|
||||
})
|
||||
})
|
||||
|
|
@ -32,7 +32,7 @@ const CODEX_TRUST_PROMPT_RE =
|
|||
const CODEX_UPDATE_PROMPT_RE = /update available|install update|Skip for now|Skip until next/i
|
||||
const CODEX_SKILL_PREVIEW_RE = /Press enter to insert|esc to close|electron|orca-cli|orca-emulator/i
|
||||
const SETUP_PANE_ACTIVITY_RE = /install-orca-skills|pnpm|Progress:|Packages:|Lockfile/i
|
||||
const CLEAN_SKILL_ROW_RE = /^ [A-Za-z][A-Za-z0-9 -]{1,32}\s+\[Skill\]\s/
|
||||
const CLEAN_SKILL_ROW_RE = /^ [A-Za-z][A-Za-z0-9 .-]{1,32}\s+\[Skill\]\s/
|
||||
const CODEX_READY_SETTLE_MS = 3_500
|
||||
const SETUP_CHANGES_AFTER_PREVIEW = 3
|
||||
|
||||
|
|
@ -44,6 +44,12 @@ type PaneDescriptor = {
|
|||
rect: { x: number; y: number; width: number; height: number }
|
||||
cols: number
|
||||
rows: number
|
||||
proposed: { cols: number; rows: number } | null
|
||||
appliedPtySize: { cols: number; rows: number } | null
|
||||
viewportY: number
|
||||
baseY: number
|
||||
isUserScrolling: boolean | null
|
||||
screenToPaneGap: number | null
|
||||
hasWebgl: boolean
|
||||
}
|
||||
|
||||
|
|
@ -259,7 +265,7 @@ async function forceTerminalWebgl(page: Page): Promise<boolean> {
|
|||
}
|
||||
|
||||
async function describeActiveTerminalPanes(page: Page): Promise<PaneDescriptor[]> {
|
||||
return page.evaluate(() => {
|
||||
return page.evaluate(async () => {
|
||||
const state = window.__store?.getState()
|
||||
const worktreeId = state?.activeWorktreeId
|
||||
const tabId =
|
||||
|
|
@ -274,29 +280,54 @@ async function describeActiveTerminalPanes(page: Page): Promise<PaneDescriptor[]
|
|||
throw new Error('Expected a split terminal tab with at least two panes')
|
||||
}
|
||||
const diagnostics = manager.getRenderingDiagnostics?.() ?? []
|
||||
return [...panes]
|
||||
.sort((a, b) => {
|
||||
const aRect = a.container.getBoundingClientRect()
|
||||
const bRect = b.container.getBoundingClientRect()
|
||||
return aRect.x - bRect.x || aRect.y - bRect.y
|
||||
})
|
||||
.map((pane) => {
|
||||
if (!pane.container.dataset.ptyId) {
|
||||
throw new Error(`Terminal pane ${pane.id} has no PTY binding`)
|
||||
}
|
||||
const rect = pane.container.getBoundingClientRect()
|
||||
const rendering = diagnostics.find((diagnostic) => diagnostic.paneId === pane.id)
|
||||
return {
|
||||
tabId,
|
||||
paneId: pane.id,
|
||||
leafId: pane.leafId,
|
||||
ptyId: pane.container.dataset.ptyId,
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
cols: pane.terminal.cols,
|
||||
rows: pane.terminal.rows,
|
||||
hasWebgl: rendering?.hasWebgl ?? false
|
||||
}
|
||||
})
|
||||
return Promise.all(
|
||||
[...panes]
|
||||
.sort((a, b) => {
|
||||
const aRect = a.container.getBoundingClientRect()
|
||||
const bRect = b.container.getBoundingClientRect()
|
||||
return aRect.x - bRect.x || aRect.y - bRect.y
|
||||
})
|
||||
.map(async (pane) => {
|
||||
const ptyId = pane.container.dataset.ptyId
|
||||
if (!ptyId) {
|
||||
throw new Error(`Terminal pane ${pane.id} has no PTY binding`)
|
||||
}
|
||||
const rect = pane.container.getBoundingClientRect()
|
||||
const screenRect = pane.container
|
||||
.querySelector<HTMLElement>('.xterm-screen')
|
||||
?.getBoundingClientRect()
|
||||
const rendering = diagnostics.find((diagnostic) => diagnostic.paneId === pane.id)
|
||||
let proposed: { cols: number; rows: number } | null = null
|
||||
try {
|
||||
proposed = pane.fitAddon.proposeDimensions() ?? null
|
||||
} catch {
|
||||
proposed = null
|
||||
}
|
||||
const appliedPtySize = await window.api.pty.getSize(ptyId).catch(() => null)
|
||||
const terminalCore = pane.terminal as typeof pane.terminal & {
|
||||
_core?: { _bufferService?: { isUserScrolling?: boolean } }
|
||||
}
|
||||
return {
|
||||
tabId,
|
||||
paneId: pane.id,
|
||||
leafId: pane.leafId,
|
||||
ptyId,
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
cols: pane.terminal.cols,
|
||||
rows: pane.terminal.rows,
|
||||
proposed,
|
||||
appliedPtySize,
|
||||
viewportY: pane.terminal.buffer.active.viewportY,
|
||||
baseY: pane.terminal.buffer.active.baseY,
|
||||
isUserScrolling:
|
||||
typeof terminalCore._core?._bufferService?.isUserScrolling === 'boolean'
|
||||
? terminalCore._core._bufferService.isUserScrolling
|
||||
: null,
|
||||
screenToPaneGap: screenRect ? rect.right - screenRect.right : null,
|
||||
hasWebgl: rendering?.hasWebgl ?? false
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -490,6 +521,10 @@ async function captureClickEvidence(
|
|||
const beforeWindowPath = persistEvidenceFile('full-window-before-click.png', beforeFullPage)
|
||||
const afterPanePath = persistEvidenceFile('left-pane-after-click.png', afterPane)
|
||||
const bufferPath = persistEvidenceFile('left-pane-buffer.txt', beforeContent)
|
||||
const metricsPath = persistEvidenceFile(
|
||||
'left-pane-metrics.json',
|
||||
`${JSON.stringify(pane, null, 2)}\n`
|
||||
)
|
||||
|
||||
await testInfo.attach('codex-skill-preview-left-pane-before-click', {
|
||||
body: beforePane,
|
||||
|
|
@ -509,7 +544,13 @@ async function captureClickEvidence(
|
|||
})
|
||||
testInfo.annotations.push({
|
||||
type: 'codex-skill-preview-evidence-files',
|
||||
description: JSON.stringify({ beforePanePath, beforeWindowPath, afterPanePath, bufferPath })
|
||||
description: JSON.stringify({
|
||||
beforePanePath,
|
||||
beforeWindowPath,
|
||||
afterPanePath,
|
||||
bufferPath,
|
||||
metricsPath
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
|
|
@ -563,6 +604,9 @@ test.describe('Codex skill preview terminal artifact repro @headful', () => {
|
|||
const rightPane = await getRightTerminalPane(orcaPage)
|
||||
expect(leftPane.hasWebgl).toBe(true)
|
||||
expect(rightPane.hasWebgl).toBe(true)
|
||||
expect(leftPane.proposed).toEqual({ cols: leftPane.cols, rows: leftPane.rows })
|
||||
expect(leftPane.appliedPtySize).toEqual({ cols: leftPane.cols, rows: leftPane.rows })
|
||||
expect(leftPane.isUserScrolling).toBe(false)
|
||||
await waitForPaneContent(
|
||||
orcaPage,
|
||||
rightPane.tabId,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,210 @@
|
|||
import { randomUUID } from 'node:crypto'
|
||||
import { rmSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import {
|
||||
ensureTerminalVisible,
|
||||
getAllWorktreeIds,
|
||||
switchToWorktree,
|
||||
waitForActiveWorktree,
|
||||
waitForSessionReady
|
||||
} from './helpers/store'
|
||||
import {
|
||||
getTerminalContent,
|
||||
sendToTerminal,
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager
|
||||
} from './helpers/terminal'
|
||||
import { nodeTerminalCommand } from './terminal-node-command'
|
||||
import { waitForPtyShellEcho } from './terminal-pty-readiness'
|
||||
|
||||
// A Codex-like agent: pre-fills scrollback, then keeps streaming — commits a
|
||||
// row and redraws a synchronized-output "Working…" status frame every tick.
|
||||
// The stream continues while the pane is hidden, which is what routes the
|
||||
// return through the hidden-output snapshot restore.
|
||||
function streamingAgentFixtureScript(runId: string): string {
|
||||
return `
|
||||
async function writeStdout(chunk) {
|
||||
await new Promise((resolve) => process.stdout.write(chunk, resolve))
|
||||
}
|
||||
let row = 0
|
||||
let pre = ''
|
||||
for (; row < 300; row += 1) {
|
||||
pre += 'STREAMING_SWITCH_${runId}_ROW_' + String(row).padStart(4, '0') + '\\n'
|
||||
}
|
||||
await writeStdout(pre + 'STREAMING_SWITCH_${runId}_PRESTREAM_DONE\\n')
|
||||
const spinner = ['|', '/', '-', '\\\\']
|
||||
for (let tick = 0; tick < 800; tick += 1) {
|
||||
let frame = '\\x1b[?2026h'
|
||||
if (tick % 3 === 0) {
|
||||
frame += '\\r\\x1b[2KSTREAMING_SWITCH_${runId}_ROW_' + String(row).padStart(4, '0') + '\\n'
|
||||
row += 1
|
||||
}
|
||||
frame += '\\r\\x1b[2KWorking… ' + spinner[tick % 4] + ' tick=' + tick + '\\x1b[?2026l'
|
||||
await writeStdout(frame)
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
async function closeFeatureTips(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const store = window.__store
|
||||
store?.getState().markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation'])
|
||||
if (store?.getState().activeModal === 'feature-tips') {
|
||||
store.getState().closeModal()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function pinActiveTerminalNearBottom(page: Page): Promise<{
|
||||
tabId: string
|
||||
targetViewportY: number
|
||||
baseY: number
|
||||
}> {
|
||||
return page.evaluate(() => {
|
||||
const store = window.__store
|
||||
const state = store?.getState()
|
||||
const worktreeId = state?.activeWorktreeId
|
||||
const tabId =
|
||||
state?.activeTabType === 'terminal'
|
||||
? state.activeTabId
|
||||
: worktreeId
|
||||
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
|
||||
: null
|
||||
const manager = tabId ? window.__paneManagers?.get(tabId) : null
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
if (!tabId || !pane) {
|
||||
throw new Error('Active terminal pane unavailable')
|
||||
}
|
||||
const target = pane.container.querySelector<HTMLElement>('.xterm') ?? pane.container
|
||||
target.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
|
||||
deltaY: -240
|
||||
})
|
||||
)
|
||||
const buffer = pane.terminal.buffer.active
|
||||
const targetViewportY = Math.max(0, buffer.baseY - 6)
|
||||
pane.terminal.scrollToLine(targetViewportY)
|
||||
pane.container
|
||||
.querySelector<HTMLElement>('.xterm-viewport')
|
||||
?.dispatchEvent(new Event('scroll', { bubbles: true }))
|
||||
return { tabId, targetViewportY, baseY: buffer.baseY }
|
||||
})
|
||||
}
|
||||
|
||||
async function readSettledViewport(
|
||||
page: Page,
|
||||
tabId: string
|
||||
): Promise<{ viewportY: number; baseY: number }> {
|
||||
// Wait until the replay has actually parsed (scrollback regrew) and the
|
||||
// viewport stopped moving, then report where it settled.
|
||||
let last: { viewportY: number; baseY: number } | null = null
|
||||
let stableCount = 0
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const current = await page.evaluate((tabId) => {
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
const buffer = pane?.terminal?.buffer?.active
|
||||
return buffer ? { viewportY: buffer.viewportY, baseY: buffer.baseY } : null
|
||||
}, tabId)
|
||||
if (!current || current.baseY < 100) {
|
||||
stableCount = 0
|
||||
last = current
|
||||
return false
|
||||
}
|
||||
if (last && current.viewportY === last.viewportY) {
|
||||
stableCount += 1
|
||||
} else {
|
||||
stableCount = 0
|
||||
}
|
||||
last = current
|
||||
return stableCount >= 3
|
||||
},
|
||||
{
|
||||
timeout: 20_000,
|
||||
intervals: [250],
|
||||
message: 'terminal viewport did not settle after returning to the streaming worktree'
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
if (!last) {
|
||||
throw new Error('viewport settle poll finished without a sample')
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
test.describe('Terminal pinned viewport with streaming agent across worktree switch', () => {
|
||||
test('returning to a pinned pane with an active stream does not land at the top', async ({
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await closeFeatureTips(orcaPage)
|
||||
const firstWorktreeId = await waitForActiveWorktree(orcaPage)
|
||||
const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find(
|
||||
(id) => id !== firstWorktreeId
|
||||
)
|
||||
test.skip(!secondWorktreeId, 'streaming pinned repro needs the seeded secondary worktree')
|
||||
if (!secondWorktreeId) {
|
||||
return
|
||||
}
|
||||
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage)
|
||||
await waitForPtyShellEcho(orcaPage, ptyId, 15_000)
|
||||
const runId = randomUUID()
|
||||
const scriptPath = path.join(testRepoPath, `.orca-streaming-switch-${runId}.mjs`)
|
||||
writeFileSync(scriptPath, streamingAgentFixtureScript(runId))
|
||||
|
||||
try {
|
||||
await sendToTerminal(orcaPage, ptyId, `${nodeTerminalCommand([scriptPath])}\r`)
|
||||
await expect
|
||||
.poll(() => getTerminalContent(orcaPage, 30_000), {
|
||||
timeout: 15_000,
|
||||
message: 'streaming fixture did not reach terminal scrollback'
|
||||
})
|
||||
.toContain(`STREAMING_SWITCH_${runId}_PRESTREAM_DONE`)
|
||||
|
||||
const pinned = await pinActiveTerminalNearBottom(orcaPage)
|
||||
expect(pinned.baseY).toBeGreaterThan(100)
|
||||
await orcaPage.waitForTimeout(150)
|
||||
|
||||
// Stream continues while hidden; hidden byte drops mark the pane for a
|
||||
// snapshot restore on return.
|
||||
await switchToWorktree(orcaPage, secondWorktreeId)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
await orcaPage.waitForTimeout(3_000)
|
||||
|
||||
await switchToWorktree(orcaPage, firstWorktreeId)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
|
||||
const settled = await readSettledViewport(orcaPage, pinned.tabId)
|
||||
const bottomDistance = settled.baseY - settled.viewportY
|
||||
// The user pinned six rows above the bottom. A faithful restore keeps
|
||||
// them near the pin; the bug clamps to the very top of the scrollback.
|
||||
expect(
|
||||
settled.viewportY,
|
||||
`settled at viewportY=${settled.viewportY} baseY=${settled.baseY} (pinned ${JSON.stringify(pinned)})`
|
||||
).toBeGreaterThan(20)
|
||||
expect(
|
||||
bottomDistance,
|
||||
`settled ${bottomDistance} rows above the bottom (pinned 6 rows above)`
|
||||
).toBeGreaterThan(1)
|
||||
expect(
|
||||
bottomDistance,
|
||||
`settled ${bottomDistance} rows above the bottom (pinned 6 rows above)`
|
||||
).toBeLessThan(80)
|
||||
} finally {
|
||||
rmSync(scriptPath, { force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -5,9 +5,11 @@ import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } fro
|
|||
import {
|
||||
execInTerminal,
|
||||
sendToTerminal,
|
||||
waitForActivePaneHookDescriptor,
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager
|
||||
} from './helpers/terminal'
|
||||
import { waitForTerminalPtyDataInjector } from './helpers/terminal-pty-injection'
|
||||
|
||||
const STREAMING_FIXTURE_PATH = path.join(
|
||||
process.cwd(),
|
||||
|
|
@ -72,6 +74,32 @@ async function waitForMarkerAtBottom(page: Page, marker: string): Promise<void>
|
|||
|
||||
async function dispatchSubRowWheelUp(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const state = window.__store?.getState()
|
||||
const worktreeId = state?.activeWorktreeId
|
||||
const tabId =
|
||||
state?.activeTabType === 'terminal'
|
||||
? state.activeTabId
|
||||
: worktreeId
|
||||
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
|
||||
: null
|
||||
const manager = tabId ? window.__paneManagers?.get(tabId) : null
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
if (!pane?.terminal.element) {
|
||||
throw new Error('Active terminal pane unavailable')
|
||||
}
|
||||
pane.terminal.element.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
|
||||
deltaY: -2
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function dispatchRealWheel(page: Page, deltaY: number): Promise<void> {
|
||||
const point = await page.evaluate(() => {
|
||||
const state = window.__store?.getState()
|
||||
const worktreeId = state?.activeWorktreeId
|
||||
const tabId =
|
||||
|
|
@ -90,19 +118,13 @@ async function dispatchSubRowWheelUp(page: Page): Promise<void> {
|
|||
throw new Error('Active terminal screen unavailable')
|
||||
}
|
||||
const rect = screen.getBoundingClientRect()
|
||||
// A -2px delta is far below one cell height: xterm scrolls zero rows, the
|
||||
// viewport stays at the bottom, but the wheel listener still observes an
|
||||
// upward wheel — the phantom-pin shape from trackpad jitter.
|
||||
const event = new WheelEvent('wheel', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: rect.left + rect.width / 2,
|
||||
clientY: rect.top + Math.min(rect.height - 1, 40),
|
||||
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
|
||||
deltaY: -2
|
||||
})
|
||||
pane.terminal.element.dispatchEvent(event)
|
||||
return {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + Math.min(rect.height - 1, 40)
|
||||
}
|
||||
})
|
||||
await page.mouse.move(point.x, point.y)
|
||||
await page.mouse.wheel(0, deltaY)
|
||||
}
|
||||
|
||||
async function dispatchPlainHomeKeydown(page: Page): Promise<void> {
|
||||
|
|
@ -142,6 +164,60 @@ async function dispatchPlainHomeKeydown(page: Page): Promise<void> {
|
|||
})
|
||||
}
|
||||
|
||||
async function injectQueuedWriteThenType(page: Page, paneKey: string): Promise<void> {
|
||||
await page.evaluate((targetPaneKey) => {
|
||||
const injectionTarget = window as Window & {
|
||||
__terminalPtyDataInjection?: { inject: (paneKey: string, data: string) => boolean }
|
||||
}
|
||||
const state = window.__store?.getState()
|
||||
const worktreeId = state?.activeWorktreeId
|
||||
const tabId =
|
||||
state?.activeTabType === 'terminal'
|
||||
? state.activeTabId
|
||||
: worktreeId
|
||||
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
|
||||
: null
|
||||
const manager = tabId ? window.__paneManagers?.get(tabId) : null
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
if (!pane) {
|
||||
throw new Error('Active terminal pane unavailable')
|
||||
}
|
||||
const terminal = pane.terminal
|
||||
const originalWrite = terminal.write
|
||||
const holder: { write: { data: string; callback?: () => void } | null } = { write: null }
|
||||
terminal.write = ((data: string, callback?: () => void) => {
|
||||
holder.write = { data, callback }
|
||||
}) as typeof terminal.write
|
||||
try {
|
||||
const payload = '\x1b[?2026h\r\x1b[2KWorking in-flight\x1b[?2026l'
|
||||
if (!injectionTarget.__terminalPtyDataInjection?.inject(targetPaneKey, payload)) {
|
||||
throw new Error('PTY injector unavailable')
|
||||
}
|
||||
const textarea = pane.container.querySelector<HTMLTextAreaElement>('.xterm-helper-textarea')
|
||||
if (!textarea) {
|
||||
throw new Error('xterm helper textarea unavailable')
|
||||
}
|
||||
textarea.focus()
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
key: 'x',
|
||||
code: 'KeyX'
|
||||
})
|
||||
Object.defineProperty(event, 'keyCode', { configurable: true, value: 88 })
|
||||
Object.defineProperty(event, 'which', { configurable: true, value: 88 })
|
||||
textarea.dispatchEvent(event)
|
||||
} finally {
|
||||
terminal.write = originalWrite
|
||||
}
|
||||
const heldWrite = holder.write
|
||||
if (!heldWrite) {
|
||||
throw new Error('Foreground terminal write was not captured')
|
||||
}
|
||||
originalWrite.call(terminal, heldWrite.data, heldWrite.callback)
|
||||
}, paneKey)
|
||||
}
|
||||
|
||||
async function startStreamingFixturePhase1(page: Page): Promise<string> {
|
||||
await waitForSessionReady(page)
|
||||
await waitForActiveWorktree(page)
|
||||
|
|
@ -159,6 +235,8 @@ test.describe('terminal scroll intent keeps following output', () => {
|
|||
}) => {
|
||||
const ptyId = await startStreamingFixturePhase1(orcaPage)
|
||||
|
||||
// A -2px delta is far below one cell height: xterm scrolls zero rows, but
|
||||
// the intent listener still observes the trackpad-jitter-shaped wheel.
|
||||
await dispatchSubRowWheelUp(orcaPage)
|
||||
await orcaPage.waitForTimeout(INTENT_SETTLE_WAIT_MS)
|
||||
|
||||
|
|
@ -177,4 +255,58 @@ test.describe('terminal scroll intent keeps following output', () => {
|
|||
await dispatchPlainHomeKeydown(orcaPage)
|
||||
await waitForMarkerAtBottom(orcaPage, 'STREAM_PHASE2_DONE')
|
||||
})
|
||||
|
||||
test('a real wheel pin stays fixed while visible output streams', async ({ orcaPage }) => {
|
||||
const ptyId = await startStreamingFixturePhase1(orcaPage)
|
||||
|
||||
await dispatchRealWheel(orcaPage, -240)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const probe = await probeActiveViewport(orcaPage, 'STREAM_PHASE1_DONE')
|
||||
return probe ? probe.baseY - probe.viewportY : 0
|
||||
})
|
||||
.toBeGreaterThan(1)
|
||||
const pinned = await probeActiveViewport(orcaPage, 'STREAM_PHASE1_DONE')
|
||||
if (!pinned) {
|
||||
throw new Error('terminal viewport unavailable after wheel pin')
|
||||
}
|
||||
|
||||
await sendToTerminal(orcaPage, ptyId, 'g')
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const probe = await probeActiveViewport(orcaPage, 'STREAM_PHASE2_DONE')
|
||||
return Boolean(probe && probe.containsMarker && probe.viewportY === pinned.viewportY)
|
||||
},
|
||||
{ timeout: 30_000, message: 'visible streaming output moved the wheel-pinned viewport' }
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('typing after a pinned write is queued resumes follow-output', async ({ orcaPage }) => {
|
||||
await startStreamingFixturePhase1(orcaPage)
|
||||
const { paneKey } = await waitForActivePaneHookDescriptor(orcaPage)
|
||||
await waitForTerminalPtyDataInjector(orcaPage, paneKey)
|
||||
|
||||
await dispatchRealWheel(orcaPage, -320)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const probe = await probeActiveViewport(orcaPage, 'STREAM_PHASE1_DONE')
|
||||
return probe ? probe.baseY - probe.viewportY : 0
|
||||
})
|
||||
.toBeGreaterThan(2)
|
||||
|
||||
// Hold the xterm write call so typing deterministically lands between the
|
||||
// old per-write intent capture and its completion-time enforcement from #8625.
|
||||
await injectQueuedWriteThenType(orcaPage, paneKey)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const probe = await probeActiveViewport(orcaPage, 'STREAM_PHASE1_DONE')
|
||||
return probe ? probe.baseY - probe.viewportY : Number.NaN
|
||||
},
|
||||
{ timeout: 5_000, intervals: [25] }
|
||||
)
|
||||
.toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue