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:
parent
fa62433219
commit
ca60158d34
3 changed files with 252 additions and 0 deletions
53
src/renderer/components/common/ConnectionStatusBadge.tsx
Normal file
53
src/renderer/components/common/ConnectionStatusBadge.tsx
Normal 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 ?? ''}`} />;
|
||||
}
|
||||
};
|
||||
180
src/renderer/components/common/ContextSwitcher.tsx
Normal file
180
src/renderer/components/common/ContextSwitcher.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue