Persist terminal scrollback outside session JSON
This commit is contained in:
parent
68b8070db5
commit
b4188b4861
|
|
@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<typeof setTimeout> | 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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
const refs = new Set<string>()
|
||||
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<string, string> = {}
|
||||
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 }
|
||||
}
|
||||
|
|
@ -1710,6 +1710,7 @@ export type PreloadApi = {
|
|||
get: () => Promise<WorkspaceSessionState>
|
||||
set: (args: WorkspaceSessionState) => Promise<void>
|
||||
patch: (args: WorkspaceSessionPatch) => Promise<void>
|
||||
readTerminalScrollback: (args: { ref: string }) => string | null
|
||||
setSync: (args: WorkspaceSessionState) => void
|
||||
}
|
||||
remoteWorkspace: {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -337,6 +337,7 @@ export default function TerminalPane({
|
|||
const paneTitlesRef = useRef<Record<number, string>>({})
|
||||
paneTitlesRef.current = paneTitles
|
||||
const removedTitleLeafIdsRef = useRef<Set<string>>(new Set())
|
||||
const clearedScrollbackLeafIdsRef = useRef<Set<string>>(new Set())
|
||||
const [renamingPaneId, setRenamingPaneId] = useState<number | null>(null)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const renameInputRef = useRef<HTMLInputElement>(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,
|
||||
|
|
|
|||
|
|
@ -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<React.SetStateAction<boolean>>
|
||||
onSearchSelectedText: (text: string) => void
|
||||
onRequestClosePane: (paneId: number) => void
|
||||
onClearPaneScrollback: (pane: ManagedPane) => void
|
||||
searchOpenRef: React.RefObject<boolean>
|
||||
searchStateRef: React.RefObject<SearchState>
|
||||
macOptionAsAltRef: React.RefObject<MacOptionAsAlt>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<ManagedPane, 'id' | 'leafId' | 'terminal' | 'serializeAddon'>
|
||||
|
||||
|
|
@ -22,6 +23,20 @@ type CaptureTerminalShutdownLayoutArgs = {
|
|||
paneTitlesByPaneId: Record<number, string>
|
||||
existingLayout: TerminalLayoutSnapshot | undefined
|
||||
captureBuffers?: boolean
|
||||
clearedScrollbackLeafIds?: ReadonlySet<string>
|
||||
}
|
||||
|
||||
function omitClearedLeafState(
|
||||
record: Record<string, string> | undefined,
|
||||
clearedLeafIds: ReadonlySet<string> | undefined
|
||||
): Record<string, string> | 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<string, string> = {}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, string> }
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<WebSessionTabsSyncState>
|
||||
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -485,6 +485,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
|
|||
})
|
||||
)
|
||||
},
|
||||
readTerminalScrollback: () => null,
|
||||
setSync: (session) => {
|
||||
writeJson(SESSION_STORAGE_KEY, sanitizeWebRuntimeWorkspaceSession(session))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -647,6 +647,8 @@ export type TerminalLayoutSnapshot = {
|
|||
ptyIdsByLeafId?: Record<string, string>
|
||||
/** Serialized terminal buffers per leaf for scrollback restoration on restart. */
|
||||
buffersByLeafId?: Record<string, string>
|
||||
/** Durable scrollback snapshot refs per leaf; raw bytes live outside session JSON. */
|
||||
scrollbackRefsByLeafId?: Record<string, string>
|
||||
/** User-assigned pane titles, keyed by stable layout leaf UUID.
|
||||
* Persisted alongside buffers via the existing session:set flow. */
|
||||
titlesByLeafId?: Record<string, string>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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<WorkspaceSessionState> = {}): 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<WorkspaceSessionState> = {}): 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', () => {
|
||||
|
|
|
|||
|
|
@ -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<Repo, 'id' | 'connectionId'>
|
||||
|
||||
|
|
@ -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<string, string> | undefined): {
|
||||
buffers: Record<string, string> | undefined
|
||||
changed: boolean
|
||||
} {
|
||||
if (!buffers) {
|
||||
return { buffers: undefined, changed: false }
|
||||
}
|
||||
let changed = false
|
||||
const capped: Record<string, string> = {}
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue