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",
|
||||
"date-fns": "^3.6.0",
|
||||
"electron-updater": "^6.7.3",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mdast-util-to-hast": "^13.2.1",
|
||||
"react": "^18.3.1",
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ importers:
|
|||
electron-updater:
|
||||
specifier: ^6.7.3
|
||||
version: 6.7.3
|
||||
idb-keyval:
|
||||
specifier: ^6.2.2
|
||||
version: 6.2.2
|
||||
lucide-react:
|
||||
specifier: ^0.562.0
|
||||
version: 0.562.0(react@18.3.1)
|
||||
|
|
@ -2414,6 +2417,9 @@ packages:
|
|||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
idb-keyval@6.2.2:
|
||||
resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==}
|
||||
|
||||
ieee754@1.2.1:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
|
||||
|
|
@ -6577,6 +6583,8 @@ snapshots:
|
|||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
idb-keyval@6.2.2: {}
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
|
||||
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