mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 00:02:37 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -123,6 +123,9 @@ export function getDefaultWorkspaceSession(): WorkspaceSessionState {
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {}
|
||||
terminalLayoutsByTabId: {},
|
||||
openFilesByWorktree: {},
|
||||
activeFileIdByWorktree: {},
|
||||
activeTabTypeByWorktree: {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user