diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index 1a5fd170b..5f9518f17 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -24,4 +24,12 @@ export function registerSessionHandlers(store: Store): void { store.flush() event.returnValue = true }) + + ipcMain.on( + 'session:read-terminal-scrollback-sync', + (event, args: { ref?: unknown } | undefined) => { + event.returnValue = + typeof args?.ref === 'string' ? store.readTerminalScrollbackSnapshot(args.ref) : null + } + ) } diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 08d77a5c5..a7830911b 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -24,6 +24,7 @@ import type { WorkspaceSessionState } from '../shared/types' import { isTerminalLeafId, makePaneKey } from '../shared/stable-pane-id' +import { TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT } from '../shared/terminal-scrollback-limits' import { MAX_BROWSER_HISTORY_ENTRIES } from '../shared/workspace-session-browser-history' import { ONBOARDING_FINAL_STEP, ONBOARDING_FLOW_VERSION } from '../shared/constants' @@ -3504,7 +3505,7 @@ describe('Store', () => { ).toBeUndefined() }) - it('strips local terminal scrollback buffers when setting workspace session', async () => { + it('stores remote terminal scrollback out of workspace session JSON', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'local-repo', connectionId: null })) store.addRepo(makeRepo({ id: 'remote-repo', connectionId: 'ssh-target-1' })) @@ -3516,9 +3517,12 @@ describe('Store', () => { expect(session.terminalLayoutsByTabId['local-tab'].ptyIdsByLeafId).toEqual({ [TEST_LEAF_1]: 'local-pty' }) - expect(session.terminalLayoutsByTabId['remote-tab'].buffersByLeafId).toEqual({ - [TEST_LEAF_2]: 'remote-scrollback' + expect(session.terminalLayoutsByTabId['remote-tab'].buffersByLeafId).toBeUndefined() + expect(session.terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId).toEqual({ + [TEST_LEAF_2]: expect.stringMatching(/^v1-[0-9a-f]{32}$/) }) + const ref = session.terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId?.[TEST_LEAF_2] + expect(ref ? store.readTerminalScrollbackSnapshot(ref) : null).toBe('remote-scrollback') }) it('caps oversized browser history when setting workspace session', async () => { @@ -3535,7 +3539,7 @@ describe('Store', () => { expect(prunedBytes).toBeLessThan(oversizedBytes / 2) }) - it('keeps terminal scrollback buffers when the repo catalog is not hydrated yet', async () => { + it('stores maybe-remote terminal scrollback out of workspace session JSON', async () => { const store = await createStore() store.setWorkspaceSession({ @@ -3563,9 +3567,65 @@ describe('Store', () => { expect( store.getWorkspaceSession().terminalLayoutsByTabId['remote-tab'].buffersByLeafId + ).toBeUndefined() + expect( + store.getWorkspaceSession().terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId ).toEqual({ - [TEST_LEAF_2]: 'maybe-remote-scrollback' + [TEST_LEAF_2]: expect.stringMatching(/^v1-[0-9a-f]{32}$/) }) + const ref = + store.getWorkspaceSession().terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId?.[ + TEST_LEAF_2 + ] + expect(ref ? store.readTerminalScrollbackSnapshot(ref) : null).toBe('maybe-remote-scrollback') + }) + + it('deletes terminal scrollback snapshot files when refs leave the workspace session', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'remote-repo', connectionId: 'ssh-target-1' })) + const session = makeSessionWithTerminalBuffers() + store.setWorkspaceSession({ + ...session, + tabsByWorktree: { 'remote-repo::/remote': session.tabsByWorktree['remote-repo::/remote'] }, + terminalLayoutsByTabId: { 'remote-tab': session.terminalLayoutsByTabId['remote-tab'] } + }) + const ref = + store.getWorkspaceSession().terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId?.[ + TEST_LEAF_2 + ] + expect(ref).toEqual(expect.stringMatching(/^v1-[0-9a-f]{32}$/)) + if (!ref) { + throw new Error('expected scrollback snapshot ref') + } + expect(existsSync(join(testState.dir, 'terminal-scrollback', `${ref}.bin`))).toBe(true) + + store.setWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {} + }) + + expect(existsSync(join(testState.dir, 'terminal-scrollback', `${ref}.bin`))).toBe(false) + }) + + it('reads only the replay tail from oversized terminal scrollback snapshots', async () => { + const store = await createStore() + const ref = 'v1-00000000000000000000000000000000' + const snapshotDir = join(testState.dir, 'terminal-scrollback') + mkdirSync(snapshotDir, { recursive: true }) + writeFileSync( + join(snapshotDir, `${ref}.bin`), + `stale-prefix-${'x'.repeat(TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT)}tail`, + 'utf-8' + ) + + const buffer = store.readTerminalScrollbackSnapshot(ref) + + expect(buffer).toHaveLength(TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT) + expect(buffer?.startsWith('stale-prefix')).toBe(false) + expect(buffer?.endsWith('tail')).toBe(true) }) it('strips legacy local terminal scrollback buffers when loading workspace session', async () => { @@ -3585,9 +3645,12 @@ describe('Store', () => { const store = await createStore() const session = store.getWorkspaceSession() expect(session.terminalLayoutsByTabId['local-tab'].buffersByLeafId).toBeUndefined() - expect(session.terminalLayoutsByTabId['remote-tab'].buffersByLeafId).toEqual({ - [TEST_LEAF_2]: 'remote-scrollback' + expect(session.terminalLayoutsByTabId['remote-tab'].buffersByLeafId).toBeUndefined() + expect(session.terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId).toEqual({ + [TEST_LEAF_2]: expect.stringMatching(/^v1-[0-9a-f]{32}$/) }) + const ref = session.terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId?.[TEST_LEAF_2] + expect(ref ? store.readTerminalScrollbackSnapshot(ref) : null).toBe('remote-scrollback') }) it('caps oversized legacy browser history when loading workspace session', async () => { @@ -4646,7 +4709,12 @@ describe('Store', () => { expect(layout.activeLeafId).toBe(TEST_LEAF_1) expect(layout.expandedLeafId).toBeNull() expect(layout.ptyIdsByLeafId).toEqual({ [TEST_LEAF_1]: 'daemon-pty' }) - expect(layout.buffersByLeafId).toEqual({ [TEST_LEAF_1]: 'Current buffer' }) + expect(layout.buffersByLeafId).toBeUndefined() + expect(layout.scrollbackRefsByLeafId).toEqual({ + [TEST_LEAF_1]: expect.stringMatching(/^v1-[0-9a-f]{32}$/) + }) + const ref = layout.scrollbackRefsByLeafId?.[TEST_LEAF_1] + expect(ref ? store.readTerminalScrollbackSnapshot(ref) : null).toBe('Current buffer') expect(layout.titlesByLeafId).toEqual({ [TEST_LEAF_1]: 'Current' }) expect(session.tabsByWorktree.wt1[0].ptyId).toBe('daemon-pty') }) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 3fca37c67..971946b83 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -123,6 +123,12 @@ import { sourceControlAiSettingsFromLegacy } from '../shared/source-control-ai' import { normalizeDisabledTuiAgents } from '../shared/tui-agent-selection' +import { + collectTerminalScrollbackSnapshotRefs, + deleteTerminalScrollbackSnapshotSync, + migrateWorkspaceSessionTerminalScrollbackSnapshots, + readTerminalScrollbackSnapshotSync +} from './terminal-scrollback-snapshots' function encrypt(plaintext: string): string { if (!plaintext || !safeStorage.isEncryptionAvailable()) { @@ -1167,6 +1173,11 @@ function normalizeTerminalLayoutSnapshotForPersistence( leafIdByInputLeafId, duplicatedInputLeafIds ) + const scrollbackRefsByLeafId = remapLeafRecordForPersistence( + inputSnapshot.scrollbackRefsByLeafId, + leafIdByInputLeafId, + duplicatedInputLeafIds + ) const titlesByLeafId = remapLeafRecordForPersistence( inputSnapshot.titlesByLeafId, leafIdByInputLeafId, @@ -1175,6 +1186,7 @@ function normalizeTerminalLayoutSnapshotForPersistence( const recordsChanged = !leafRecordEquivalent(inputSnapshot.ptyIdsByLeafId, ptyIdsByLeafId) || !leafRecordEquivalent(inputSnapshot.buffersByLeafId, buffersByLeafId) || + !leafRecordEquivalent(inputSnapshot.scrollbackRefsByLeafId, scrollbackRefsByLeafId) || !leafRecordEquivalent(inputSnapshot.titlesByLeafId, titlesByLeafId) const metadataChanged = activeLeafId !== inputSnapshot.activeLeafId || expandedLeafId !== inputSnapshot.expandedLeafId @@ -1184,6 +1196,7 @@ function normalizeTerminalLayoutSnapshotForPersistence( const { ptyIdsByLeafId: _oldPtyIdsByLeafId, buffersByLeafId: _oldBuffersByLeafId, + scrollbackRefsByLeafId: _oldScrollbackRefsByLeafId, titlesByLeafId: _oldTitlesByLeafId, ...snapshotWithoutLeafRecords } = inputSnapshot @@ -1195,6 +1208,7 @@ function normalizeTerminalLayoutSnapshotForPersistence( expandedLeafId, ...(ptyIdsByLeafId ? { ptyIdsByLeafId } : {}), ...(buffersByLeafId ? { buffersByLeafId } : {}), + ...(scrollbackRefsByLeafId ? { scrollbackRefsByLeafId } : {}), ...(titlesByLeafId ? { titlesByLeafId } : {}) }, changed: true, @@ -1526,6 +1540,21 @@ function cloneWorkspaceSessionState(session: WorkspaceSessionState): WorkspaceSe return structuredClone(session) } +function deleteRemovedTerminalScrollbackSnapshots( + prior: WorkspaceSessionState | undefined, + next: WorkspaceSessionState +): void { + if (!prior) { + return + } + const nextRefs = collectTerminalScrollbackSnapshotRefs(next) + for (const ref of collectTerminalScrollbackSnapshotRefs(prior)) { + if (!nextRefs.has(ref)) { + deleteTerminalScrollbackSnapshotSync(ref) + } + } +} + export class Store { private state: PersistedState private writeTimer: ReturnType | null = null @@ -2112,12 +2141,18 @@ export class Store { result = getDefaultPersistedState(homedir()) } + const workspaceSession = pruneWorkspaceSessionBrowserHistory( + pruneLocalTerminalScrollbackBuffers(result.workspaceSession, result.repos) + ) + const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots(workspaceSession) + if (migratedScrollback.changed) { + this.loadNeedsSave = true + } + result = { ...result, repos: clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []), - workspaceSession: pruneWorkspaceSessionBrowserHistory( - pruneLocalTerminalScrollbackBuffers(result.workspaceSession, result.repos) - ) + workspaceSession: migratedScrollback.session } return this.migrateTelemetry(result, fileExistedOnLoad) @@ -3211,6 +3246,10 @@ export class Store { return this.state.workspaceSession ?? getDefaultWorkspaceSession() } + readTerminalScrollbackSnapshot(ref: string): string | null { + return readTerminalScrollbackSnapshotSync(ref) + } + /** Resolve the worktree a terminal tab belongs to, from the session's * tab→worktree map. More reliable than agent-echoed hook fields. */ getWorktreeIdForTab(tabId: string): string | undefined { @@ -3336,6 +3375,11 @@ export class Store { layout.buffersByLeafId, liveLeafIds ) + const scrollbackRefsByLeafId = preserveMissingLeafRecordEntries( + priorLayout.scrollbackRefsByLeafId, + layout.scrollbackRefsByLeafId, + liveLeafIds + ) const titlesByLeafId = preserveMissingLeafRecordEntries( priorLayout.titlesByLeafId, layout.titlesByLeafId, @@ -3344,12 +3388,19 @@ export class Store { if (buffersByLeafId) { layout.buffersByLeafId = buffersByLeafId } + if (scrollbackRefsByLeafId) { + layout.scrollbackRefsByLeafId = scrollbackRefsByLeafId + } if (titlesByLeafId) { layout.titlesByLeafId = titlesByLeafId } } } } + session = pruneLocalTerminalScrollbackBuffers(session, this.state.repos) + const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots(session) + session = migratedScrollback.session + deleteRemovedTerminalScrollbackSnapshots(prior, session) this.state.workspaceSession = session this.scheduleSave() } diff --git a/src/main/terminal-scrollback-snapshots.ts b/src/main/terminal-scrollback-snapshots.ts new file mode 100644 index 000000000..d75aaf306 --- /dev/null +++ b/src/main/terminal-scrollback-snapshots.ts @@ -0,0 +1,187 @@ +import { createHash } from 'crypto' +import { + closeSync, + mkdirSync, + openSync, + readSync, + renameSync, + rmSync, + statSync, + writeFileSync +} from 'fs' +import { join } from 'path' +import { app } from 'electron' +import type { WorkspaceSessionState } from '../shared/types' +import { + TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT, + TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT +} from '../shared/terminal-scrollback-limits' + +const SNAPSHOT_DIR_NAME = 'terminal-scrollback' +const REF_PREFIX = 'v1' + +function getSnapshotRoot(): string { + return join(app.getPath('userData'), SNAPSHOT_DIR_NAME) +} + +export function makeTerminalScrollbackSnapshotRef(tabId: string, leafId: string): string { + const hash = createHash('sha256').update(`${tabId}\0${leafId}`).digest('hex').slice(0, 32) + return `${REF_PREFIX}-${hash}` +} + +function snapshotPath(ref: string): string | null { + if (!/^v1-[0-9a-f]{32}$/.test(ref)) { + return null + } + return join(getSnapshotRoot(), `${ref}.bin`) +} + +function trailingUtf8Bytes(value: string, maxBytes: number): Buffer { + const bytes = Buffer.from(value, 'utf-8') + if (bytes.length <= maxBytes) { + return bytes + } + let start = bytes.length - maxBytes + while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) { + start++ + } + return bytes.subarray(start) +} + +function readTrailingUtf8(path: string, maxBytes: number): string { + const size = statSync(path).size + const length = Math.min(size, maxBytes) + if (length <= 0) { + return '' + } + const bytes = Buffer.allocUnsafe(length) + const fd = openSync(path, 'r') + try { + readSync(fd, bytes, 0, length, size - length) + } finally { + closeSync(fd) + } + let start = 0 + while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) { + start++ + } + return bytes.subarray(start).toString('utf-8') +} + +export function writeTerminalScrollbackSnapshotSync(args: { + tabId: string + leafId: string + buffer: string +}): string | null { + if (!args.buffer) { + return null + } + const ref = makeTerminalScrollbackSnapshotRef(args.tabId, args.leafId) + const path = snapshotPath(ref) + if (!path) { + return null + } + try { + mkdirSync(getSnapshotRoot(), { recursive: true, mode: 0o700 }) + const tmpPath = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` + const bytes = trailingUtf8Bytes(args.buffer, TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT) + let renamed = false + try { + writeFileSync(tmpPath, bytes, { mode: 0o600 }) + renameSync(tmpPath, path) + renamed = true + } finally { + if (!renamed) { + rmSync(tmpPath, { force: true }) + } + } + return ref + } catch (err) { + console.warn( + `[terminal-scrollback] Failed to write snapshot: ${err instanceof Error ? err.message : String(err)}` + ) + return null + } +} + +export function readTerminalScrollbackSnapshotSync(ref: string): string | null { + const path = snapshotPath(ref) + if (!path) { + return null + } + try { + return readTrailingUtf8(path, TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT) + } catch { + return null + } +} + +export function deleteTerminalScrollbackSnapshotSync(ref: string): void { + const path = snapshotPath(ref) + if (!path) { + return + } + try { + rmSync(path, { force: true }) + } catch { + // Best-effort cleanup; stale refs are harmless and bounded by per-file caps. + } +} + +export function collectTerminalScrollbackSnapshotRefs(session: WorkspaceSessionState): Set { + const refs = new Set() + for (const layout of Object.values(session.terminalLayoutsByTabId ?? {})) { + for (const ref of Object.values(layout.scrollbackRefsByLeafId ?? {})) { + refs.add(ref) + } + } + return refs +} + +export function migrateWorkspaceSessionTerminalScrollbackSnapshots( + session: WorkspaceSessionState +): { session: WorkspaceSessionState; changed: boolean } { + let terminalLayoutsByTabId: WorkspaceSessionState['terminalLayoutsByTabId'] | null = null + for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId ?? {})) { + const buffers = layout.buffersByLeafId + if (!buffers || Object.keys(buffers).length === 0) { + continue + } + const refs = { ...layout.scrollbackRefsByLeafId } + const remainingBuffers: Record = {} + let layoutChanged = false + for (const [leafId, buffer] of Object.entries(buffers)) { + const ref = writeTerminalScrollbackSnapshotSync({ tabId, leafId, buffer }) + if (ref) { + refs[leafId] = ref + layoutChanged = true + } else { + remainingBuffers[leafId] = buffer + if (refs[leafId]) { + delete refs[leafId] + layoutChanged = true + } + } + } + if (!layoutChanged) { + continue + } + terminalLayoutsByTabId ??= { ...session.terminalLayoutsByTabId } + const nextLayout = { ...layout } + if (Object.keys(refs).length > 0) { + nextLayout.scrollbackRefsByLeafId = refs + } else { + delete nextLayout.scrollbackRefsByLeafId + } + if (Object.keys(remainingBuffers).length > 0) { + nextLayout.buffersByLeafId = remainingBuffers + } else { + delete nextLayout.buffersByLeafId + } + terminalLayoutsByTabId[tabId] = nextLayout + } + if (!terminalLayoutsByTabId) { + return { session, changed: false } + } + return { session: { ...session, terminalLayoutsByTabId }, changed: true } +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 7d2f827f1..3a444e391 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1710,6 +1710,7 @@ export type PreloadApi = { get: () => Promise set: (args: WorkspaceSessionState) => Promise patch: (args: WorkspaceSessionPatch) => Promise + readTerminalScrollback: (args: { ref: string }) => string | null setSync: (args: WorkspaceSessionState) => void } remoteWorkspace: { diff --git a/src/preload/index.ts b/src/preload/index.ts index 77d6cedc7..8107332e7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2075,6 +2075,8 @@ const api = { get: () => ipcRenderer.invoke('session:get'), set: (args) => ipcRenderer.invoke('session:set', args), patch: (args) => ipcRenderer.invoke('session:patch', args), + readTerminalScrollback: (args) => + ipcRenderer.sendSync('session:read-terminal-scrollback-sync', args), /** Synchronous session save for beforeunload — blocks until flushed to disk. */ setSync: (args) => { ipcRenderer.sendSync('session:set-sync', args) diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 501f25dac..e5821ec18 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -337,6 +337,7 @@ export default function TerminalPane({ const paneTitlesRef = useRef>({}) paneTitlesRef.current = paneTitles const removedTitleLeafIdsRef = useRef>(new Set()) + const clearedScrollbackLeafIdsRef = useRef>(new Set()) const [renamingPaneId, setRenamingPaneId] = useState(null) const [renameValue, setRenameValue] = useState('') const renameInputRef = useRef(null) @@ -513,17 +514,29 @@ export default function TerminalPane({ const existing = useAppStore.getState().terminalLayoutsByTabId[tabId] const currentPanes = manager.getPanes() const currentLeafIds = new Set(currentPanes.map((p) => p.leafId)) + const clearedScrollbackLeafIds = clearedScrollbackLeafIdsRef.current + const scrollbackPreserveLeafIds = new Set( + [...currentLeafIds].filter((leafId) => !clearedScrollbackLeafIds.has(leafId)) + ) // Preserve existing buffersByLeafId so layout-only persists (resize, split, // reorder) don't clobber previously captured scrollback. Drop entries for // leaves that no longer exist. const mergedBuffers = mergeCapturedLeafState({ prior: existing?.buffersByLeafId, fresh: {}, - currentLeafIds + currentLeafIds: scrollbackPreserveLeafIds }) if (Object.keys(mergedBuffers).length > 0) { layout.buffersByLeafId = mergedBuffers } + const mergedScrollbackRefs = mergeCapturedLeafState({ + prior: existing?.scrollbackRefsByLeafId, + fresh: {}, + currentLeafIds: scrollbackPreserveLeafIds + }) + if (Object.keys(mergedScrollbackRefs).length > 0) { + layout.scrollbackRefsByLeafId = mergedScrollbackRefs + } // Why: between pane creation and the deferred rAF where PTYs actually // attach, all transports have getPtyId() === null. The merge below // preserves the *prior* snapshot's leaf→PTY mappings while still letting @@ -570,8 +583,20 @@ export default function TerminalPane({ layout.titlesByLeafId = titlesByLeafId } setTabLayout(tabId, layout) + for (const leafId of currentLeafIds) { + clearedScrollbackLeafIds.delete(leafId) + } }, [tabId, setTabLayout]) + const clearPaneScrollback = useCallback( + (pane: ManagedPane): void => { + clearedScrollbackLeafIdsRef.current.add(pane.leafId) + pane.terminal.clear() + persistLayoutSnapshot() + }, + [persistLayoutSnapshot] + ) + useEffect(() => { if (!terminalTab) { return @@ -1058,6 +1083,7 @@ export default function TerminalPane({ setSearchOpen, onSearchSelectedText: handleSearchSelectedText, onRequestClosePane: handleRequestClosePane, + onClearPaneScrollback: clearPaneScrollback, searchOpenRef, searchStateRef, macOptionAsAltRef, @@ -1381,9 +1407,13 @@ export default function TerminalPane({ existingLayout: existing, // Why: beforeunload skips local/floating bytes because session payloads // immediately prune them; worktree sleep keeps them as defense-in-depth. - captureBuffers: shouldCaptureScrollbackBuffers + captureBuffers: shouldCaptureScrollbackBuffers, + clearedScrollbackLeafIds: clearedScrollbackLeafIdsRef.current }) setTabLayout(tabId, layout) + for (const pane of panes) { + clearedScrollbackLeafIdsRef.current.delete(pane.leafId) + } } shutdownBufferCaptures.set(tabId, captureBuffers) return () => { @@ -1582,6 +1612,7 @@ export default function TerminalPane({ fallbackCwd: cwd ?? '', toggleExpandPane, onRequestClosePane: handleRequestClosePane, + onClearPaneScrollback: clearPaneScrollback, onSetTitle: handleStartRename, onPasteError: setTerminalError, onAgentSessionForkReady: setAgentSessionFork, diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts index e1768b9fb..aaa56d372 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts @@ -2,7 +2,7 @@ * precedence in one ordered handler so shell input, pane commands, search, and * split actions do not race across separate window listeners. */ import { useEffect } from 'react' -import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' import type { PtyTransport } from './pty-transport' import { resolveTerminalShortcutAction } from './terminal-shortcut-policy' import type { MacOptionAsAlt } from './terminal-shortcut-policy' @@ -120,6 +120,7 @@ type KeyboardHandlersDeps = { setSearchOpen: React.Dispatch> onSearchSelectedText: (text: string) => void onRequestClosePane: (paneId: number) => void + onClearPaneScrollback: (pane: ManagedPane) => void searchOpenRef: React.RefObject searchStateRef: React.RefObject macOptionAsAltRef: React.RefObject @@ -143,6 +144,7 @@ export function useTerminalKeyboardShortcuts({ setSearchOpen, onSearchSelectedText, onRequestClosePane, + onClearPaneScrollback, searchOpenRef, searchStateRef, macOptionAsAltRef, @@ -289,7 +291,7 @@ export function useTerminalKeyboardShortcuts({ e.stopImmediatePropagation() const pane = manager.getActivePane() ?? manager.getPanes()[0] if (pane) { - pane.terminal.clear() + onClearPaneScrollback(pane) } return } @@ -446,6 +448,7 @@ export function useTerminalKeyboardShortcuts({ setSearchOpen, onSearchSelectedText, onRequestClosePane, + onClearPaneScrollback, searchOpenRef, searchStateRef, macOptionAsAltRef, diff --git a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts index b04b23db0..77f22bdbb 100644 --- a/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts +++ b/src/renderer/src/components/terminal-pane/terminal-layout-leaf-ids.ts @@ -107,10 +107,12 @@ export function normalizeTerminalLayoutSnapshot( : null const ptyIdsByLeafId = remapLeafRecord(snapshot.ptyIdsByLeafId, rewrite) const buffersByLeafId = remapLeafRecord(snapshot.buffersByLeafId, rewrite) + const scrollbackRefsByLeafId = remapLeafRecord(snapshot.scrollbackRefsByLeafId, rewrite) const titlesByLeafId = remapLeafRecord(snapshot.titlesByLeafId, rewrite) const { ptyIdsByLeafId: _oldPtyIdsByLeafId, buffersByLeafId: _oldBuffersByLeafId, + scrollbackRefsByLeafId: _oldScrollbackRefsByLeafId, titlesByLeafId: _oldTitlesByLeafId, ...snapshotWithoutLeafRecords } = snapshot @@ -122,6 +124,7 @@ export function normalizeTerminalLayoutSnapshot( expandedLeafId, ...(ptyIdsByLeafId ? { ptyIdsByLeafId } : {}), ...(buffersByLeafId ? { buffersByLeafId } : {}), + ...(scrollbackRefsByLeafId ? { scrollbackRefsByLeafId } : {}), ...(titlesByLeafId ? { titlesByLeafId } : {}) }, changed: true 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 index e9608dcd1..5e1a96e9a 100644 --- 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 @@ -137,4 +137,42 @@ describe('captureTerminalShutdownLayout', () => { expect(layout.ptyIdsByLeafId).toEqual({ [LEAF_ID]: 'pty-1' }) expect(layout.titlesByLeafId).toEqual({ [LEAF_ID]: 'local shell' }) }) + + it('does not preserve prior scrollback buffers or refs for a cleared leaf', async () => { + const { captureTerminalShutdownLayout } = await import('./terminal-shutdown-layout-capture') + const pane = { + id: 1, + leafId: LEAF_ID, + stablePaneId: LEAF_ID, + terminal: { options: { scrollback: 1_000 } }, + serializeAddon: { + serialize: vi.fn(() => '') + } + } + const manager = { + getPanes: vi.fn(() => [pane]), + getActivePane: vi.fn(() => pane) + } + + const layout = captureTerminalShutdownLayout({ + manager: manager as never, + container: mockRootForPane(1), + expandedPaneId: null, + paneTransports: new Map([[1, { getPtyId: vi.fn(() => 'pty-1') }]]), + paneTitlesByPaneId: { 1: 'local shell' }, + existingLayout: { + root: null, + activeLeafId: null, + expandedLeafId: null, + buffersByLeafId: { [LEAF_ID]: 'previous-scrollback' }, + scrollbackRefsByLeafId: { [LEAF_ID]: 'v1-previous' } + }, + clearedScrollbackLeafIds: new Set([LEAF_ID]) + }) + + expect(layout.buffersByLeafId).toBeUndefined() + expect(layout.scrollbackRefsByLeafId).toBeUndefined() + expect(layout.ptyIdsByLeafId).toEqual({ [LEAF_ID]: 'pty-1' }) + expect(layout.titlesByLeafId).toEqual({ [LEAF_ID]: 'local shell' }) + }) }) 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 index 709af1fee..46a3892f0 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts @@ -4,8 +4,9 @@ import type { PtyTransport } from './pty-transport' import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' import { serializeTerminalLayout } from './layout-serialization' import { mergeCapturedLeafState } from './merge-captured-leaf-state' +import { TERMINAL_SCROLLBACK_SESSION_BUFFER_CHAR_LIMIT } from '../../../../shared/terminal-scrollback-limits' -const MAX_BUFFER_BYTES = 512 * 1024 +const MAX_BUFFER_BYTES = TERMINAL_SCROLLBACK_SESSION_BUFFER_CHAR_LIMIT type ShutdownPane = Pick @@ -22,6 +23,20 @@ type CaptureTerminalShutdownLayoutArgs = { paneTitlesByPaneId: Record existingLayout: TerminalLayoutSnapshot | undefined captureBuffers?: boolean + clearedScrollbackLeafIds?: ReadonlySet +} + +function omitClearedLeafState( + record: Record | undefined, + clearedLeafIds: ReadonlySet | undefined +): Record | undefined { + if (!record || !clearedLeafIds || clearedLeafIds.size === 0) { + return record + } + const next = Object.fromEntries( + Object.entries(record).filter(([leafId]) => !clearedLeafIds.has(leafId)) + ) + return Object.keys(next).length > 0 ? next : undefined } export function captureTerminalShutdownLayout({ @@ -31,7 +46,8 @@ export function captureTerminalShutdownLayout({ paneTransports, paneTitlesByPaneId, existingLayout, - captureBuffers = true + captureBuffers = true, + clearedScrollbackLeafIds }: CaptureTerminalShutdownLayoutArgs): TerminalLayoutSnapshot { const panes = manager.getPanes() const buffers: Record = {} @@ -85,11 +101,16 @@ export function captureTerminalShutdownLayout({ const mergedBuffers = captureBuffers ? mergeCapturedLeafState({ - prior: existingLayout?.buffersByLeafId, + prior: omitClearedLeafState(existingLayout?.buffersByLeafId, clearedScrollbackLeafIds), fresh: buffers, currentLeafIds }) : {} + const mergedScrollbackRefs = mergeCapturedLeafState({ + prior: omitClearedLeafState(existingLayout?.scrollbackRefsByLeafId, clearedScrollbackLeafIds), + fresh: {}, + currentLeafIds + }) const mergedPtyIds = mergeCapturedLeafState({ prior: existingLayout?.ptyIdsByLeafId, fresh: Object.fromEntries(ptyEntries), @@ -98,6 +119,9 @@ export function captureTerminalShutdownLayout({ if (Object.keys(mergedBuffers).length > 0) { layout.buffersByLeafId = mergedBuffers } + if (Object.keys(mergedScrollbackRefs).length > 0) { + layout.scrollbackRefsByLeafId = mergedScrollbackRefs + } if (Object.keys(mergedPtyIds).length > 0) { layout.ptyIdsByLeafId = mergedPtyIds } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index 14b8d48ca..c029bc158 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -47,6 +47,7 @@ type UseTerminalPaneContextMenuDeps = { fallbackCwd: string toggleExpandPane: (paneId: number) => void onRequestClosePane: (paneId: number) => void + onClearPaneScrollback: (pane: ManagedPane) => void onSetTitle: (paneId: number) => void onPasteError: (message: string) => void onAgentSessionForkReady: (fork: PreparedAgentSessionFork) => void @@ -85,6 +86,7 @@ export function useTerminalPaneContextMenu({ fallbackCwd, toggleExpandPane, onRequestClosePane, + onClearPaneScrollback, onSetTitle, onPasteError, onAgentSessionForkReady, @@ -248,7 +250,7 @@ export function useTerminalPaneContextMenu({ const onClearScreen = (): void => { const pane = resolveMenuPane() if (pane) { - pane.terminal.clear() + onClearPaneScrollback(pane) } } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 792859b81..5dc899186 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -205,6 +205,37 @@ function terminalSelectionExceedsPrimaryLimit(terminal: Terminal): boolean { return cellEstimate > PRIMARY_SELECTION_MAX_LENGTH } +function hydrateTerminalScrollbackRefs(layout: TerminalLayoutSnapshot): { + layout: TerminalLayoutSnapshot + hydrated: boolean +} { + const refs = layout.scrollbackRefsByLeafId + if (!refs || Object.keys(refs).length === 0) { + return { layout, hydrated: false } + } + + const buffers = { ...layout.buffersByLeafId } + let hydrated = false + for (const [leafId, ref] of Object.entries(refs)) { + if (buffers[leafId] !== undefined) { + continue + } + try { + const buffer = window.api.session.readTerminalScrollback({ ref }) + if (buffer) { + buffers[leafId] = buffer + hydrated = true + } + } catch { + // Best-effort restore; failed snapshot reads should not block terminal mount. + } + } + + return hydrated + ? { layout: { ...layout, buffersByLeafId: buffers }, hydrated } + : { layout, hydrated } +} + type SplitStartupPayload = { command: string; env?: Record } type SplitWithStartupDeps = { @@ -458,6 +489,11 @@ export function useTerminalPaneLifecycle({ initialLayoutRef.current = normalizedInitialLayout.snapshot useAppStore.getState().setTabLayout(tabId, normalizedInitialLayout.snapshot) } + const initialLayoutHadBuffers = Boolean(initialLayoutRef.current.buffersByLeafId) + const hydratedInitialScrollback = hydrateTerminalScrollbackRefs(initialLayoutRef.current) + if (hydratedInitialScrollback.hydrated) { + initialLayoutRef.current = hydratedInitialScrollback.layout + } let shouldPersistLayout = false const ptyDeps = { tabId, @@ -989,12 +1025,18 @@ export function useTerminalPaneLifecycle({ } const restoredPaneByLeafId = replayTerminalLayout(manager, initialLayoutRef.current, isActive) - restoreScrollbackBuffers( - manager, - initialLayoutRef.current.buffersByLeafId, - restoredPaneByLeafId, - replayingPanesRef - ) + const restoredBuffers = initialLayoutRef.current.buffersByLeafId + restoreScrollbackBuffers(manager, restoredBuffers, restoredPaneByLeafId, replayingPanesRef) + if (restoredBuffers && initialLayoutRef.current.scrollbackRefsByLeafId) { + const layoutWithoutRestoredBuffers = { ...initialLayoutRef.current } + delete layoutWithoutRestoredBuffers.buffersByLeafId + initialLayoutRef.current = layoutWithoutRestoredBuffers + if (initialLayoutHadBuffers) { + // Why: raw replay bytes belong only to this mount. Drop legacy hydrated + // copies from Zustand so normal session writes stay ref-only. + useAppStore.getState().setTabLayout(tabId, layoutWithoutRestoredBuffers) + } + } // Seed pane titles from the persisted snapshot using the same // old-leafId → new-paneId mapping used for buffer restore. diff --git a/src/renderer/src/lib/workspace-session-patch.test.ts b/src/renderer/src/lib/workspace-session-patch.test.ts index 6e8db2fd8..b97128374 100644 --- a/src/renderer/src/lib/workspace-session-patch.test.ts +++ b/src/renderer/src/lib/workspace-session-patch.test.ts @@ -159,6 +159,7 @@ describe('buildWorkspaceSessionPatch', () => { activeLeafId: null, expandedLeafId: null, buffersByLeafId: { 'pane:1': 'serialized-local-scrollback' }, + scrollbackRefsByLeafId: { 'pane:1': 'v1-local' }, ptyIdsByLeafId: { 'pane:1': 'pty-1' } } }, @@ -177,6 +178,7 @@ describe('buildWorkspaceSessionPatch', () => { ) expect('pendingActivationSpawn' in patch.tabsByWorktree![localWorktreeId][0]).toBe(false) expect(patch.terminalLayoutsByTabId?.['tab-local'].buffersByLeafId).toBeUndefined() + expect(patch.terminalLayoutsByTabId?.['tab-local'].scrollbackRefsByLeafId).toBeUndefined() }) it('keeps optional clearing keys in patches', () => { diff --git a/src/renderer/src/lib/workspace-session.test.ts b/src/renderer/src/lib/workspace-session.test.ts index cf3579ce1..03710b498 100644 --- a/src/renderer/src/lib/workspace-session.test.ts +++ b/src/renderer/src/lib/workspace-session.test.ts @@ -196,6 +196,7 @@ describe('buildWorkspaceSessionPayload', () => { activeLeafId: null, expandedLeafId: null, buffersByLeafId: { 'pane:1': 'serialized-local-scrollback' }, + scrollbackRefsByLeafId: { 'pane:1': 'v1-local' }, ptyIdsByLeafId: { 'pane:1': 'pty-1' }, titlesByLeafId: { 'pane:1': 'build' } } diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index 91489d244..7b1497d1a 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' import { makePaneKey } from '../../../shared/stable-pane-id' +import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' import type { BrowserPage, BrowserWorkspace, Tab, TerminalTab } from '../../../shared/types' import type { OpenFile } from '../store/slices/editor' import { @@ -321,6 +322,60 @@ describe('applyWebSessionTabsSnapshot', () => { expect(patch.activeTabIdByWorktree?.[WT]).toBe(mirroredId) }) + it('removes stale scrollback refs from mirrored terminal layouts', () => { + const mirroredId = toWebTerminalSurfaceTabId('host-tab-1') + const ptyId = 'remote:web-env-1@@terminal-1' + const existingTab: TerminalTab = { + id: mirroredId, + ptyId, + worktreeId: WT, + title: 'host shell', + defaultTitle: 'host shell', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: NOW + } + + const patch = applyWebSessionTabsSnapshot( + makeState({ + tabsByWorktree: { [WT]: [existingTab] }, + ptyIdsByTabId: { [mirroredId]: [ptyId] }, + terminalLayoutsByTabId: { + [mirroredId]: { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: ptyId }, + scrollbackRefsByLeafId: { [LEAF_ID]: 'v1-stale-ref' } + } + } + }), + makeSnapshot([ + { + type: 'terminal', + id: HOST_SURFACE_ID, + title: 'host shell', + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-1' + } + ]), + ENV, + NOW + ) as Partial + + expect(patch.terminalLayoutsByTabId?.[mirroredId]).toMatchObject({ + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: ptyId } + }) + expect(patch.terminalLayoutsByTabId?.[mirroredId]?.scrollbackRefsByLeafId).toBeUndefined() + }) + it('hydrates host split tab groups with mirrored terminal tab ids', () => { const rightLeafId = SECOND_LEAF_ID const patch = applyWebSessionTabsSnapshot( diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index c29123452..e652ec327 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -1118,6 +1118,7 @@ function terminalLayoutEqual( (a?.expandedLeafId ?? null) === b.expandedLeafId && sameStringRecord(a?.ptyIdsByLeafId, b.ptyIdsByLeafId) && sameStringRecord(a?.buffersByLeafId, b.buffersByLeafId) && + sameStringRecord(a?.scrollbackRefsByLeafId, b.scrollbackRefsByLeafId) && sameStringRecord(a?.titlesByLeafId, b.titlesByLeafId) ) } diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 524263e19..ed8e902e9 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -485,6 +485,7 @@ function createWebPreloadApi(): Partial { }) ) }, + readTerminalScrollback: () => null, setSync: (session) => { writeJson(SESSION_STORAGE_KEY, sanitizeWebRuntimeWorkspaceSession(session)) } diff --git a/src/shared/terminal-scrollback-limits.ts b/src/shared/terminal-scrollback-limits.ts new file mode 100644 index 000000000..05e5710c4 --- /dev/null +++ b/src/shared/terminal-scrollback-limits.ts @@ -0,0 +1,3 @@ +export const TERMINAL_SCROLLBACK_SESSION_BUFFER_CHAR_LIMIT = 512 * 1024 +export const TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT = 512 * 1024 +export const TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT = 5 * 1024 * 1024 diff --git a/src/shared/types.ts b/src/shared/types.ts index b38e2fcaf..12013616c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -647,6 +647,8 @@ export type TerminalLayoutSnapshot = { ptyIdsByLeafId?: Record /** Serialized terminal buffers per leaf for scrollback restoration on restart. */ buffersByLeafId?: Record + /** Durable scrollback snapshot refs per leaf; raw bytes live outside session JSON. */ + scrollbackRefsByLeafId?: Record /** User-assigned pane titles, keyed by stable layout leaf UUID. * Persisted alongside buffers via the existing session:set flow. */ titlesByLeafId?: Record diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index f78c6cfee..5ab61590b 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -52,6 +52,7 @@ const terminalLayoutSnapshotSchema = z.object({ expandedLeafId: z.string().nullable(), ptyIdsByLeafId: z.record(z.string(), z.string()).optional(), buffersByLeafId: z.record(z.string(), z.string()).optional(), + scrollbackRefsByLeafId: z.record(z.string(), z.string()).optional(), titlesByLeafId: z.record(z.string(), z.string()).optional() }) diff --git a/src/shared/workspace-session-terminal-buffers.test.ts b/src/shared/workspace-session-terminal-buffers.test.ts index b2f29fd9c..c842172d8 100644 --- a/src/shared/workspace-session-terminal-buffers.test.ts +++ b/src/shared/workspace-session-terminal-buffers.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID } from './constants' import type { WorkspaceSessionState } from './types' +import { TERMINAL_SCROLLBACK_SESSION_BUFFER_CHAR_LIMIT } from './terminal-scrollback-limits' import { pruneLocalTerminalScrollbackBuffers, shouldPreserveTerminalScrollbackBuffers @@ -43,6 +44,7 @@ function makeSession(overrides: Partial = {}): WorkspaceS activeLeafId: null, expandedLeafId: null, buffersByLeafId: { 'pane:1': 'local-scrollback' }, + scrollbackRefsByLeafId: { 'pane:1': 'v1-local' }, ptyIdsByLeafId: { 'pane:1': 'local-pty' } }, 'remote-tab': { @@ -50,6 +52,7 @@ function makeSession(overrides: Partial = {}): WorkspaceS activeLeafId: null, expandedLeafId: null, buffersByLeafId: { 'pane:1': 'remote-scrollback' }, + scrollbackRefsByLeafId: { 'pane:1': 'v1-remote' }, ptyIdsByLeafId: { 'pane:1': 'remote-pty' } } }, @@ -78,7 +81,7 @@ describe('pruneLocalTerminalScrollbackBuffers', () => { ).toBe(true) }) - it('drops local buffers while preserving SSH buffers and PTY bindings', () => { + it('drops local scrollback while preserving SSH scrollback and PTY bindings', () => { const result = pruneLocalTerminalScrollbackBuffers(makeSession(), [ { id: 'local-repo', connectionId: null }, { id: 'remote-repo', connectionId: 'ssh-target-1' } @@ -93,6 +96,30 @@ describe('pruneLocalTerminalScrollbackBuffers', () => { expect(result.terminalLayoutsByTabId['remote-tab'].buffersByLeafId).toEqual({ 'pane:1': 'remote-scrollback' }) + expect(result.terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId).toEqual({ + 'pane:1': 'v1-remote' + }) + }) + + it('caps preserved SSH buffers so session JSON cannot scale with raw scrollback', () => { + const hugeScrollback = `start-${'x'.repeat(TERMINAL_SCROLLBACK_SESSION_BUFFER_CHAR_LIMIT + 10)}` + const result = pruneLocalTerminalScrollbackBuffers( + makeSession({ + terminalLayoutsByTabId: { + 'remote-tab': { + root: null, + activeLeafId: null, + expandedLeafId: null, + buffersByLeafId: { 'pane:1': hugeScrollback } + } + } + }), + [{ id: 'remote-repo', connectionId: 'ssh-target-1' }] + ) + + const buffer = result.terminalLayoutsByTabId['remote-tab'].buffersByLeafId?.['pane:1'] + expect(buffer).toHaveLength(TERMINAL_SCROLLBACK_SESSION_BUFFER_CHAR_LIMIT) + expect(buffer?.startsWith('start-')).toBe(false) }) it('drops floating terminal buffers even though the synthetic worktree has no repo', () => { diff --git a/src/shared/workspace-session-terminal-buffers.ts b/src/shared/workspace-session-terminal-buffers.ts index fced005b5..a63cf0753 100644 --- a/src/shared/workspace-session-terminal-buffers.ts +++ b/src/shared/workspace-session-terminal-buffers.ts @@ -1,6 +1,7 @@ import type { Repo, WorkspaceSessionState } from './types' import { FLOATING_TERMINAL_WORKTREE_ID } from './constants' import { getRepoIdFromWorktreeId } from './worktree-id' +import { TERMINAL_SCROLLBACK_SESSION_BUFFER_CHAR_LIMIT } from './terminal-scrollback-limits' export type RepoConnection = Pick @@ -34,6 +35,30 @@ export function shouldPreserveTerminalScrollbackBuffers( ) } +export function capTerminalScrollbackSessionBuffer(buffer: string): string { + if (buffer.length <= TERMINAL_SCROLLBACK_SESSION_BUFFER_CHAR_LIMIT) { + return buffer + } + return buffer.slice(-TERMINAL_SCROLLBACK_SESSION_BUFFER_CHAR_LIMIT) +} + +function capTerminalScrollbackLeafBuffers(buffers: Record | undefined): { + buffers: Record | undefined + changed: boolean +} { + if (!buffers) { + return { buffers: undefined, changed: false } + } + let changed = false + const capped: Record = {} + for (const [leafId, buffer] of Object.entries(buffers)) { + const next = capTerminalScrollbackSessionBuffer(buffer) + capped[leafId] = next + changed ||= next !== buffer + } + return { buffers: Object.keys(capped).length > 0 ? capped : undefined, changed } +} + export function pruneLocalTerminalScrollbackBuffers( session: WorkspaceSessionState, repos: readonly RepoConnection[] @@ -48,17 +73,23 @@ export function pruneLocalTerminalScrollbackBuffers( let terminalLayoutsByTabId: WorkspaceSessionState['terminalLayoutsByTabId'] | null = null for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId)) { - if (!layout.buffersByLeafId) { + if (!layout.buffersByLeafId && !layout.scrollbackRefsByLeafId) { continue } const worktreeId = worktreeIdByTabId.get(tabId) if (shouldPreserveTerminalScrollbackBuffersForRepoMap(worktreeId, connectionIdByRepoId)) { + const capped = capTerminalScrollbackLeafBuffers(layout.buffersByLeafId) + if (capped.changed) { + terminalLayoutsByTabId ??= { ...session.terminalLayoutsByTabId } + terminalLayoutsByTabId[tabId] = { ...layout, buffersByLeafId: capped.buffers } + } continue } terminalLayoutsByTabId ??= { ...session.terminalLayoutsByTabId } const layoutWithoutBuffers = { ...layout } delete layoutWithoutBuffers.buffersByLeafId + delete layoutWithoutBuffers.scrollbackRefsByLeafId terminalLayoutsByTabId[tabId] = layoutWithoutBuffers }