363 lines · 13.7 KB · TypeScript
RawDownload
/** * Store index - combines all slices and exports the unified store. */ import { api } from '@renderer/api';import { create } from 'zustand'; import { createConfigSlice } from './slices/configSlice';import { createConnectionSlice } from './slices/connectionSlice';import { createContextSlice } from './slices/contextSlice';import { createConversationSlice } from './slices/conversationSlice';import { createNotificationSlice } from './slices/notificationSlice';import { createPaneSlice } from './slices/paneSlice';import { createProjectSlice } from './slices/projectSlice';import { createRepositorySlice } from './slices/repositorySlice';import { createSessionDetailSlice } from './slices/sessionDetailSlice';import { createSessionSlice } from './slices/sessionSlice';import { createSubagentSlice } from './slices/subagentSlice';import { createTabSlice } from './slices/tabSlice';import { createTabUISlice } from './slices/tabUISlice';import { createUISlice } from './slices/uiSlice';import { createUpdateSlice } from './slices/updateSlice'; import type { DetectedError } from '../types/data';import type { AppState } from './types';import type { UpdaterStatus } from '@shared/types'; // =============================================================================// Store Creation// ============================================================================= export const useStore = create<AppState>()((...args) => ({  ...createProjectSlice(...args),  ...createRepositorySlice(...args),  ...createSessionSlice(...args),  ...createSessionDetailSlice(...args),  ...createSubagentSlice(...args),  ...createConversationSlice(...args),  ...createTabSlice(...args),  ...createTabUISlice(...args),  ...createPaneSlice(...args),  ...createUISlice(...args),  ...createNotificationSlice(...args),  ...createConfigSlice(...args),  ...createConnectionSlice(...args),  ...createContextSlice(...args),  ...createUpdateSlice(...args),})); // =============================================================================// Re-exports// ============================================================================= // =============================================================================// Store Initialization - Subscribe to IPC Events// ============================================================================= /** * Initialize notification event listeners and fetch initial notification count. * Call this once when the app starts (e.g., in App.tsx useEffect). */export function initializeNotificationListeners(): () => void {  const cleanupFns: (() => void)[] = [];  const pendingSessionRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();  const pendingProjectRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();  const SESSION_REFRESH_DEBOUNCE_MS = 150;  const PROJECT_REFRESH_DEBOUNCE_MS = 300;  const getBaseProjectId = (projectId: string | null | undefined): string | null => {    if (!projectId) return null;    const separatorIndex = projectId.indexOf('::');    return separatorIndex >= 0 ? projectId.slice(0, separatorIndex) : projectId;  };   const scheduleSessionRefresh = (projectId: string, sessionId: string): void => {    const key = `${projectId}/${sessionId}`;    // Throttle (not trailing debounce): keep at most one pending refresh per session.    // Debounce can delay updates indefinitely while the file is continuously appended.    if (pendingSessionRefreshTimers.has(key)) {      return;    }    const timer = setTimeout(() => {      pendingSessionRefreshTimers.delete(key);      const state = useStore.getState();      void state.refreshSessionInPlace(projectId, sessionId);    }, SESSION_REFRESH_DEBOUNCE_MS);    pendingSessionRefreshTimers.set(key, timer);  };   const scheduleProjectRefresh = (projectId: string): void => {    const existingTimer = pendingProjectRefreshTimers.get(projectId);    if (existingTimer) {      clearTimeout(existingTimer);    }    const timer = setTimeout(() => {      pendingProjectRefreshTimers.delete(projectId);      const state = useStore.getState();      void state.refreshSessionsInPlace(projectId);    }, PROJECT_REFRESH_DEBOUNCE_MS);    pendingProjectRefreshTimers.set(projectId, timer);  };   // Listen for new notifications from main process  if (api.notifications?.onNew) {    const cleanup = api.notifications.onNew((_event: unknown, error: unknown) => {      // Cast the error to DetectedError type      const notification = error as DetectedError;      if (notification?.id) {        // Keep list in sync immediately; unread count is synced via notification:updated/fetch.        useStore.setState((state) => {          if (state.notifications.some((n) => n.id === notification.id)) {            return {};          }          return { notifications: [notification, ...state.notifications].slice(0, 200) };        });      }    });    if (typeof cleanup === 'function') {      cleanupFns.push(cleanup);    }  }   // Listen for notification updates from main process  if (api.notifications?.onUpdated) {    const cleanup = api.notifications.onUpdated(      (_event: unknown, payload: { total: number; unreadCount: number }) => {        const unreadCount =          typeof payload.unreadCount === 'number' && Number.isFinite(payload.unreadCount)            ? Math.max(0, Math.floor(payload.unreadCount))            : 0;        useStore.setState({ unreadCount });      }    );    if (typeof cleanup === 'function') {      cleanupFns.push(cleanup);    }  }   // Navigate to error when user clicks a native OS notification  if (api.notifications?.onClicked) {    const cleanup = api.notifications.onClicked((_event: unknown, data: unknown) => {      const error = data as DetectedError;      if (error?.id && error?.sessionId && error?.projectId) {        useStore.getState().navigateToError(error);      }    });    if (typeof cleanup === 'function') {      cleanupFns.push(cleanup);    }  }   // Fetch after listeners are attached so startup events do not get overwritten by a stale response.  void useStore.getState().fetchNotifications();   /**   * Check if a session is visible in any pane (not just the focused pane's active tab).   * This ensures file change and task-list listeners refresh sessions shown in any split pane.   */  const isSessionVisibleInAnyPane = (sessionId: string): boolean => {    const { paneLayout } = useStore.getState();    return paneLayout.panes.some(      (pane) =>        pane.activeTabId != null &&        pane.tabs.some(          (tab) =>            tab.id === pane.activeTabId && tab.type === 'session' && tab.sessionId === sessionId        )    );  };   // Listen for task-list file changes to refresh currently viewed session metadata  if (api.onTodoChange) {    const cleanup = api.onTodoChange((event) => {      if (!event.sessionId || event.type === 'unlink') {        return;      }       const state = useStore.getState();      const isViewingSession =        state.selectedSessionId === event.sessionId || isSessionVisibleInAnyPane(event.sessionId);       if (isViewingSession) {        // Find the project ID from any pane's tab that shows this session        const allTabs = state.getAllPaneTabs();        const sessionTab = allTabs.find(          (t) => t.type === 'session' && t.sessionId === event.sessionId        );        if (sessionTab?.projectId) {          scheduleSessionRefresh(sessionTab.projectId, event.sessionId);        }      }       // Refresh project sessions list if applicable      const activeTab = state.getActiveTab();      const activeProjectId =        activeTab?.type === 'session' && typeof activeTab.projectId === 'string'          ? activeTab.projectId          : null;      if (activeProjectId && activeProjectId === state.selectedProjectId) {        scheduleProjectRefresh(activeProjectId);      }    });    if (typeof cleanup === 'function') {      cleanupFns.push(cleanup);    }  }   // Listen for file changes to auto-refresh current session and detect new sessions  if (api.onFileChange) {    const cleanup = api.onFileChange((event) => {      // Skip unlink events      if (event.type === 'unlink') {        return;      }       const state = useStore.getState();      const selectedProjectId = state.selectedProjectId;      const selectedProjectBaseId = getBaseProjectId(selectedProjectId);      const eventProjectBaseId = getBaseProjectId(event.projectId);      const matchesSelectedProject =        !!selectedProjectId &&        (eventProjectBaseId == null || selectedProjectBaseId === eventProjectBaseId);      const isTopLevelSessionEvent = !event.isSubagent;      const isUnknownSessionInSidebar =        event.sessionId == null ||        !state.sessions.some((session) => session.id === event.sessionId);      const shouldRefreshForPotentialNewSession =        isTopLevelSessionEvent &&        matchesSelectedProject &&        isUnknownSessionInSidebar &&        (event.type === 'add' || (state.connectionMode === 'local' && event.type === 'change'));       // Refresh sidebar session list only when a truly new top-level session appears.      // Local fs.watch can report "change" before/without "add" for newly created files.      if (shouldRefreshForPotentialNewSession) {        if (matchesSelectedProject && selectedProjectId) {          scheduleProjectRefresh(selectedProjectId);        }      }       // Keep opened session view in sync on content changes.      // Some local writers emit rename/add for in-place updates, so include "add".      if ((event.type === 'change' || event.type === 'add') && selectedProjectId) {        const activeSessionId = state.selectedSessionId;        const eventSessionId = event.sessionId;        const isViewingEventSession =          !!eventSessionId &&          (activeSessionId === eventSessionId || isSessionVisibleInAnyPane(eventSessionId));        const shouldFallbackRefreshActiveSession =          matchesSelectedProject && !eventSessionId && !!activeSessionId;        const sessionIdToRefresh =          (isViewingEventSession ? eventSessionId : null) ??          (shouldFallbackRefreshActiveSession ? activeSessionId : null);         if (sessionIdToRefresh) {          const allTabs = state.getAllPaneTabs();          const visibleSessionTab = allTabs.find(            (tab) => tab.type === 'session' && tab.sessionId === sessionIdToRefresh          );          const refreshProjectId = visibleSessionTab?.projectId ?? selectedProjectId;           // Use refreshSessionInPlace to avoid flickering and preserve UI state          scheduleSessionRefresh(refreshProjectId, sessionIdToRefresh);        }      }    });    if (typeof cleanup === 'function') {      cleanupFns.push(cleanup);    }  }   // Listen for updater status events from main process  if (api.updater?.onStatus) {    const cleanup = api.updater.onStatus((_event: unknown, status: unknown) => {      const s = status as UpdaterStatus;      switch (s.type) {        case 'checking':          useStore.setState({ updateStatus: 'checking' });          break;        case 'available':          useStore.setState({            updateStatus: 'available',            availableVersion: s.version ?? null,            releaseNotes: s.releaseNotes ?? null,            showUpdateDialog: true,          });          break;        case 'not-available':          useStore.setState({ updateStatus: 'not-available' });          break;        case 'downloading':          useStore.setState({            updateStatus: 'downloading',            downloadProgress: s.progress?.percent ?? 0,          });          break;        case 'downloaded':          useStore.setState({            updateStatus: 'downloaded',            downloadProgress: 100,            availableVersion: s.version ?? useStore.getState().availableVersion,          });          break;        case 'error':          useStore.setState({            updateStatus: 'error',            updateError: s.error ?? 'Unknown error',          });          break;      }    });    if (typeof cleanup === 'function') {      cleanupFns.push(cleanup);    }  }   // Listen for SSH connection status changes from main process  // NOTE: Only syncs connection status here. Data fetching is handled by  // connectionSlice.connectSsh/disconnectSsh and contextSlice.switchContext.  if (api.ssh?.onStatus) {    const cleanup = api.ssh.onStatus((_event: unknown, status: unknown) => {      const s = status as { state: string; host: string | null; error: string | null };      useStore        .getState()        .setConnectionStatus(          s.state as 'disconnected' | 'connecting' | 'connected' | 'error',          s.host,          s.error        );    });    if (typeof cleanup === 'function') {      cleanupFns.push(cleanup);    }  }   // Listen for context changes from main process (e.g., SSH disconnect)  if (api.context?.onChanged) {    const cleanup = api.context.onChanged((_event: unknown, data: unknown) => {      const { id } = data as { id: string; type: string };      const currentContextId = useStore.getState().activeContextId;      if (id !== currentContextId) {        // Main process switched context externally (e.g., SSH disconnect)        // Trigger renderer-side context switch to sync state        void useStore.getState().switchContext(id);      }    });    if (typeof cleanup === 'function') {      cleanupFns.push(cleanup);    }  }   // Return cleanup function  return () => {    for (const timer of pendingSessionRefreshTimers.values()) {      clearTimeout(timer);    }    pendingSessionRefreshTimers.clear();    for (const timer of pendingProjectRefreshTimers.values()) {      clearTimeout(timer);    }    pendingProjectRefreshTimers.clear();    cleanupFns.forEach((fn) => fn());  };}