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:
matt 2026-02-12 01:35:29 +00:00
parent 5b31306b20
commit f129715dc8
4 changed files with 527 additions and 0 deletions

View file

@ -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",

View file

@ -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: {}

View 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,
};

View 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,
});
}
},
});