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
This commit is contained in:
matt 2026-02-12 02:18:47 +00:00
parent fa62433219
commit ca60158d34
3 changed files with 252 additions and 0 deletions

View file

@ -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<ConnectionStatusBadgeProps>): React.JSX.Element => {
const { connectionState, connectedHost } = useStore((s) => ({
connectionState: s.connectionState,
connectedHost: s.connectedHost,
}));
// Local context always shows Monitor icon
if (contextId === 'local') {
return <Monitor className={`size-3.5 text-text-muted ${className ?? ''}`} />;
}
// 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 <Wifi className={`size-3.5 text-green-400 ${className ?? ''}`} />;
case 'connecting':
return <Loader2 className={`size-3.5 animate-spin text-text-muted ${className ?? ''}`} />;
case 'disconnected':
return <WifiOff className={`size-3.5 text-text-muted ${className ?? ''}`} />;
case 'error':
return <WifiOff className={`size-3.5 text-red-400 ${className ?? ''}`} />;
default:
return <WifiOff className={`size-3.5 text-text-muted ${className ?? ''}`} />;
}
};

View file

@ -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<HTMLDivElement>(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 (
<div ref={dropdownRef} className="relative">
{/* Trigger button */}
<button
onClick={() => !isContextSwitching && setIsOpen(!isOpen)}
disabled={isContextSwitching}
className={`flex items-center gap-1.5 rounded px-2 py-1 text-xs transition-opacity hover:opacity-80 ${isContextSwitching ? 'opacity-50' : ''}`}
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
<ConnectionStatusBadge contextId={activeContextId} />
<span
className="font-medium"
style={{ color: isContextSwitching ? 'var(--color-text-muted)' : 'var(--color-text)' }}
>
{activeLabel}
</span>
<ChevronDown
className={`size-3 transition-transform ${isOpen ? 'rotate-180' : ''}`}
style={{ color: 'var(--color-text-muted)' }}
/>
</button>
{/* Dropdown panel */}
{isOpen && !isContextSwitching && (
<>
{/* Backdrop overlay */}
<div
role="presentation"
className="fixed inset-0 z-10"
onClick={() => setIsOpen(false)}
/>
{/* Dropdown content */}
<div
className="absolute inset-x-4 top-full z-20 mt-1 max-h-[250px] overflow-y-auto rounded-lg py-1 shadow-xl"
style={{
backgroundColor: 'var(--color-surface-sidebar)',
borderWidth: '1px',
borderStyle: 'solid',
borderColor: 'var(--color-border)',
}}
>
{/* Header */}
<div
className="px-3 py-2 text-[10px] font-semibold uppercase tracking-wider"
style={{ color: 'var(--color-text-muted)' }}
>
Switch Workspace
</div>
{/* Context list */}
{availableContexts.map((ctx) => {
const isSelected = ctx.id === activeContextId;
const label = getContextLabel(ctx.id);
return (
<ContextItem
key={ctx.id}
contextId={ctx.id}
label={label}
isSelected={isSelected}
onSelect={() => {
void switchContext(ctx.id);
setIsOpen(false);
}}
/>
);
})}
</div>
</>
)}
</div>
);
};
/**
* Individual context item in the dropdown.
*/
interface ContextItemProps {
contextId: string;
label: string;
isSelected: boolean;
onSelect: () => void;
}
const ContextItem = ({
contextId,
label,
isSelected,
onSelect,
}: Readonly<ContextItemProps>): 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 (
<button
onClick={onSelect}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors"
style={buttonStyle}
>
<ConnectionStatusBadge contextId={contextId} />
<span
className="flex-1 truncate text-sm"
style={{ color: isSelected ? 'var(--color-text)' : 'var(--color-text-muted)' }}
>
{label}
</span>
{isSelected && <Check className="size-3.5 shrink-0 text-indigo-400" />}
</button>
);
};

View file

@ -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<void>;
initializeContextSystem: () => Promise<void>;
fetchAvailableContexts: () => Promise<void>;
}
// =============================================================================
@ -227,6 +230,7 @@ export const createContextSlice: StateCreator<AppState, [], [], ContextSlice> =
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<AppState, [], [], ContextSlice> =
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();