feat: preserve opened editor files across app restarts (#355)

Persist edit-mode files in the workspace session so they are restored
on restart. Diffs and conflict views are intentionally excluded as they
depend on transient git state.
This commit is contained in:
Jinjing
2026-04-06 20:48:52 -07:00
committed by GitHub
parent 2939caf476
commit e5f10f8092
7 changed files with 359 additions and 12 deletions
+6 -6
View File
@@ -124,12 +124,12 @@ describe('createMainWindow', () => {
const beforeInputEvent = windowHandlers['before-input-event']
for (const input of [
{ type: 'keyDown', control: true, meta: false, alt: false, key: '-' },
{ type: 'keyDown', control: true, meta: false, alt: false, key: '_' },
{ type: 'keyDown', control: true, meta: false, alt: false, key: 'Minus' },
{ type: 'keyDown', control: true, meta: false, alt: false, key: 'Subtract' },
{ type: 'keyDown', control: true, meta: false, alt: false, key: '', code: 'Minus' },
{ type: 'keyDown', control: true, meta: false, alt: false, key: '', code: 'NumpadSubtract' }
{ type: 'keyDown', control: true, meta: true, alt: false, key: '-' },
{ type: 'keyDown', control: true, meta: true, alt: false, key: '_' },
{ type: 'keyDown', control: true, meta: true, alt: false, key: 'Minus' },
{ type: 'keyDown', control: true, meta: true, alt: false, key: 'Subtract' },
{ type: 'keyDown', control: true, meta: true, alt: false, key: '', code: 'Minus' },
{ type: 'keyDown', control: true, meta: true, alt: false, key: '', code: 'NumpadSubtract' }
]) {
const preventDefault = vi.fn()
beforeInputEvent({ preventDefault } as never, input as never)
+52 -3
View File
@@ -22,9 +22,41 @@ import {
} from './runtime/sync-runtime-graph'
import { useGlobalFileDrop } from './hooks/useGlobalFileDrop'
import { registerUpdaterBeforeUnloadBypass } from './lib/updater-beforeunload'
import type { PersistedOpenFile } from '../../shared/types'
import type { OpenFile } from './store/slices/editor'
const isMac = navigator.userAgent.includes('Mac')
/** Build the editor-file portion of the workspace session for persistence.
* Only edit-mode files are saved — diffs and conflict views are transient. */
function buildEditorSessionData(
openFiles: OpenFile[],
activeFileIdByWorktree: Record<string, string | null>,
activeTabTypeByWorktree: Record<string, 'terminal' | 'editor'>
): {
openFilesByWorktree: Record<string, PersistedOpenFile[]>
activeFileIdByWorktree: Record<string, string | null>
activeTabTypeByWorktree: Record<string, 'terminal' | 'editor'>
} {
const editFiles = openFiles.filter((f) => f.mode === 'edit')
const byWorktree: Record<string, PersistedOpenFile[]> = {}
for (const f of editFiles) {
const arr = byWorktree[f.worktreeId] ?? (byWorktree[f.worktreeId] = [])
arr.push({
filePath: f.filePath,
relativePath: f.relativePath,
worktreeId: f.worktreeId,
language: f.language,
isPreview: f.isPreview || undefined
})
}
return {
openFilesByWorktree: byWorktree,
activeFileIdByWorktree,
activeTabTypeByWorktree
}
}
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false
@@ -64,6 +96,7 @@ function App(): React.JSX.Element {
const initGitHubCache = useAppStore((s) => s.initGitHubCache)
const refreshAllGitHub = useAppStore((s) => s.refreshAllGitHub)
const hydrateWorkspaceSession = useAppStore((s) => s.hydrateWorkspaceSession)
const hydrateEditorSession = useAppStore((s) => s.hydrateEditorSession)
const reconnectPersistedTerminals = useAppStore((s) => s.reconnectPersistedTerminals)
const hydratePersistedUI = useAppStore((s) => s.hydratePersistedUI)
const openModal = useAppStore((s) => s.openModal)
@@ -75,6 +108,11 @@ function App(): React.JSX.Element {
const filterRepoIds = useAppStore((s) => s.filterRepoIds)
const persistedUIReady = useAppStore((s) => s.persistedUIReady)
// Editor state for session persistence
const openFiles = useAppStore((s) => s.openFiles)
const activeFileIdByWorktree = useAppStore((s) => s.activeFileIdByWorktree)
const activeTabTypeByWorktree = useAppStore((s) => s.activeTabTypeByWorktree)
// Right sidebar + editor state
const toggleRightSidebar = useAppStore((s) => s.toggleRightSidebar)
const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen)
@@ -112,6 +150,7 @@ function App(): React.JSX.Element {
if (!cancelled) {
hydratePersistedUI(persistedUI)
hydrateWorkspaceSession(session)
hydrateEditorSession(session)
await reconnectPersistedTerminals(abortController.signal)
syncZoomCSSVar()
}
@@ -161,6 +200,7 @@ function App(): React.JSX.Element {
initGitHubCache,
hydratePersistedUI,
hydrateWorkspaceSession,
hydrateEditorSession,
reconnectPersistedTerminals
])
@@ -197,7 +237,8 @@ function App(): React.JSX.Element {
activeTabId,
tabsByWorktree,
terminalLayoutsByTabId,
activeWorktreeIdsOnShutdown
activeWorktreeIdsOnShutdown,
...buildEditorSessionData(openFiles, activeFileIdByWorktree, activeTabTypeByWorktree)
})
}, 150)
@@ -208,7 +249,10 @@ function App(): React.JSX.Element {
activeWorktreeId,
activeTabId,
tabsByWorktree,
terminalLayoutsByTabId
terminalLayoutsByTabId,
openFiles,
activeFileIdByWorktree,
activeTabTypeByWorktree
])
// On shutdown, capture terminal scrollback buffers and flush to disk.
@@ -235,7 +279,12 @@ function App(): React.JSX.Element {
activeTabId: state.activeTabId,
tabsByWorktree: state.tabsByWorktree,
terminalLayoutsByTabId: state.terminalLayoutsByTabId,
activeWorktreeIdsOnShutdown
activeWorktreeIdsOnShutdown,
...buildEditorSessionData(
state.openFiles,
state.activeFileIdByWorktree,
state.activeTabTypeByWorktree
)
})
}
window.addEventListener('beforeunload', captureAndFlush)
+86 -2
View File
@@ -11,7 +11,8 @@ import type {
GitConflictStatusSource,
GitStatusEntry,
GitStatusResult,
SearchResult
SearchResult,
WorkspaceSessionState
} from '../../../../shared/types'
export type DiffSource =
@@ -247,6 +248,9 @@ export type EditorSlice = {
// Quick open (Cmd+P)
quickOpenVisible: boolean
setQuickOpenVisible: (visible: boolean) => void
// Session hydration — restore editor files from persisted workspace session
hydrateEditorSession: (session: WorkspaceSessionState) => void
}
export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (set) => ({
@@ -1201,7 +1205,87 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
// Quick open
quickOpenVisible: false,
setQuickOpenVisible: (visible) => set({ quickOpenVisible: visible })
setQuickOpenVisible: (visible) => set({ quickOpenVisible: visible }),
// Why: only edit-mode files are restored — diffs and conflict views depend on
// transient git state that may have changed between sessions. Restoring them
// would show stale data or fail to load entirely.
hydrateEditorSession: (session) => {
set((s) => {
const openFilesByWorktree = session.openFilesByWorktree ?? {}
const persistedActiveFileIdByWorktree = session.activeFileIdByWorktree ?? {}
const persistedActiveTabTypeByWorktree = session.activeTabTypeByWorktree ?? {}
// Why: worktrees may have been deleted between sessions. Filter out
// files for worktrees that no longer exist, mirroring the validation
// that hydrateWorkspaceSession performs for terminal tabs.
const validWorktreeIds = new Set(
Object.values(s.worktreesByRepo)
.flat()
.map((w) => w.id)
)
const openFiles: OpenFile[] = []
for (const [worktreeId, files] of Object.entries(openFilesByWorktree)) {
if (!validWorktreeIds.has(worktreeId)) {
continue
}
for (const pf of files) {
openFiles.push({
id: pf.filePath,
filePath: pf.filePath,
relativePath: pf.relativePath,
worktreeId,
language: pf.language,
isDirty: false,
isPreview: pf.isPreview,
mode: 'edit'
})
}
}
if (openFiles.length === 0) {
return {}
}
// Why: use the store's activeWorktreeId (set by hydrateWorkspaceSession)
// rather than the raw session value. hydrateWorkspaceSession may have
// nulled out an invalid worktree ID, and we must respect that decision.
const activeWorktreeId = s.activeWorktreeId
const activeFileId = activeWorktreeId
? (persistedActiveFileIdByWorktree[activeWorktreeId] ?? null)
: null
// Why: verify the persisted active file still exists in the restored set.
// The file may have been removed due to worktree validation or the
// persisted data may reference a stale path.
const activeFileExists = activeFileId ? openFiles.some((f) => f.id === activeFileId) : false
const activeTabType =
activeWorktreeId && persistedActiveTabTypeByWorktree[activeWorktreeId]
? persistedActiveTabTypeByWorktree[activeWorktreeId]
: 'terminal'
// Filter per-worktree maps to only valid worktrees with valid file references
const filteredActiveFileIdByWorktree = Object.fromEntries(
Object.entries(persistedActiveFileIdByWorktree).filter(
([wId, fileId]) =>
validWorktreeIds.has(wId) && fileId && openFiles.some((f) => f.id === fileId)
)
)
const filteredActiveTabTypeByWorktree = Object.fromEntries(
Object.entries(persistedActiveTabTypeByWorktree).filter(([wId]) =>
validWorktreeIds.has(wId)
)
)
return {
openFiles,
activeFileId: activeFileExists ? activeFileId : null,
activeFileIdByWorktree: filteredActiveFileIdByWorktree,
activeTabType: activeFileExists ? activeTabType : 'terminal',
activeTabTypeByWorktree: filteredActiveTabTypeByWorktree
}
})
}
})
function getCompareVersion(
+20
View File
@@ -113,6 +113,21 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
for (const ptyId of killedPtyIds) {
nextSuppressedPtyExitIds[ptyId] = true
}
// Why: editor state is worktree-scoped. Removing a repo must also
// remove open editor files and per-worktree active-file tracking for
// all worktrees that belonged to the repo, otherwise orphaned entries
// would persist in the session save and pollute state.
const worktreeIdSet = new Set(worktreeIds)
const nextOpenFiles = s.openFiles.filter((f) => !worktreeIdSet.has(f.worktreeId))
const nextActiveFileIdByWorktree = { ...s.activeFileIdByWorktree }
const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree }
for (const wId of worktreeIds) {
delete nextActiveFileIdByWorktree[wId]
delete nextActiveTabTypeByWorktree[wId]
}
const activeFileCleared = s.activeFileId
? s.openFiles.some((f) => f.id === s.activeFileId && worktreeIdSet.has(f.worktreeId))
: false
return {
repos: s.repos.filter((r) => r.id !== repoId),
activeRepoId: s.activeRepoId === repoId ? null : s.activeRepoId,
@@ -123,6 +138,11 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
suppressedPtyExitIds: nextSuppressedPtyExitIds,
terminalLayoutsByTabId: nextLayouts,
activeTabId: s.activeTabId && killedTabIds.has(s.activeTabId) ? null : s.activeTabId,
openFiles: nextOpenFiles,
activeFileIdByWorktree: nextActiveFileIdByWorktree,
activeTabTypeByWorktree: nextActiveTabTypeByWorktree,
activeFileId: activeFileCleared ? null : s.activeFileId,
activeTabType: activeFileCleared ? 'terminal' : s.activeTabType,
sortEpoch: s.sortEpoch + 1
}
})
@@ -589,3 +589,175 @@ describe('reconnectPersistedTerminals', () => {
expect((mockApi.pty as Record<string, unknown>).spawn).toHaveBeenCalledTimes(1)
})
})
describe('hydrateEditorSession', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('restores edit-mode files from persisted session', () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
store.setState({
repos: [
{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }
],
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
// Why: hydrateEditorSession reads activeWorktreeId from the store
// (set by hydrateWorkspaceSession), not from the raw session.
activeWorktreeId: wt
})
store.getState().hydrateEditorSession({
activeRepoId: 'repo1',
activeWorktreeId: wt,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
openFilesByWorktree: {
[wt]: [
{
filePath: '/path/wt1/src/index.ts',
relativePath: 'src/index.ts',
worktreeId: wt,
language: 'typescript'
},
{
filePath: '/path/wt1/README.md',
relativePath: 'README.md',
worktreeId: wt,
language: 'markdown',
isPreview: true
}
]
},
activeFileIdByWorktree: { [wt]: '/path/wt1/src/index.ts' },
activeTabTypeByWorktree: { [wt]: 'editor' }
})
const s = store.getState()
expect(s.openFiles).toHaveLength(2)
expect(s.openFiles[0].filePath).toBe('/path/wt1/src/index.ts')
expect(s.openFiles[0].mode).toBe('edit')
expect(s.openFiles[0].isDirty).toBe(false)
expect(s.openFiles[1].isPreview).toBe(true)
expect(s.activeFileId).toBe('/path/wt1/src/index.ts')
expect(s.activeTabType).toBe('editor')
})
it('does nothing when no editor files are persisted', () => {
const store = createTestStore()
store.getState().hydrateEditorSession({
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {}
})
const s = store.getState()
expect(s.openFiles).toHaveLength(0)
expect(s.activeFileId).toBeNull()
expect(s.activeTabType).toBe('terminal')
})
it('falls back to terminal if persisted activeFileId is missing from restored files', () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
store.setState({
repos: [
{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }
],
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
activeWorktreeId: wt
})
store.getState().hydrateEditorSession({
activeRepoId: 'repo1',
activeWorktreeId: wt,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
openFilesByWorktree: {
[wt]: [
{
filePath: '/path/wt1/src/index.ts',
relativePath: 'src/index.ts',
worktreeId: wt,
language: 'typescript'
}
]
},
// Points to a file that no longer exists in the restored set
activeFileIdByWorktree: { [wt]: '/path/wt1/gone.ts' },
activeTabTypeByWorktree: { [wt]: 'editor' }
})
const s = store.getState()
expect(s.openFiles).toHaveLength(1)
expect(s.activeFileId).toBeNull()
expect(s.activeTabType).toBe('terminal')
})
it('filters out files for deleted worktrees', () => {
const store = createTestStore()
const validWt = 'repo1::/path/wt1'
const deletedWt = 'repo1::/path/gone'
store.setState({
repos: [
{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }
],
worktreesByRepo: {
repo1: [makeWorktree({ id: validWt, repoId: 'repo1', path: '/path/wt1' })]
},
activeWorktreeId: validWt
})
store.getState().hydrateEditorSession({
activeRepoId: 'repo1',
activeWorktreeId: validWt,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
openFilesByWorktree: {
[validWt]: [
{
filePath: '/path/wt1/src/index.ts',
relativePath: 'src/index.ts',
worktreeId: validWt,
language: 'typescript'
}
],
[deletedWt]: [
{
filePath: '/path/gone/src/app.ts',
relativePath: 'src/app.ts',
worktreeId: deletedWt,
language: 'typescript'
}
]
},
activeFileIdByWorktree: {
[validWt]: '/path/wt1/src/index.ts',
[deletedWt]: '/path/gone/src/app.ts'
},
activeTabTypeByWorktree: { [validWt]: 'editor', [deletedWt]: 'editor' }
})
const s = store.getState()
// Only files from the valid worktree should be restored
expect(s.openFiles).toHaveLength(1)
expect(s.openFiles[0].worktreeId).toBe(validWt)
// Deleted worktree should not appear in per-worktree maps
expect(s.activeFileIdByWorktree[deletedWt]).toBeUndefined()
expect(s.activeTabTypeByWorktree[deletedWt]).toBeUndefined()
})
})
+4 -1
View File
@@ -123,6 +123,9 @@ export function getDefaultWorkspaceSession(): WorkspaceSessionState {
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {}
terminalLayoutsByTabId: {},
openFilesByWorktree: {},
activeFileIdByWorktree: {},
activeTabTypeByWorktree: {}
}
}
+19
View File
@@ -90,6 +90,17 @@ export type TerminalLayoutSnapshot = {
buffersByLeafId?: Record<string, string>
}
/** Minimal subset of OpenFile persisted across restarts.
* Only edit-mode files are saved — diffs, conflict reviews, and other
* transient views are reconstructed on demand from git state. */
export type PersistedOpenFile = {
filePath: string
relativePath: string
worktreeId: string
language: string
isPreview?: boolean
}
export type WorkspaceSessionState = {
activeRepoId: string | null
activeWorktreeId: string | null
@@ -100,6 +111,14 @@ export type WorkspaceSessionState = {
* Used on startup to eagerly re-spawn PTY processes so the Active filter
* works immediately after restart. */
activeWorktreeIdsOnShutdown?: string[]
/** Editor files that were open at shutdown, keyed by worktree ID.
* Only edit-mode files are persisted — diffs and conflict views are
* transient and not restored. */
openFilesByWorktree?: Record<string, PersistedOpenFile[]>
/** Per-worktree active editor file ID (filePath) at shutdown. */
activeFileIdByWorktree?: Record<string, string | null>
/** Per-worktree active tab type (terminal vs editor) at shutdown. */
activeTabTypeByWorktree?: Record<string, 'terminal' | 'editor'>
}
// ─── GitHub ──────────────────────────────────────────────────────────