From ca60158d34c6da7a0ec8d4a32e6ebe7ffd4d8c70 Mon Sep 17 00:00:00 2001 From: matt Date: Thu, 12 Feb 2026 02:18:47 +0000 Subject: [PATCH] feat(04-01): add ContextSwitcher and ConnectionStatusBadge components - Add ConnectionStatusBadge with 4 visual states (Monitor/Wifi/WifiOff/Spinner) - Add ContextSwitcher dropdown listing Local + SSH contexts with status badges - Add availableContexts state to contextSlice - Add fetchAvailableContexts action called on system initialization - ContextSwitcher follows SidebarHeader dropdown pattern (outside click, escape key) - Switcher disabled during context switch to prevent race conditions --- .../common/ConnectionStatusBadge.tsx | 53 ++++++ .../components/common/ContextSwitcher.tsx | 180 ++++++++++++++++++ src/renderer/store/slices/contextSlice.ts | 19 ++ 3 files changed, 252 insertions(+) create mode 100644 src/renderer/components/common/ConnectionStatusBadge.tsx create mode 100644 src/renderer/components/common/ContextSwitcher.tsx diff --git a/src/renderer/components/common/ConnectionStatusBadge.tsx b/src/renderer/components/common/ConnectionStatusBadge.tsx new file mode 100644 index 00000000..77a10373 --- /dev/null +++ b/src/renderer/components/common/ConnectionStatusBadge.tsx @@ -0,0 +1,53 @@ +/** + * ConnectionStatusBadge - Visual indicator for workspace connection status. + * + * Renders appropriate icon based on connection state: + * - Local: Monitor icon (muted) + * - SSH connected: Wifi icon (green) + * - SSH connecting: Animated spinner (muted) + * - SSH disconnected: WifiOff icon (muted) + * - SSH error: WifiOff icon (red) + */ + +import { useStore } from '@renderer/store'; +import { Loader2, Monitor, Wifi, WifiOff } from 'lucide-react'; + +interface ConnectionStatusBadgeProps { + contextId: string; + className?: string; +} + +export const ConnectionStatusBadge = ({ + contextId, + className, +}: Readonly): React.JSX.Element => { + const { connectionState, connectedHost } = useStore((s) => ({ + connectionState: s.connectionState, + connectedHost: s.connectedHost, + })); + + // Local context always shows Monitor icon + if (contextId === 'local') { + return ; + } + + // SSH context - determine if this specific SSH context matches connected host + const isConnectedToThisHost = contextId === `ssh-${connectedHost}`; + + // If this SSH context doesn't match the connected host, treat as disconnected + const effectiveState = isConnectedToThisHost ? connectionState : 'disconnected'; + + // Render icon based on connection state + switch (effectiveState) { + case 'connected': + return ; + case 'connecting': + return ; + case 'disconnected': + return ; + case 'error': + return ; + default: + return ; + } +}; diff --git a/src/renderer/components/common/ContextSwitcher.tsx b/src/renderer/components/common/ContextSwitcher.tsx new file mode 100644 index 00000000..f58c5e3e --- /dev/null +++ b/src/renderer/components/common/ContextSwitcher.tsx @@ -0,0 +1,180 @@ +/** + * ContextSwitcher - Workspace context dropdown in SidebarHeader. + * + * Displays active context (Local or SSH host) with connection status badge. + * Dropdown lists all available contexts with click-to-switch functionality. + */ + +import { useEffect, useRef, useState } from 'react'; + +import { useStore } from '@renderer/store'; +import { Check, ChevronDown } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; + +import { ConnectionStatusBadge } from './ConnectionStatusBadge'; + +export const ContextSwitcher = (): React.JSX.Element => { + const { activeContextId, isContextSwitching, availableContexts, switchContext } = useStore( + useShallow((s) => ({ + activeContextId: s.activeContextId, + isContextSwitching: s.isContextSwitching, + availableContexts: s.availableContexts, + switchContext: s.switchContext, + })) + ); + + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + // Close dropdown on outside click + useEffect(() => { + function handleClickOutside(event: MouseEvent): void { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Close dropdown on Escape key + useEffect(() => { + function handleEscape(event: KeyboardEvent): void { + if (event.key === 'Escape') { + setIsOpen(false); + } + } + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, []); + + // Get display label for context + const getContextLabel = (contextId: string): string => { + if (contextId === 'local') { + return 'Local'; + } + // Strip 'ssh-' prefix for display (e.g., 'ssh-192.168.1.10' -> '192.168.1.10') + return contextId.startsWith('ssh-') ? contextId.slice(4) : contextId; + }; + + const activeLabel = getContextLabel(activeContextId); + + return ( +
+ {/* Trigger button */} + + + {/* Dropdown panel */} + {isOpen && !isContextSwitching && ( + <> + {/* Backdrop overlay */} +
setIsOpen(false)} + /> + + {/* Dropdown content */} +
+ {/* Header */} +
+ Switch Workspace +
+ + {/* Context list */} + {availableContexts.map((ctx) => { + const isSelected = ctx.id === activeContextId; + const label = getContextLabel(ctx.id); + + return ( + { + void switchContext(ctx.id); + setIsOpen(false); + }} + /> + ); + })} +
+ + )} +
+ ); +}; + +/** + * Individual context item in the dropdown. + */ +interface ContextItemProps { + contextId: string; + label: string; + isSelected: boolean; + onSelect: () => void; +} + +const ContextItem = ({ + contextId, + label, + isSelected, + onSelect, +}: Readonly): React.JSX.Element => { + const [isHovered, setIsHovered] = useState(false); + + const buttonStyle: React.CSSProperties = isSelected + ? { backgroundColor: 'var(--color-surface-raised)', color: 'var(--color-text)' } + : { + backgroundColor: isHovered ? 'var(--color-surface-raised)' : 'transparent', + opacity: isHovered ? 0.5 : 1, + }; + + return ( + + ); +}; diff --git a/src/renderer/store/slices/contextSlice.ts b/src/renderer/store/slices/contextSlice.ts index 1493b0c3..2e6e8ba0 100644 --- a/src/renderer/store/slices/contextSlice.ts +++ b/src/renderer/store/slices/contextSlice.ts @@ -14,6 +14,7 @@ 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 { ContextInfo } from '@shared/types/api'; import type { StateCreator } from 'zustand'; // ============================================================================= @@ -26,10 +27,12 @@ export interface ContextSlice { 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; initializeContextSystem: () => Promise; + fetchAvailableContexts: () => Promise; } // ============================================================================= @@ -227,6 +230,7 @@ export const createContextSlice: StateCreator = isContextSwitching: false, targetContextId: null, contextSnapshotsReady: false, + availableContexts: [{ id: 'local', type: 'local' as const }], // Initialize context system (called once on app mount) initializeContextSystem: async () => { @@ -245,12 +249,27 @@ export const createContextSlice: StateCreator = 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 window.electronAPI.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();