- Added member color assignment using a new utility function for better visual representation in the UI. - Updated team member prompts to encourage brief introductions, aligning with project guidelines. - Introduced a draft persistence mechanism for message and task creation dialogs to enhance user experience. - Refactored team configuration handling to support new member structures and improve data integrity. This update aims to streamline team interactions and improve the overall user experience in team management features.
393 lines
14 KiB
TypeScript
393 lines
14 KiB
TypeScript
/**
|
|
* 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 { api } from '@renderer/api';
|
|
import { contextStorage } from '@renderer/services/contextStorage';
|
|
import { draftStorage } from '@renderer/services/draftStorage';
|
|
|
|
import { getFullResetState } from '../utils/stateResetHelpers';
|
|
|
|
import type { AppState } from '../types';
|
|
import type { ContextSnapshot } from '@renderer/services/contextStorage';
|
|
import type { Project, RepositoryGroup } from '@renderer/types/data';
|
|
import type { Pane } from '@renderer/types/panes';
|
|
import type { ContextInfo } from '@shared/types/api';
|
|
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
|
|
availableContexts: ContextInfo[]; // list of all available contexts (local + SSH)
|
|
|
|
// Actions
|
|
switchContext: (targetContextId: string) => Promise<void>;
|
|
initializeContextSystem: () => Promise<void>;
|
|
fetchAvailableContexts: () => 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,
|
|
availableContexts: [{ id: 'local', type: 'local' as const }],
|
|
|
|
// 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();
|
|
void draftStorage.cleanupExpired();
|
|
}
|
|
|
|
// Fetch active context from main process
|
|
const activeContextId = await api.context.getActive();
|
|
|
|
set({
|
|
contextSnapshotsReady: true,
|
|
activeContextId,
|
|
});
|
|
|
|
// Fetch available contexts
|
|
await get().fetchAvailableContexts();
|
|
} catch (error) {
|
|
console.error('[contextSlice] Failed to initialize context system:', error);
|
|
set({ contextSnapshotsReady: true }); // Continue anyway
|
|
}
|
|
},
|
|
|
|
// Fetch list of available contexts (local + SSH)
|
|
fetchAvailableContexts: async () => {
|
|
try {
|
|
const result = await api.context.list();
|
|
set({ availableContexts: result });
|
|
} catch (error) {
|
|
console.error('[contextSlice] Failed to fetch available contexts:', error);
|
|
// Fallback to local-only
|
|
set({ availableContexts: [{ id: 'local', type: 'local' }] });
|
|
}
|
|
},
|
|
|
|
// Switch to a different context
|
|
switchContext: async (targetContextId: string) => {
|
|
const state = get();
|
|
|
|
// Early return if already on target context
|
|
if (targetContextId === state.activeContextId) {
|
|
return;
|
|
}
|
|
|
|
// Re-entrancy guard: prevent concurrent switch races from overlapping events
|
|
if (state.isContextSwitching) {
|
|
return;
|
|
}
|
|
|
|
set({
|
|
isContextSwitching: true,
|
|
targetContextId,
|
|
});
|
|
|
|
try {
|
|
// Step 1: Save current snapshot + load target snapshot + switch main process
|
|
// These are independent — run in parallel for speed
|
|
const currentSnapshot = captureSnapshot(state, state.activeContextId);
|
|
const [, targetSnapshot] = await Promise.all([
|
|
contextStorage.saveSnapshot(state.activeContextId, currentSnapshot),
|
|
contextStorage.loadSnapshot(targetContextId),
|
|
api.context.switch(targetContextId),
|
|
]);
|
|
|
|
// Step 2: Apply cached snapshot immediately for instant visual feedback
|
|
if (targetSnapshot) {
|
|
set({
|
|
projects: targetSnapshot.projects,
|
|
repositoryGroups: targetSnapshot.repositoryGroups,
|
|
selectedProjectId: targetSnapshot.selectedProjectId,
|
|
selectedRepositoryId: targetSnapshot.selectedRepositoryId,
|
|
selectedWorktreeId: targetSnapshot.selectedWorktreeId,
|
|
viewMode: targetSnapshot.viewMode,
|
|
sessions: targetSnapshot.sessions,
|
|
selectedSessionId: targetSnapshot.selectedSessionId,
|
|
sessionsCursor: targetSnapshot.sessionsCursor,
|
|
sessionsHasMore: targetSnapshot.sessionsHasMore,
|
|
sessionsTotalCount: targetSnapshot.sessionsTotalCount,
|
|
pinnedSessionIds: targetSnapshot.pinnedSessionIds,
|
|
notifications: targetSnapshot.notifications,
|
|
unreadCount: targetSnapshot.unreadCount,
|
|
openTabs: targetSnapshot.openTabs,
|
|
activeTabId: targetSnapshot.activeTabId,
|
|
selectedTabIds: targetSnapshot.selectedTabIds,
|
|
activeProjectId: targetSnapshot.activeProjectId,
|
|
paneLayout: targetSnapshot.paneLayout,
|
|
sidebarCollapsed: targetSnapshot.sidebarCollapsed,
|
|
// Finalize switch — overlay disappears, user sees cached data instantly
|
|
activeContextId: targetContextId,
|
|
isContextSwitching: false,
|
|
targetContextId: null,
|
|
});
|
|
}
|
|
|
|
// Step 3: Fetch fresh data in background (slow over SSH)
|
|
// Wrapped in try/catch so fetch failures don't wipe valid snapshot data.
|
|
// IPC handlers return [] on SSH scan failure — we must guard against that.
|
|
try {
|
|
const [freshProjects, freshRepoGroups] = await Promise.all([
|
|
api.getProjects(),
|
|
api.getRepositoryGroups(),
|
|
]);
|
|
|
|
if (targetSnapshot) {
|
|
// Guard: don't overwrite snapshot data if fetch returned empty
|
|
// (likely transient SSH scan failure, not genuinely empty workspace)
|
|
const snapshotHadData =
|
|
targetSnapshot.projects.length > 0 || targetSnapshot.repositoryGroups.length > 0;
|
|
const freshIsEmpty = freshProjects.length === 0 && freshRepoGroups.length === 0;
|
|
|
|
if (snapshotHadData && freshIsEmpty) {
|
|
console.warn(
|
|
'[contextSlice] Background fetch returned empty but snapshot had data — keeping snapshot'
|
|
);
|
|
} else {
|
|
set(validateSnapshot(targetSnapshot, freshProjects, freshRepoGroups));
|
|
}
|
|
} else {
|
|
// No cache (first visit) — apply empty state with fresh data
|
|
set({
|
|
...getEmptyContextState(),
|
|
projects: freshProjects,
|
|
repositoryGroups: freshRepoGroups,
|
|
activeContextId: targetContextId,
|
|
isContextSwitching: false,
|
|
targetContextId: null,
|
|
});
|
|
}
|
|
} catch (fetchError) {
|
|
console.error('[contextSlice] Background data refresh failed:', fetchError);
|
|
// Keep snapshot data as fallback — don't wipe user's view
|
|
if (!targetSnapshot) {
|
|
// No snapshot and fetch failed — finalize switch with empty state
|
|
set({
|
|
...getEmptyContextState(),
|
|
activeContextId: targetContextId,
|
|
isContextSwitching: false,
|
|
targetContextId: null,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Step 4: Fetch notifications in background
|
|
void get().fetchNotifications();
|
|
} catch (error) {
|
|
console.error('[contextSlice] Failed to switch context:', error);
|
|
// Do NOT leave in broken state
|
|
set({
|
|
isContextSwitching: false,
|
|
targetContextId: null,
|
|
});
|
|
}
|
|
},
|
|
});
|