diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts index 5a3a4c3694b..6e70e95b98d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -1,5 +1,5 @@ import type { PaneManager } from '@/lib/pane-manager/pane-manager' -import type { ScrollState } from '@/lib/pane-manager/pane-manager-types' +import type { ManagedPane, ScrollState } from '@/lib/pane-manager/pane-manager-types' import { resetAllTerminalWebglAtlases } from '@/lib/pane-manager/pane-manager-registry' import { flushTerminalOutput, @@ -22,7 +22,9 @@ type ResumeTerminalVisibilityArgs = { isActive: boolean wasVisible: boolean shouldUseLightTabResume: boolean - captureViewportPositions: (useRememberedSnapshots: boolean) => Map + captureViewportPositions: ( + useRememberedSnapshots: boolean + ) => Map withSuppressedScrollTracking: (callback: () => void) => void } @@ -32,7 +34,9 @@ type HideTerminalVisibilityArgs = { wasWorktreeActive: boolean isWorktreeActive: boolean hasCompletedVisibleResume: boolean - captureViewportPositions: (useRememberedSnapshots: boolean) => Map + captureViewportPositions: ( + useRememberedSnapshots: boolean + ) => Map } type HideTerminalVisibilityResult = { @@ -163,13 +167,14 @@ function resumeTerminalVisibilityHeavy(manager: PaneManager, isActive: boolean): function restoreTerminalViewportPositions( manager: PaneManager, - viewportPositions: Map + viewportPositions: Map ): void { for (const pane of manager.getPanes()) { - const position = viewportPositions.get(pane.id) + const position = viewportPositions.get(pane.leafId) if (position) { logTerminalScrollRestore('visibility-restore', { before: terminalViewportForDebug(pane.terminal), + leafId: pane.leafId, paneId: pane.id, saved: terminalScrollStateForDebug(position), source: 'resumeTerminalVisibility' diff --git a/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts index 00d1df67849..423613471e0 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts @@ -99,7 +99,7 @@ describe('useTerminalScrollVisibilityMemory', () => { scrollToBottom: vi.fn() } const manager = { - getPanes: vi.fn(() => [{ id: 1, terminal }]) + getPanes: vi.fn(() => [{ id: 1, leafId: 'leaf-1', terminal }]) } const animationFrames: FrameRequestCallback[] = [] globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { @@ -131,7 +131,7 @@ describe('useTerminalScrollVisibilityMemory', () => { scrollToBottom: vi.fn() } const manager = { - getPanes: vi.fn(() => [{ id: 1, terminal }]) + getPanes: vi.fn(() => [{ id: 1, leafId: 'leaf-1', terminal }]) } const cancelAnimationFrame = vi.fn() globalThis.requestAnimationFrame = vi.fn(() => 7) @@ -161,7 +161,7 @@ describe('useTerminalScrollVisibilityMemory', () => { }) } const manager = { - getPanes: vi.fn(() => [{ id: 1, terminal }]) + getPanes: vi.fn(() => [{ id: 1, leafId: 'leaf-1', terminal }]) } const userScrolledState = { bufferType: 'normal', @@ -195,7 +195,52 @@ describe('useTerminalScrollVisibilityMemory', () => { mocks.isTerminalScrollRestoreInProgress.mockReturnValueOnce(true) listener() - expect(visibilityMemory.captureViewportPositions(true).get(1)).toBe(userScrolledState) + expect(visibilityMemory.captureViewportPositions(true).get('leaf-1' as never)).toBe( + userScrolledState + ) expect(mocks.captureScrollState).toHaveBeenCalledTimes(1) }) + + it('does not reuse a remembered snapshot when a pane id gets a new leaf', () => { + const terminal = { + onScroll: vi.fn(() => ({ dispose: vi.fn() })) + } + let panes = [{ id: 1, leafId: 'old-leaf', terminal }] + const manager = { + getPanes: vi.fn(() => panes) + } + const oldBottomState = { + bufferType: 'normal', + wasAtBottom: true, + viewportY: 100, + baseY: 100 + } + const newLeafState = { + bufferType: 'normal', + wasAtBottom: false, + viewportY: 42, + baseY: 100 + } + let currentState = oldBottomState + mocks.captureScrollState.mockImplementation(() => currentState) + + beginHookRender() + const visibilityMemory = useTerminalScrollVisibilityMemory({ + managerRef: { current: manager as never }, + isVisibleRef: { current: true }, + visibleResumeCompleteRef: { current: true }, + paneCount: 1 + }) + + expect(visibilityMemory.captureViewportPositions(false).get('old-leaf' as never)).toStrictEqual( + oldBottomState + ) + panes = [{ id: 1, leafId: 'new-leaf', terminal }] + currentState = newLeafState + + expect(visibilityMemory.captureViewportPositions(true).get('new-leaf' as never)).toStrictEqual( + newLeafState + ) + expect(mocks.captureScrollState).toHaveBeenCalledTimes(2) + }) }) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts b/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts index c93d04623e9..5e37ee4eb52 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts @@ -8,7 +8,7 @@ import { isTerminalScrollRestoreInProgress } from '@/lib/pane-manager/pane-scroll' import type { PaneManager } from '@/lib/pane-manager/pane-manager' -import type { ScrollState } from '@/lib/pane-manager/pane-manager-types' +import type { ManagedPane, ScrollState } from '@/lib/pane-manager/pane-manager-types' type VisibleScrollSnapshot = { scrollState: ScrollState @@ -23,7 +23,9 @@ type UseTerminalScrollVisibilityMemoryArgs = { } type TerminalScrollVisibilityMemory = { - captureViewportPositions: (useRememberedSnapshots: boolean) => Map + captureViewportPositions: ( + useRememberedSnapshots: boolean + ) => Map withSuppressedScrollTracking: (callback: () => void) => void applyPendingFollowOutputRequests: () => boolean scheduleFollowOutputIfNeeded: (paneId: number) => void @@ -37,8 +39,10 @@ export function useTerminalScrollVisibilityMemory({ visibleResumeCompleteRef, paneCount }: UseTerminalScrollVisibilityMemoryArgs): TerminalScrollVisibilityMemory { - const visibleScrollSnapshotsRef = useRef>(new Map()) - const scrollDisposablesRef = useRef>(new Map()) + const visibleScrollSnapshotsRef = useRef>( + new Map() + ) + const scrollDisposablesRef = useRef>(new Map()) const suppressScrollTrackingRef = useRef(false) const pendingFollowOutputPaneIdsRef = useRef>(new Set()) const followOutputFrameIdsRef = useRef([]) @@ -52,32 +56,32 @@ export function useTerminalScrollVisibilityMemory({ ) const rememberVisibleScrollSnapshot = useCallback( - (paneId: number, terminal: Terminal): void => { - visibleScrollSnapshotsRef.current.set(paneId, captureVisibleScrollSnapshot(terminal)) + (leafId: ManagedPane['leafId'], terminal: Terminal): void => { + visibleScrollSnapshotsRef.current.set(leafId, captureVisibleScrollSnapshot(terminal)) }, [captureVisibleScrollSnapshot] ) const captureViewportPositions = useCallback( - (useRememberedSnapshots: boolean): Map => { + (useRememberedSnapshots: boolean): Map => { const manager = managerRef.current if (!manager) { return new Map() } return new Map( manager.getPanes().map((pane) => { - const remembered = visibleScrollSnapshotsRef.current.get(pane.id) + const remembered = visibleScrollSnapshotsRef.current.get(pane.leafId) if (useRememberedSnapshots && remembered) { - return [pane.id, remembered.scrollState] as const + return [pane.leafId, remembered.scrollState] as const } const state = captureScrollState(pane.terminal) if (!useRememberedSnapshots || !remembered) { - visibleScrollSnapshotsRef.current.set(pane.id, { + visibleScrollSnapshotsRef.current.set(pane.leafId, { scrollState: state, outputEpoch: getTerminalOutputEpoch(pane.terminal) }) } - return [pane.id, state] as const + return [pane.leafId, state] as const }) ) }, @@ -110,7 +114,7 @@ export function useTerminalScrollVisibilityMemory({ if (!pending.has(pane.id)) { continue } - const previous = visibleScrollSnapshotsRef.current.get(pane.id) + const previous = visibleScrollSnapshotsRef.current.get(pane.leafId) // Why: focus/follow can run immediately after a hidden pane becomes // visible. A bounded flush is enough to observe new output without // putting the whole hidden PTY backlog back on the interaction path. @@ -120,7 +124,7 @@ export function useTerminalScrollVisibilityMemory({ if (hasNewOutput) { cancelDeferredScrollRestore(pane.terminal) pane.terminal.scrollToBottom() - rememberVisibleScrollSnapshot(pane.id, pane.terminal) + rememberVisibleScrollSnapshot(pane.leafId, pane.terminal) didScroll = true } pending.delete(pane.id) @@ -167,17 +171,16 @@ export function useTerminalScrollVisibilityMemory({ } const disposables = scrollDisposablesRef.current const panes = manager.getPanes() - const livePaneIds = new Set(panes.map((pane) => pane.id)) - for (const [paneId, disposable] of disposables) { - if (!livePaneIds.has(paneId)) { + const liveLeafIds = new Set(panes.map((pane) => pane.leafId)) + for (const [leafId, disposable] of disposables) { + if (!liveLeafIds.has(leafId)) { disposable.dispose() - disposables.delete(paneId) - visibleScrollSnapshotsRef.current.delete(paneId) - pendingFollowOutputPaneIdsRef.current.delete(paneId) + disposables.delete(leafId) + visibleScrollSnapshotsRef.current.delete(leafId) } } for (const pane of panes) { - if (disposables.has(pane.id)) { + if (disposables.has(pane.leafId)) { continue } const onScroll = ( @@ -189,7 +192,7 @@ export function useTerminalScrollVisibilityMemory({ continue } disposables.set( - pane.id, + pane.leafId, onScroll.call(pane.terminal, () => { if ( !isVisibleRef.current || @@ -198,7 +201,7 @@ export function useTerminalScrollVisibilityMemory({ ) { return } - rememberVisibleScrollSnapshot(pane.id, pane.terminal) + rememberVisibleScrollSnapshot(pane.leafId, pane.terminal) }) ) }