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 { DetectedError, Project, RepositoryGroup } from '@renderer/types/data';
|
||||||
import type { Pane } from '@renderer/types/panes';
|
import type { Pane } from '@renderer/types/panes';
|
||||||
import type { Tab } from '@renderer/types/tabs';
|
import type { Tab } from '@renderer/types/tabs';
|
||||||
|
import type { ContextInfo } from '@shared/types/api';
|
||||||
import type { StateCreator } from 'zustand';
|
import type { StateCreator } from 'zustand';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -26,10 +27,12 @@ export interface ContextSlice {
|
||||||
isContextSwitching: boolean; // true during switch transition
|
isContextSwitching: boolean; // true during switch transition
|
||||||
targetContextId: string | null; // context being switched to
|
targetContextId: string | null; // context being switched to
|
||||||
contextSnapshotsReady: boolean; // true after initial IndexedDB check
|
contextSnapshotsReady: boolean; // true after initial IndexedDB check
|
||||||
|
availableContexts: ContextInfo[]; // list of all available contexts (local + SSH)
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
switchContext: (targetContextId: string) => Promise<void>;
|
switchContext: (targetContextId: string) => Promise<void>;
|
||||||
initializeContextSystem: () => Promise<void>;
|
initializeContextSystem: () => Promise<void>;
|
||||||
|
fetchAvailableContexts: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -227,6 +230,7 @@ export const createContextSlice: StateCreator<AppState, [], [], ContextSlice> =
|
||||||
isContextSwitching: false,
|
isContextSwitching: false,
|
||||||
targetContextId: null,
|
targetContextId: null,
|
||||||
contextSnapshotsReady: false,
|
contextSnapshotsReady: false,
|
||||||
|
availableContexts: [{ id: 'local', type: 'local' as const }],
|
||||||
|
|
||||||
// Initialize context system (called once on app mount)
|
// Initialize context system (called once on app mount)
|
||||||
initializeContextSystem: async () => {
|
initializeContextSystem: async () => {
|
||||||
|
|
@ -245,12 +249,27 @@ export const createContextSlice: StateCreator<AppState, [], [], ContextSlice> =
|
||||||
contextSnapshotsReady: true,
|
contextSnapshotsReady: true,
|
||||||
activeContextId,
|
activeContextId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch available contexts
|
||||||
|
await get().fetchAvailableContexts();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[contextSlice] Failed to initialize context system:', error);
|
console.error('[contextSlice] Failed to initialize context system:', error);
|
||||||
set({ contextSnapshotsReady: true }); // Continue anyway
|
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
|
// Switch to a different context
|
||||||
switchContext: async (targetContextId: string) => {
|
switchContext: async (targetContextId: string) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue