From f129715dc8de4e64ec48a46f730ef4bb04b6fd7e Mon Sep 17 00:00:00 2001 From: matt Date: Thu, 12 Feb 2026 01:35:29 +0000 Subject: [PATCH] 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 --- package.json | 1 + pnpm-lock.yaml | 8 + src/renderer/services/contextStorage.ts | 201 ++++++++++++++ src/renderer/store/slices/contextSlice.ts | 317 ++++++++++++++++++++++ 4 files changed, 527 insertions(+) create mode 100644 src/renderer/services/contextStorage.ts create mode 100644 src/renderer/store/slices/contextSlice.ts diff --git a/package.json b/package.json index b27fb54e..87709f3c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f95cc56..4d077f17 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/renderer/services/contextStorage.ts b/src/renderer/services/contextStorage.ts new file mode 100644 index 00000000..aaf37750 --- /dev/null +++ b/src/renderer/services/contextStorage.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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, +}; diff --git a/src/renderer/store/slices/contextSlice.ts b/src/renderer/store/slices/contextSlice.ts new file mode 100644 index 00000000..1493b0c3 --- /dev/null +++ b/src/renderer/store/slices/contextSlice.ts @@ -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; + initializeContextSystem: () => Promise; +} + +// ============================================================================= +// Helpers +// ============================================================================= + +/** + * Get empty context state for fresh contexts. + * Returns state with empty arrays, null selections, and default dashboard tab. + */ +function getEmptyContextState(): Partial { + 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 { + 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 = (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, + }); + } + }, +});