From e5f10f80929692fadbf686ed2fa6311303846cdb Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 6 Apr 2026 20:48:52 -0700 Subject: [PATCH] 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. --- src/main/window/createMainWindow.test.ts | 12 +- src/renderer/src/App.tsx | 55 +++++- src/renderer/src/store/slices/editor.ts | 88 ++++++++- src/renderer/src/store/slices/repos.ts | 20 ++ .../slices/store-session-cascades.test.ts | 172 ++++++++++++++++++ src/shared/constants.ts | 5 +- src/shared/types.ts | 19 ++ 7 files changed, 359 insertions(+), 12 deletions(-) diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 8e7bc2d9b8c..caeba78bc2e 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -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) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index e05b453de8a..4e94ffc8d84 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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, + activeTabTypeByWorktree: Record +): { + openFilesByWorktree: Record + activeFileIdByWorktree: Record + activeTabTypeByWorktree: Record +} { + const editFiles = openFiles.filter((f) => f.mode === 'edit') + const byWorktree: Record = {} + 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) diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index ee3c14c457b..1e56c9b2509 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -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 = (set) => ({ @@ -1201,7 +1205,87 @@ export const createEditorSlice: StateCreator = (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( diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index bca4c75997e..9443ecb5933 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -113,6 +113,21 @@ export const createRepoSlice: StateCreator = (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 = (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 } }) diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index 23eafa1d129..0596b6536f0 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -589,3 +589,175 @@ describe('reconnectPersistedTerminals', () => { expect((mockApi.pty as Record).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() + }) +}) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 13db951fbfa..e6e1fe4232d 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -123,6 +123,9 @@ export function getDefaultWorkspaceSession(): WorkspaceSessionState { activeWorktreeId: null, activeTabId: null, tabsByWorktree: {}, - terminalLayoutsByTabId: {} + terminalLayoutsByTabId: {}, + openFilesByWorktree: {}, + activeFileIdByWorktree: {}, + activeTabTypeByWorktree: {} } } diff --git a/src/shared/types.ts b/src/shared/types.ts index 59447560436..f94a0855c01 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -90,6 +90,17 @@ export type TerminalLayoutSnapshot = { buffersByLeafId?: Record } +/** 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 + /** Per-worktree active editor file ID (filePath) at shutdown. */ + activeFileIdByWorktree?: Record + /** Per-worktree active tab type (terminal vs editor) at shutdown. */ + activeTabTypeByWorktree?: Record } // ─── GitHub ──────────────────────────────────────────────────────────