feat(03-01): add IndexedDB storage layer and contextSlice
- Install idb-keyval for IndexedDB persistence - Create contextStorage service with TTL-based snapshot save/load - Create contextSlice with switchContext and initializeContextSystem actions - Implement snapshot validation to filter invalid tabs/selections - Exclude transient state (loading, errors, Maps/Sets) from snapshots
This commit is contained in:
parent
5b31306b20
commit
f129715dc8
4 changed files with 527 additions and 0 deletions
|
|
@ -45,6 +45,7 @@
|
||||||
"@tanstack/react-virtual": "^3.10.8",
|
"@tanstack/react-virtual": "^3.10.8",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"electron-updater": "^6.7.3",
|
"electron-updater": "^6.7.3",
|
||||||
|
"idb-keyval": "^6.2.2",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"mdast-util-to-hast": "^13.2.1",
|
"mdast-util-to-hast": "^13.2.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@ importers:
|
||||||
electron-updater:
|
electron-updater:
|
||||||
specifier: ^6.7.3
|
specifier: ^6.7.3
|
||||||
version: 6.7.3
|
version: 6.7.3
|
||||||
|
idb-keyval:
|
||||||
|
specifier: ^6.2.2
|
||||||
|
version: 6.2.2
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.562.0
|
specifier: ^0.562.0
|
||||||
version: 0.562.0(react@18.3.1)
|
version: 0.562.0(react@18.3.1)
|
||||||
|
|
@ -2414,6 +2417,9 @@ packages:
|
||||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
idb-keyval@6.2.2:
|
||||||
|
resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==}
|
||||||
|
|
||||||
ieee754@1.2.1:
|
ieee754@1.2.1:
|
||||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||||
|
|
||||||
|
|
@ -6577,6 +6583,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
|
|
||||||
|
idb-keyval@6.2.2: {}
|
||||||
|
|
||||||
ieee754@1.2.1: {}
|
ieee754@1.2.1: {}
|
||||||
|
|
||||||
ignore@5.3.2: {}
|
ignore@5.3.2: {}
|
||||||
|
|
|
||||||
201
src/renderer/services/contextStorage.ts
Normal file
201
src/renderer/services/contextStorage.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
/**
|
||||||
|
* Context Storage - IndexedDB persistence layer for context snapshots.
|
||||||
|
*
|
||||||
|
* Provides TTL-based storage for workspace state snapshots, enabling
|
||||||
|
* instant restoration when switching between local and SSH contexts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { del, get, keys, set } from 'idb-keyval';
|
||||||
|
|
||||||
|
import type { DetectedError, Project, RepositoryGroup, Session } from '@renderer/types/data';
|
||||||
|
import type { PaneLayout } from '@renderer/types/panes';
|
||||||
|
import type { Tab } from '@renderer/types/tabs';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Constants
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const SNAPSHOT_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
const STORAGE_KEY_PREFIX = 'context-snapshot:';
|
||||||
|
const SNAPSHOT_VERSION = 1; // Increment when ContextSnapshot structure changes
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context snapshot - persistable state for instant workspace switching.
|
||||||
|
* Excludes transient state (loading flags, errors, search, non-serializable Maps/Sets).
|
||||||
|
*/
|
||||||
|
export interface ContextSnapshot {
|
||||||
|
// Data state (persistable)
|
||||||
|
projects: Project[];
|
||||||
|
selectedProjectId: string | null;
|
||||||
|
repositoryGroups: RepositoryGroup[];
|
||||||
|
selectedRepositoryId: string | null;
|
||||||
|
selectedWorktreeId: string | null;
|
||||||
|
viewMode: 'flat' | 'grouped';
|
||||||
|
sessions: Session[];
|
||||||
|
selectedSessionId: string | null;
|
||||||
|
sessionsCursor: string | null;
|
||||||
|
sessionsHasMore: boolean;
|
||||||
|
sessionsTotalCount: number;
|
||||||
|
pinnedSessionIds: string[];
|
||||||
|
notifications: DetectedError[];
|
||||||
|
unreadCount: number;
|
||||||
|
|
||||||
|
// Tab/pane state
|
||||||
|
openTabs: Tab[];
|
||||||
|
activeTabId: string | null;
|
||||||
|
selectedTabIds: string[];
|
||||||
|
activeProjectId: string | null;
|
||||||
|
paneLayout: PaneLayout;
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
sidebarCollapsed: boolean;
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
_metadata: {
|
||||||
|
contextId: string;
|
||||||
|
capturedAt: number;
|
||||||
|
version: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stored snapshot - wraps ContextSnapshot with timestamp and version.
|
||||||
|
*/
|
||||||
|
interface StoredSnapshot {
|
||||||
|
snapshot: ContextSnapshot;
|
||||||
|
timestamp: number;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Storage Implementation
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a context snapshot to IndexedDB.
|
||||||
|
*/
|
||||||
|
async function saveSnapshot(contextId: string, snapshot: ContextSnapshot): Promise<void> {
|
||||||
|
try {
|
||||||
|
const stored: StoredSnapshot = {
|
||||||
|
snapshot,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
version: SNAPSHOT_VERSION,
|
||||||
|
};
|
||||||
|
const key = `${STORAGE_KEY_PREFIX}${contextId}`;
|
||||||
|
await set(key, stored);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[contextStorage] Failed to save snapshot for ${contextId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a context snapshot from IndexedDB.
|
||||||
|
* Returns null if not found, expired, or invalid.
|
||||||
|
*/
|
||||||
|
async function loadSnapshot(contextId: string): Promise<ContextSnapshot | null> {
|
||||||
|
try {
|
||||||
|
const key = `${STORAGE_KEY_PREFIX}${contextId}`;
|
||||||
|
const stored = await get(key);
|
||||||
|
|
||||||
|
if (!stored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check TTL
|
||||||
|
const age = Date.now() - stored.timestamp;
|
||||||
|
if (age > SNAPSHOT_TTL_MS) {
|
||||||
|
// Expired - delete and return null
|
||||||
|
void deleteSnapshot(contextId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check version compatibility (simple check for now)
|
||||||
|
if (stored.version !== SNAPSHOT_VERSION) {
|
||||||
|
console.warn(
|
||||||
|
`[contextStorage] Snapshot version mismatch for ${contextId}: expected ${SNAPSHOT_VERSION}, got ${stored.version}`
|
||||||
|
);
|
||||||
|
void deleteSnapshot(contextId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stored.snapshot;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[contextStorage] Failed to load snapshot for ${contextId}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a context snapshot from IndexedDB.
|
||||||
|
*/
|
||||||
|
async function deleteSnapshot(contextId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const key = `${STORAGE_KEY_PREFIX}${contextId}`;
|
||||||
|
await del(key);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[contextStorage] Failed to delete snapshot for ${contextId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up expired snapshots.
|
||||||
|
* Iterates all context snapshots and deletes expired ones.
|
||||||
|
*/
|
||||||
|
async function cleanupExpired(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const allKeys = await keys();
|
||||||
|
const snapshotKeys = allKeys.filter((k) =>
|
||||||
|
typeof k === 'string' ? k.startsWith(STORAGE_KEY_PREFIX) : false
|
||||||
|
);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const key of snapshotKeys) {
|
||||||
|
try {
|
||||||
|
const stored = await get(key);
|
||||||
|
if (stored) {
|
||||||
|
const age = now - stored.timestamp;
|
||||||
|
if (age > SNAPSHOT_TTL_MS) {
|
||||||
|
await del(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Skip individual key errors
|
||||||
|
console.error(`[contextStorage] Failed to check/delete key ${String(key)}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[contextStorage] Failed to cleanup expired snapshots:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if IndexedDB is available.
|
||||||
|
* Returns true if storage is accessible, false otherwise.
|
||||||
|
*/
|
||||||
|
async function isAvailable(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const testKey = '__idb_test__';
|
||||||
|
await set(testKey, true);
|
||||||
|
await del(testKey);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Exports
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const contextStorage = {
|
||||||
|
saveSnapshot,
|
||||||
|
loadSnapshot,
|
||||||
|
deleteSnapshot,
|
||||||
|
cleanupExpired,
|
||||||
|
isAvailable,
|
||||||
|
};
|
||||||
317
src/renderer/store/slices/contextSlice.ts
Normal file
317
src/renderer/store/slices/contextSlice.ts
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
/**
|
||||||
|
* Context Slice - Manages context switching lifecycle.
|
||||||
|
*
|
||||||
|
* Orchestrates snapshot capture/restore for instant workspace switching
|
||||||
|
* between local and SSH contexts, with IndexedDB persistence and TTL.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { contextStorage } from '@renderer/services/contextStorage';
|
||||||
|
|
||||||
|
import { getFullResetState } from '../utils/stateResetHelpers';
|
||||||
|
|
||||||
|
import type { AppState } from '../types';
|
||||||
|
import type { ContextSnapshot } from '@renderer/services/contextStorage';
|
||||||
|
import type { DetectedError, Project, RepositoryGroup } from '@renderer/types/data';
|
||||||
|
import type { Pane } from '@renderer/types/panes';
|
||||||
|
import type { Tab } from '@renderer/types/tabs';
|
||||||
|
import type { StateCreator } from 'zustand';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Slice Interface
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface ContextSlice {
|
||||||
|
// State
|
||||||
|
activeContextId: string; // 'local' initially
|
||||||
|
isContextSwitching: boolean; // true during switch transition
|
||||||
|
targetContextId: string | null; // context being switched to
|
||||||
|
contextSnapshotsReady: boolean; // true after initial IndexedDB check
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
switchContext: (targetContextId: string) => Promise<void>;
|
||||||
|
initializeContextSystem: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Helpers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get empty context state for fresh contexts.
|
||||||
|
* Returns state with empty arrays, null selections, and default dashboard tab.
|
||||||
|
*/
|
||||||
|
function getEmptyContextState(): Partial<AppState> {
|
||||||
|
return {
|
||||||
|
...getFullResetState(),
|
||||||
|
projects: [],
|
||||||
|
repositoryGroups: [],
|
||||||
|
sessions: [],
|
||||||
|
notifications: [],
|
||||||
|
unreadCount: 0,
|
||||||
|
openTabs: [],
|
||||||
|
activeTabId: null,
|
||||||
|
selectedTabIds: [],
|
||||||
|
activeProjectId: null,
|
||||||
|
paneLayout: {
|
||||||
|
panes: [
|
||||||
|
{
|
||||||
|
id: 'pane-default',
|
||||||
|
tabs: [],
|
||||||
|
activeTabId: null,
|
||||||
|
selectedTabIds: [],
|
||||||
|
widthFraction: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
focusedPaneId: 'pane-default',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate snapshot against fresh data from target context.
|
||||||
|
* Filters invalid tabs, selections, and ensures at-least-one-pane invariant.
|
||||||
|
*/
|
||||||
|
function validateSnapshot(
|
||||||
|
snapshot: ContextSnapshot,
|
||||||
|
freshProjects: Project[],
|
||||||
|
freshRepoGroups: RepositoryGroup[]
|
||||||
|
): Partial<AppState> {
|
||||||
|
const validProjectIds = new Set(freshProjects.map((p) => p.id));
|
||||||
|
const validWorktreeIds = new Set(freshRepoGroups.flatMap((rg) => rg.worktrees.map((w) => w.id)));
|
||||||
|
|
||||||
|
// Validate selectedProjectId
|
||||||
|
const selectedProjectId =
|
||||||
|
snapshot.selectedProjectId && validProjectIds.has(snapshot.selectedProjectId)
|
||||||
|
? snapshot.selectedProjectId
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Validate selectedRepositoryId and selectedWorktreeId
|
||||||
|
const selectedRepositoryId = snapshot.selectedRepositoryId; // repos may differ but allow graceful fallback
|
||||||
|
const selectedWorktreeId =
|
||||||
|
snapshot.selectedWorktreeId && validWorktreeIds.has(snapshot.selectedWorktreeId)
|
||||||
|
? snapshot.selectedWorktreeId
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Validate tabs — filter out session tabs referencing invalid projects
|
||||||
|
const validTabs = snapshot.openTabs.filter((tab) => {
|
||||||
|
if (tab.type === 'session' && tab.projectId) {
|
||||||
|
return validProjectIds.has(tab.projectId) || validWorktreeIds.has(tab.projectId);
|
||||||
|
}
|
||||||
|
return true; // Keep dashboard and non-session tabs
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate activeTabId
|
||||||
|
let activeTabId = snapshot.activeTabId;
|
||||||
|
if (activeTabId && !validTabs.find((t) => t.id === activeTabId)) {
|
||||||
|
activeTabId = validTabs[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate pane layout tabs
|
||||||
|
const validatedPanes = snapshot.paneLayout.panes
|
||||||
|
.map((pane) => {
|
||||||
|
const paneTabs = pane.tabs.filter((tab) => {
|
||||||
|
if (tab.type === 'session' && tab.projectId) {
|
||||||
|
return validProjectIds.has(tab.projectId) || validWorktreeIds.has(tab.projectId);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const paneActiveId = paneTabs.find((t) => t.id === pane.activeTabId)
|
||||||
|
? pane.activeTabId
|
||||||
|
: (paneTabs[0]?.id ?? null);
|
||||||
|
return {
|
||||||
|
...pane,
|
||||||
|
tabs: paneTabs,
|
||||||
|
activeTabId: paneActiveId,
|
||||||
|
selectedTabIds: pane.selectedTabIds.filter((id) => paneTabs.some((t) => t.id === id)),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((pane) => pane.tabs.length > 0); // Remove empty panes
|
||||||
|
|
||||||
|
// Ensure at least one pane exists
|
||||||
|
const finalPanes: Pane[] =
|
||||||
|
validatedPanes.length > 0
|
||||||
|
? validatedPanes
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
id: 'pane-default',
|
||||||
|
tabs: [],
|
||||||
|
activeTabId: null,
|
||||||
|
selectedTabIds: [],
|
||||||
|
widthFraction: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Restored from snapshot (use fresh data for projects/repoGroups)
|
||||||
|
projects: freshProjects,
|
||||||
|
selectedProjectId,
|
||||||
|
repositoryGroups: freshRepoGroups,
|
||||||
|
selectedRepositoryId,
|
||||||
|
selectedWorktreeId,
|
||||||
|
viewMode: snapshot.viewMode,
|
||||||
|
sessions: snapshot.sessions,
|
||||||
|
selectedSessionId: snapshot.selectedSessionId,
|
||||||
|
sessionsCursor: snapshot.sessionsCursor,
|
||||||
|
sessionsHasMore: snapshot.sessionsHasMore,
|
||||||
|
sessionsTotalCount: snapshot.sessionsTotalCount,
|
||||||
|
pinnedSessionIds: snapshot.pinnedSessionIds,
|
||||||
|
notifications: snapshot.notifications,
|
||||||
|
unreadCount: snapshot.unreadCount,
|
||||||
|
openTabs: validTabs,
|
||||||
|
activeTabId,
|
||||||
|
selectedTabIds: snapshot.selectedTabIds.filter((id) => validTabs.some((t) => t.id === id)),
|
||||||
|
activeProjectId:
|
||||||
|
snapshot.activeProjectId &&
|
||||||
|
(validProjectIds.has(snapshot.activeProjectId) ||
|
||||||
|
validWorktreeIds.has(snapshot.activeProjectId))
|
||||||
|
? snapshot.activeProjectId
|
||||||
|
: selectedProjectId,
|
||||||
|
paneLayout: {
|
||||||
|
panes: finalPanes,
|
||||||
|
focusedPaneId: finalPanes.find((p) => p.id === snapshot.paneLayout.focusedPaneId)
|
||||||
|
? snapshot.paneLayout.focusedPaneId
|
||||||
|
: finalPanes[0].id,
|
||||||
|
},
|
||||||
|
sidebarCollapsed: snapshot.sidebarCollapsed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture current context state as a snapshot.
|
||||||
|
* Excludes transient state (loading flags, errors, search, Maps/Sets).
|
||||||
|
*/
|
||||||
|
function captureSnapshot(state: AppState, contextId: string): ContextSnapshot {
|
||||||
|
return {
|
||||||
|
// Data state
|
||||||
|
projects: state.projects,
|
||||||
|
selectedProjectId: state.selectedProjectId,
|
||||||
|
repositoryGroups: state.repositoryGroups,
|
||||||
|
selectedRepositoryId: state.selectedRepositoryId,
|
||||||
|
selectedWorktreeId: state.selectedWorktreeId,
|
||||||
|
viewMode: state.viewMode,
|
||||||
|
sessions: state.sessions,
|
||||||
|
selectedSessionId: state.selectedSessionId,
|
||||||
|
sessionsCursor: state.sessionsCursor,
|
||||||
|
sessionsHasMore: state.sessionsHasMore,
|
||||||
|
sessionsTotalCount: state.sessionsTotalCount,
|
||||||
|
pinnedSessionIds: state.pinnedSessionIds,
|
||||||
|
notifications: state.notifications,
|
||||||
|
unreadCount: state.unreadCount,
|
||||||
|
|
||||||
|
// Tab/pane state
|
||||||
|
openTabs: state.openTabs,
|
||||||
|
activeTabId: state.activeTabId,
|
||||||
|
selectedTabIds: state.selectedTabIds,
|
||||||
|
activeProjectId: state.activeProjectId,
|
||||||
|
paneLayout: state.paneLayout,
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
sidebarCollapsed: state.sidebarCollapsed,
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
_metadata: {
|
||||||
|
contextId,
|
||||||
|
capturedAt: Date.now(),
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Slice Creator
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const createContextSlice: StateCreator<AppState, [], [], ContextSlice> = (set, get) => ({
|
||||||
|
// Initial state
|
||||||
|
activeContextId: 'local',
|
||||||
|
isContextSwitching: false,
|
||||||
|
targetContextId: null,
|
||||||
|
contextSnapshotsReady: false,
|
||||||
|
|
||||||
|
// Initialize context system (called once on app mount)
|
||||||
|
initializeContextSystem: async () => {
|
||||||
|
try {
|
||||||
|
// Check IndexedDB availability
|
||||||
|
const available = await contextStorage.isAvailable();
|
||||||
|
if (available) {
|
||||||
|
// Clean up expired snapshots
|
||||||
|
void contextStorage.cleanupExpired();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch active context from main process
|
||||||
|
const activeContextId = await window.electronAPI.context.getActive();
|
||||||
|
|
||||||
|
set({
|
||||||
|
contextSnapshotsReady: true,
|
||||||
|
activeContextId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[contextSlice] Failed to initialize context system:', error);
|
||||||
|
set({ contextSnapshotsReady: true }); // Continue anyway
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Switch to a different context
|
||||||
|
switchContext: async (targetContextId: string) => {
|
||||||
|
const state = get();
|
||||||
|
|
||||||
|
// Early return if already on target context
|
||||||
|
if (targetContextId === state.activeContextId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({
|
||||||
|
isContextSwitching: true,
|
||||||
|
targetContextId,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Capture current context's snapshot
|
||||||
|
const currentSnapshot = captureSnapshot(state, state.activeContextId);
|
||||||
|
await contextStorage.saveSnapshot(state.activeContextId, currentSnapshot);
|
||||||
|
|
||||||
|
// Step 2: Switch main process context
|
||||||
|
await window.electronAPI.context.switch(targetContextId);
|
||||||
|
|
||||||
|
// Step 3: Fetch fresh data from target context
|
||||||
|
const [freshProjects, freshRepoGroups] = await Promise.all([
|
||||||
|
window.electronAPI.getProjects(),
|
||||||
|
window.electronAPI.getRepositoryGroups(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Step 4: Attempt to restore snapshot for target context
|
||||||
|
const targetSnapshot = await contextStorage.loadSnapshot(targetContextId);
|
||||||
|
|
||||||
|
if (targetSnapshot) {
|
||||||
|
// Validate and restore snapshot
|
||||||
|
const validatedState = validateSnapshot(targetSnapshot, freshProjects, freshRepoGroups);
|
||||||
|
set(validatedState);
|
||||||
|
} else {
|
||||||
|
// No snapshot (new context or expired) - apply empty state
|
||||||
|
const emptyState = getEmptyContextState();
|
||||||
|
set({
|
||||||
|
...emptyState,
|
||||||
|
projects: freshProjects,
|
||||||
|
repositoryGroups: freshRepoGroups,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Fetch notifications in background
|
||||||
|
void get().fetchNotifications();
|
||||||
|
|
||||||
|
// Step 6: Finalize switch
|
||||||
|
set({
|
||||||
|
isContextSwitching: false,
|
||||||
|
targetContextId: null,
|
||||||
|
activeContextId: targetContextId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[contextSlice] Failed to switch context:', error);
|
||||||
|
// Do NOT leave in broken state
|
||||||
|
set({
|
||||||
|
isContextSwitching: false,
|
||||||
|
targetContextId: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue