diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 1c57032d6bf..61592d47da2 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -36,7 +36,7 @@ import { } from '@/lib/pane-manager/mobile-fit-overrides' import { getDriverForPty, onDriverChange } from '@/lib/pane-manager/mobile-driver-state' import { safeFit } from '@/lib/pane-manager/pane-tree-ops' -import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' +import { captureTerminalShutdownLayout } from './terminal-shutdown-layout-capture' // Why: registry lives in a leaf module so the store slice can import it // without re-entering the `slice → TerminalPane → store → slice` cycle @@ -44,8 +44,6 @@ import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-sch import { shutdownBufferCaptures } from './shutdown-buffer-captures' import { mergeCapturedLeafState } from './merge-captured-leaf-state' -const MAX_BUFFER_BYTES = 512 * 1024 - type TerminalPaneProps = { tabId: string worktreeId: string @@ -835,41 +833,6 @@ export default function TerminalPane({ if (panes.length === 0) { return } - const buffers: Record = {} - for (const pane of panes) { - try { - // Why: non-focused panes may have renderer-throttled PTY bytes queued; - // push them into xterm before taking the shutdown scrollback snapshot. - flushTerminalOutput(pane.terminal) - const leafId = paneLeafId(pane.id) - let scrollback = pane.terminal.options.scrollback ?? 10_000 - let serialized = pane.serializeAddon.serialize({ scrollback }) - // Cap at 512KB — binary search for largest scrollback that fits. - if (serialized.length > MAX_BUFFER_BYTES && scrollback > 1) { - let lo = 1 - let hi = scrollback - let best = '' - while (lo <= hi) { - const mid = Math.floor((lo + hi) / 2) - const attempt = pane.serializeAddon.serialize({ scrollback: mid }) - if (attempt.length <= MAX_BUFFER_BYTES) { - best = attempt - lo = mid + 1 - } else { - hi = mid - 1 - } - } - serialized = best - } - if (serialized.length > 0) { - buffers[leafId] = serialized - } - } catch { - // Serialization failure for one pane should not block others. - } - } - const activePaneId = manager.getActivePane()?.id ?? panes[0]?.id ?? null - const layout = serializeTerminalLayout(container, activePaneId, expandedPaneIdRef.current) // Why: setTabLayout REPLACES — it doesn't merge. captureBuffers can // run during a transient window (post-remount, just-attached, // mid-replay) where xterm hasn't rendered yet so serialize returns 0 @@ -877,42 +840,14 @@ export default function TerminalPane({ // buffer. Merge prior state in for leaves whose live capture came back // empty. Same shape as persistLayoutSnapshot. const existing = useAppStore.getState().terminalLayoutsByTabId[tabId] - const currentLeafIds = new Set(panes.map((p) => paneLeafId(p.id))) - const ptyEntries = panes - .map( - (pane) => - [ - paneLeafId(pane.id), - paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null - ] as const - ) - .filter((entry): entry is readonly [string, string] => entry[1] !== null) - const mergedBuffers = mergeCapturedLeafState({ - prior: existing?.buffersByLeafId, - fresh: buffers, - currentLeafIds + const layout = captureTerminalShutdownLayout({ + manager, + container, + expandedPaneId: expandedPaneIdRef.current, + paneTransports: paneTransportsRef.current, + paneTitlesByPaneId: paneTitlesRef.current, + existingLayout: existing }) - const mergedPtyIds = mergeCapturedLeafState({ - prior: existing?.ptyIdsByLeafId, - fresh: Object.fromEntries(ptyEntries), - currentLeafIds - }) - if (Object.keys(mergedBuffers).length > 0) { - layout.buffersByLeafId = mergedBuffers - } - if (Object.keys(mergedPtyIds).length > 0) { - layout.ptyIdsByLeafId = mergedPtyIds - } - // Merge pane titles so the shutdown snapshot doesn't silently drop them. - // Why: the old early-return on empty buffers skipped this entirely, which - // meant titles were lost on restart when the terminal had no scrollback - // content (e.g. fresh pane, cleared screen). - const titleEntries = panes - .filter((p) => paneTitlesRef.current[p.id]) - .map((p) => [paneLeafId(p.id), paneTitlesRef.current[p.id]] as const) - if (titleEntries.length > 0) { - layout.titlesByLeafId = Object.fromEntries(titleEntries) - } setTabLayout(tabId, layout) } shutdownBufferCaptures.set(tabId, captureBuffers) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 107c02050df..bfb6d718076 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -785,6 +785,32 @@ describe('connectPanePty', () => { } }) + it('writes visible split-pane PTY bytes immediately even when the tab is not active', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isActiveRef: { current: false }, + isVisibleRef: { current: true } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + expect(capturedDataCallback.current).not.toBeNull() + capturedDataCallback.current?.('visible split output\r\n') + + expect(pane.terminal.write).toHaveBeenCalledWith('visible split output\r\n') + }) + it('marks panes that receive Arabic output for DOM rendering', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() diff --git a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts new file mode 100644 index 00000000000..0eb2861e97c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts @@ -0,0 +1,91 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest' +import type { TerminalLayoutSnapshot } from '../../../../shared/types' + +const mocks = vi.hoisted(() => ({ + flushTerminalOutput: vi.fn() +})) + +vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({ + flushTerminalOutput: mocks.flushTerminalOutput +})) + +class MockHTMLElement { + classList: { contains: (cls: string) => boolean } + dataset: Record + children: MockHTMLElement[] + style: Record + firstElementChild: MockHTMLElement | null + + constructor(opts: { + classList?: string[] + dataset?: Record + children?: MockHTMLElement[] + style?: Record + firstElementChild?: MockHTMLElement | null + }) { + const classes = opts.classList ?? [] + this.classList = { contains: (cls: string) => classes.includes(cls) } + this.dataset = opts.dataset ?? {} + this.children = opts.children ?? [] + this.style = opts.style ?? {} + this.firstElementChild = opts.firstElementChild ?? null + } +} + +beforeAll(() => { + ;(globalThis as unknown as Record).HTMLElement = MockHTMLElement +}) + +function mockRootForPane(paneId: number): HTMLDivElement { + const pane = new MockHTMLElement({ classList: ['pane'], dataset: { paneId: String(paneId) } }) + return new MockHTMLElement({ firstElementChild: pane }) as unknown as HTMLDivElement +} + +describe('captureTerminalShutdownLayout', () => { + it('flushes queued terminal output before serializing shutdown scrollback', async () => { + const { captureTerminalShutdownLayout } = await import('./terminal-shutdown-layout-capture') + const order: string[] = [] + const terminal = { + options: { scrollback: 1_000 }, + pendingOutput: '' + } + const pane = { + id: 1, + terminal, + serializeAddon: { + serialize: vi.fn(() => { + order.push('serialize') + return `snapshot:${terminal.pendingOutput}` + }) + } + } + const manager = { + getPanes: vi.fn(() => [pane]), + getActivePane: vi.fn(() => pane) + } + mocks.flushTerminalOutput.mockImplementation((target: typeof terminal) => { + expect(target).toBe(terminal) + order.push('flush') + terminal.pendingOutput = 'queued-before-quit' + }) + + const layout = captureTerminalShutdownLayout({ + manager: manager as never, + container: mockRootForPane(1), + expandedPaneId: null, + paneTransports: new Map([[1, { getPtyId: vi.fn(() => 'pty-1') }]]), + paneTitlesByPaneId: { 1: 'build logs' }, + existingLayout: undefined + }) + + expect(order).toEqual(['flush', 'serialize']) + expect(layout).toMatchObject({ + root: { type: 'leaf', leafId: 'pane:1' }, + activeLeafId: 'pane:1', + expandedLeafId: null, + buffersByLeafId: { 'pane:1': 'snapshot:queued-before-quit' }, + ptyIdsByLeafId: { 'pane:1': 'pty-1' }, + titlesByLeafId: { 'pane:1': 'build logs' } + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts new file mode 100644 index 00000000000..00f6673d2e0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts @@ -0,0 +1,102 @@ +import type { TerminalLayoutSnapshot } from '../../../../shared/types' +import type { ManagedPane } from '@/lib/pane-manager/pane-manager' +import type { PtyTransport } from './pty-transport' +import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' +import { paneLeafId, serializeTerminalLayout } from './layout-serialization' +import { mergeCapturedLeafState } from './merge-captured-leaf-state' + +const MAX_BUFFER_BYTES = 512 * 1024 + +type ShutdownPane = Pick + +type ShutdownPaneManager = { + getPanes(): ShutdownPane[] + getActivePane(): ShutdownPane | null +} + +type CaptureTerminalShutdownLayoutArgs = { + manager: ShutdownPaneManager + container: HTMLDivElement + expandedPaneId: number | null + paneTransports: ReadonlyMap> + paneTitlesByPaneId: Record + existingLayout: TerminalLayoutSnapshot | undefined +} + +export function captureTerminalShutdownLayout({ + manager, + container, + expandedPaneId, + paneTransports, + paneTitlesByPaneId, + existingLayout +}: CaptureTerminalShutdownLayoutArgs): TerminalLayoutSnapshot { + const panes = manager.getPanes() + const buffers: Record = {} + + for (const pane of panes) { + try { + // Why: non-focused panes may have renderer-throttled PTY bytes queued; + // push them into xterm before taking the shutdown scrollback snapshot. + flushTerminalOutput(pane.terminal) + const leafId = paneLeafId(pane.id) + let scrollback = pane.terminal.options.scrollback ?? 10_000 + let serialized = pane.serializeAddon.serialize({ scrollback }) + // Cap at 512KB — binary search for largest scrollback that fits. + if (serialized.length > MAX_BUFFER_BYTES && scrollback > 1) { + let lo = 1 + let hi = scrollback + let best = '' + while (lo <= hi) { + const mid = Math.floor((lo + hi) / 2) + const attempt = pane.serializeAddon.serialize({ scrollback: mid }) + if (attempt.length <= MAX_BUFFER_BYTES) { + best = attempt + lo = mid + 1 + } else { + hi = mid - 1 + } + } + serialized = best + } + if (serialized.length > 0) { + buffers[leafId] = serialized + } + } catch { + // Serialization failure for one pane should not block others. + } + } + + const activePaneId = manager.getActivePane()?.id ?? panes[0]?.id ?? null + const layout = serializeTerminalLayout(container, activePaneId, expandedPaneId) + const currentLeafIds = new Set(panes.map((p) => paneLeafId(p.id))) + const ptyEntries = panes + .map((pane) => [paneLeafId(pane.id), paneTransports.get(pane.id)?.getPtyId() ?? null] as const) + .filter((entry): entry is readonly [string, string] => entry[1] !== null) + + const mergedBuffers = mergeCapturedLeafState({ + prior: existingLayout?.buffersByLeafId, + fresh: buffers, + currentLeafIds + }) + const mergedPtyIds = mergeCapturedLeafState({ + prior: existingLayout?.ptyIdsByLeafId, + fresh: Object.fromEntries(ptyEntries), + currentLeafIds + }) + if (Object.keys(mergedBuffers).length > 0) { + layout.buffersByLeafId = mergedBuffers + } + if (Object.keys(mergedPtyIds).length > 0) { + layout.ptyIdsByLeafId = mergedPtyIds + } + + const titleEntries = panes + .filter((p) => paneTitlesByPaneId[p.id]) + .map((p) => [paneLeafId(p.id), paneTitlesByPaneId[p.id]] as const) + if (titleEntries.length > 0) { + layout.titlesByLeafId = Object.fromEntries(titleEntries) + } + + return layout +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts new file mode 100644 index 00000000000..b28b0d56842 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts @@ -0,0 +1,96 @@ +import type * as ReactModule from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + fitAndFocusPanes: vi.fn(), + fitPanes: vi.fn(), + flushTerminalOutput: vi.fn() +})) + +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useEffect: (effect: () => void | (() => void)) => { + effect() + }, + useRef: (value: T) => ({ current: value }) + } +}) + +vi.mock('./pane-helpers', () => ({ + fitAndFocusPanes: mocks.fitAndFocusPanes, + fitPanes: mocks.fitPanes +})) + +vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({ + flushTerminalOutput: mocks.flushTerminalOutput +})) + +class MockResizeObserver { + observe = vi.fn() + disconnect = vi.fn() +} + +describe('useTerminalPaneGlobalEffects', () => { + beforeEach(() => { + vi.clearAllMocks() + ;(globalThis as unknown as { window: unknown }).window = { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + api: { + ui: { + onFileDrop: vi.fn(() => vi.fn()) + } + } + } + ;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = MockResizeObserver + }) + + afterEach(() => { + delete (globalThis as unknown as { window?: unknown }).window + delete (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver + }) + + it('flushes visible terminal panes before resuming rendering and fitting', async () => { + const { useTerminalPaneGlobalEffects } = await import('./use-terminal-pane-global-effects') + const order: string[] = [] + const terminalA = { name: 'terminal-a' } + const terminalB = { name: 'terminal-b' } + const manager = { + getPanes: vi.fn(() => [ + { id: 1, terminal: terminalA }, + { id: 2, terminal: terminalB } + ]), + resumeRendering: vi.fn(() => order.push('resume')), + suspendRendering: vi.fn(), + fitAllPanes: vi.fn(), + getActivePane: vi.fn(() => null), + setActivePane: vi.fn() + } + mocks.flushTerminalOutput.mockImplementation((terminal: { name: string }) => { + order.push(`flush:${terminal.name}`) + }) + mocks.fitAndFocusPanes.mockImplementation(() => order.push('fit-focus')) + + const isActiveRef = { current: false } + const isVisibleRef = { current: false } + useTerminalPaneGlobalEffects({ + tabId: 'tab-1', + worktreeId: 'wt-1', + isActive: true, + isVisible: true, + managerRef: { current: manager as never }, + containerRef: { current: null }, + paneTransportsRef: { current: new Map() }, + isActiveRef, + isVisibleRef, + toggleExpandPane: vi.fn() + }) + + expect(order).toEqual(['flush:terminal-a', 'flush:terminal-b', 'resume', 'fit-focus']) + expect(mocks.fitPanes).not.toHaveBeenCalled() + expect(isActiveRef.current).toBe(true) + expect(isVisibleRef.current).toBe(true) + }) +})