diff --git a/mobile/src/session/TerminalPaneView.tsx b/mobile/src/session/TerminalPaneView.tsx index c6d718e8294..761013a95ef 100644 --- a/mobile/src/session/TerminalPaneView.tsx +++ b/mobile/src/session/TerminalPaneView.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react' +import { useCallback, useState } from 'react' import { StyleSheet, View } from 'react-native' import type { ParsedFileLinkLocation } from '../../../src/shared/file-link-location' import { TerminalWebView } from '../terminal/TerminalWebView' @@ -57,6 +57,10 @@ export function TerminalPaneView({ onTextScaleChange, onCellBoxChange }: TerminalPaneViewProps) { + const [visited, setVisited] = useState(active) + if (active && !visited) { + setVisited(true) + } const setRef = useCallback( (ref: TerminalWebViewHandle | null) => { onRef(handle, ref) @@ -64,6 +68,10 @@ export function TerminalPaneView({ [handle, onRef] ) + if (!active && !visited) { + return null + } + return ( ({ AppState: { addEventListener: () => ({ remove() {} }) } })) vi.mock('lucide-react-native', () => ({ RefreshCw: 'Icon' })) +vi.mock('./TerminalWebView', () => import('./TerminalWebView.web')) const { createTerminalDocument } = await import('./document/create-terminal-document') const { mountTerminalWebDocument } = await import('./terminal-web-document-mount') @@ -146,6 +149,114 @@ describe('a stopped document takes its engines with it', () => { }) }) +describe('terminal panes start when first selected', () => { + it('starts only the selected document among several terminal tabs', () => { + vi.useFakeTimers() + const count = 3 + const refs = new Map() + const ready: string[] = [] + const engines: ReturnType[] = [] + const replayed: string[] = [] + const addListener = vi.spyOn(document, 'addEventListener') + const removeListener = vi.spyOn(document, 'removeEventListener') + gridTerminal = () => { + const engine = terminalDocumentDouble() + engine.terminal.write = (data, callback) => { + replayed.push(data) + callback?.() + } + engines.push(engine) + return engine.terminal + } + const noop = () => {} + const onRef = (handle: string, ref: TerminalWebViewHandle | null) => { + if (ref) { + refs.set(handle, ref) + } else { + refs.delete(handle) + } + } + const onWebReady = (handle: string) => { + ready.push(handle) + const ref = refs.get(handle) + expect(ref).toBeDefined() + void ref?.init({ ...INIT, initialData: `snapshot:${handle}`, frame: null }) + } + const panes = (selected: number) => + Array.from({ length: count }, (_, index) => + createElement(TerminalPaneView, { + key: index, + handle: `terminal-${index}`, + active: index === selected, + keyboardLift: 0, + textScale: 1, + onRef, + onWebReady, + onTextScaleChange: noop, + onCellBoxChange: noop, + onSelectionMode: noop, + onSelectionCopy: noop, + onSelectionEvicted: noop, + onClipboardWrite: noop, + onModesChanged: noop, + onKeyboardAvoidanceMetrics: noop, + onHaptic: noop, + onTerminalInput: noop, + onTerminalQueryReply: noop, + onTerminalTap: noop, + onFileTap: noop, + onOpenUrl: noop + }) + ) + const documentListeners = () => addListener.mock.calls.length - removeListener.mock.calls.length + try { + act(() => { + renderer = create(createElement('div', null, panes(0)), { + createNodeMock: () => plantHost() + }) + }) + expect(startedBuilds).toEqual([true]) + expect(ready).toEqual(['terminal-0']) + expect(refs.size).toBe(1) + expect(document.querySelectorAll(`.${HOST_CLASS}`)).toHaveLength(1) + const perDocument = documentListeners() + expect(perDocument).toBeGreaterThan(0) + const firstHost = document.querySelector(`.${HOST_CLASS}`) + act(() => { + vi.advanceTimersByTime(32) + }) + expect(replayed).toEqual(['\x1b[0msnapshot:terminal-0']) + + act(() => renderer?.update(createElement('div', null, panes(1)))) + expect(startedBuilds).toEqual([true, true]) + expect(ready).toEqual(['terminal-0', 'terminal-1']) + expect(refs.size).toBe(2) + expect(documentListeners()).toBe(2 * perDocument) + act(() => { + vi.advanceTimersByTime(32) + }) + expect(replayed).toEqual(['\x1b[0msnapshot:terminal-0', '\x1b[0msnapshot:terminal-1']) + + act(() => renderer?.update(createElement('div', null, panes(0)))) + expect(document.querySelector(`.${HOST_CLASS}`)).toBe(firstHost) + expect(startedBuilds).toEqual([true, true]) + expect(ready).toEqual(['terminal-0', 'terminal-1']) + expect(engines.map((engine) => engine.disposals())).toEqual([0, 0]) + + act(() => renderer?.unmount()) + renderer = null + expect(refs.size).toBe(0) + expect(document.querySelectorAll(`.${HOST_CLASS}`)).toHaveLength(0) + expect(documentListeners()).toBe(0) + expect(engines.map((engine) => engine.disposals())).toEqual([1, 1]) + } finally { + vi.useRealTimers() + addListener.mockRestore() + removeListener.mockRestore() + } + }) +}) + describe('the page keeps its own capture buffer', () => { it('quotes the lines this document captured, and a second mount has none of them', () => { // Per-mount reporters append before quoting the buffer; embedded modules have no pre-engine head script.