refactor(desktop): make Tauri thin shell running web sidecar (#273)
## What / Why This PR finishes the desktop refactor: the Tauri app is now a thin shell that launches the web server as a sidecar and loads the UI from `http://127.0.0.1:<port>`. All real backend logic lives in `packages/web/server/index.js`; desktop Rust keeps only native integrations (menu/dialog/notifications/updater/deep-link + window chrome). This unblocks: - consistent behavior across web/desktop/vscode (single backend) - simpler desktop maintenance (no duplicated Rust backend) - host switching between Local + remote instances in desktop - reliable cold-start behavior on slow machines (VSCode + desktop) ## Key changes - Desktop sidecar runtime - build pipeline to bundle web dist + `openchamber-server` sidecar (`packages/desktop/scripts/build-sidecar.mjs`) - robust local port selection (prefer saved/default, fallback to random; persisted in `~/.config/openchamber/settings.json`) - improved PATH handling so the sidecar can locate `opencode` CLI (incl `~/.opencode/bin`, overrides, common bins) - disable native right-click context menu in production builds (dev keeps it) - Desktop instance switcher (Tauri-only) - header button + modal to add/edit/delete remote hosts, set default, probe status/ping, switch back to Local escape hatch - auth gate includes host switcher so you can recover when a remote host is broken/auth-required - host list stored desktop-locally (not tied to the currently selected remote server) - Notifications - decision logic moved server-side; desktop notifications emitted via sidecar stdout and shown natively by Tauri - prevent double-notifications on desktop Local origin (UI ignores SSE notification when native path is active) - restore macOS notification sound - Updates - Tauri updater used only when viewing Local instance in desktop shell (avoid “remote web update” triggering desktop restart) - Settings persistence & UX polish - persist model favorites/recents via `/api/config/settings` (works for web + desktop; not origin-dependent) - persist per-project sidebar collapse state in `projects[].sidebarCollapsed` via `/api/config/settings` (with debounce on toggles) - macOS header sizing/traffic-lights offsets fixed (marketing macOS major injected from desktop; MultiRun header aligned) - VSCode cold-start: keep retrying provider/agent loads after connection to avoid empty UI on slow machines - misc lint/type fixes + bun.lock sync - Desktop bootstrap / resiliency - show onboarding screen when OpenCode CLI is missing (desktop Local origin), with retry hook to restart OpenCode after install ## Testing notes - Desktop (macOS): switch Local <-> remote, set default host, verify auth gate recovery, native notifications (with sound), updater gated to Local - Web: favorites/recents + per-project collapsed state persist across reload/restart - VSCode: slow startup no longer results in missing providers/agents/models
This commit is contained in:
committed by
GitHub
parent
b733f26aed
commit
83ffb1af34
@@ -0,0 +1,704 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
RiCheckLine,
|
||||
RiCloudOffLine,
|
||||
RiEarthLine,
|
||||
RiLoader4Line,
|
||||
RiMore2Line,
|
||||
RiPencilLine,
|
||||
RiRefreshLine,
|
||||
RiServerLine,
|
||||
RiShieldKeyholeLine,
|
||||
RiStarFill,
|
||||
RiStarLine,
|
||||
RiDeleteBinLine,
|
||||
} from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isTauriShell, isDesktopShell } from '@/lib/desktop';
|
||||
import {
|
||||
desktopHostProbe,
|
||||
desktopHostsGet,
|
||||
desktopHostsSet,
|
||||
type DesktopHost,
|
||||
type HostProbeResult,
|
||||
} from '@/lib/desktopHosts';
|
||||
|
||||
const LOCAL_HOST_ID = 'local';
|
||||
|
||||
type HostStatus = {
|
||||
status: HostProbeResult['status'];
|
||||
latencyMs: number;
|
||||
};
|
||||
|
||||
const normalizeHostUrl = (raw: string): string | null => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return null;
|
||||
}
|
||||
return url.origin;
|
||||
} catch {
|
||||
// Tauri/WebKit edge: accept origin without trailing slash.
|
||||
try {
|
||||
const url = new URL(trimmed.endsWith('/') ? trimmed : `${trimmed}/`);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return null;
|
||||
}
|
||||
return url.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toNavigationUrl = (origin: string): string => {
|
||||
const trimmed = origin.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
return trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
|
||||
};
|
||||
|
||||
const getLocalOrigin = (): string => {
|
||||
if (typeof window === 'undefined') return '';
|
||||
return window.__OPENCHAMBER_LOCAL_ORIGIN__ || window.location.origin;
|
||||
};
|
||||
|
||||
const makeId = (): string => {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `host-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
};
|
||||
|
||||
const statusDotClass = (status: HostProbeResult['status'] | null): string => {
|
||||
if (status === 'ok') return 'bg-status-success';
|
||||
if (status === 'auth') return 'bg-status-warning';
|
||||
if (status === 'unreachable') return 'bg-status-error';
|
||||
return 'bg-muted-foreground/40';
|
||||
};
|
||||
|
||||
const statusLabel = (status: HostProbeResult['status'] | null): string => {
|
||||
if (status === 'ok') return 'Connected';
|
||||
if (status === 'auth') return 'Auth required';
|
||||
if (status === 'unreachable') return 'Unreachable';
|
||||
return 'Unknown';
|
||||
};
|
||||
|
||||
const statusIcon = (status: HostProbeResult['status'] | null) => {
|
||||
if (status === 'ok') return <RiCheckLine className="h-4 w-4" />;
|
||||
if (status === 'auth') return <RiShieldKeyholeLine className="h-4 w-4" />;
|
||||
if (status === 'unreachable') return <RiCloudOffLine className="h-4 w-4" />;
|
||||
return <RiEarthLine className="h-4 w-4" />;
|
||||
};
|
||||
|
||||
const buildLocalHost = (): DesktopHost => ({
|
||||
id: LOCAL_HOST_ID,
|
||||
label: 'Local',
|
||||
url: getLocalOrigin(),
|
||||
});
|
||||
|
||||
const resolveCurrentHost = (hosts: DesktopHost[]) => {
|
||||
const currentOrigin = typeof window === 'undefined' ? '' : window.location.origin;
|
||||
const localOrigin = getLocalOrigin();
|
||||
const normalizedCurrent = normalizeHostUrl(currentOrigin) || currentOrigin;
|
||||
const normalizedLocal = normalizeHostUrl(localOrigin) || localOrigin;
|
||||
|
||||
if (normalizedCurrent && normalizedLocal && normalizedCurrent === normalizedLocal) {
|
||||
return { id: LOCAL_HOST_ID, label: 'Local', url: normalizedLocal };
|
||||
}
|
||||
|
||||
const match = hosts.find((h) => {
|
||||
const normalized = normalizeHostUrl(h.url);
|
||||
return normalized && normalized === normalizedCurrent;
|
||||
});
|
||||
|
||||
if (match) {
|
||||
return { id: match.id, label: match.label, url: normalizeHostUrl(match.url) || match.url };
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'custom',
|
||||
label: normalizedCurrent || 'Instance',
|
||||
url: normalizedCurrent,
|
||||
};
|
||||
};
|
||||
|
||||
type DesktopHostSwitcherDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function DesktopHostSwitcherDialog({ open, onOpenChange }: DesktopHostSwitcherDialogProps) {
|
||||
const [configHosts, setConfigHosts] = React.useState<DesktopHost[]>([]);
|
||||
const [defaultHostId, setDefaultHostId] = React.useState<string | null>(null);
|
||||
const [statusById, setStatusById] = React.useState<Record<string, HostStatus>>({});
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [isProbing, setIsProbing] = React.useState(false);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const [error, setError] = React.useState<string>('');
|
||||
|
||||
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||
const [editLabel, setEditLabel] = React.useState('');
|
||||
const [editUrl, setEditUrl] = React.useState('');
|
||||
|
||||
const [newLabel, setNewLabel] = React.useState('');
|
||||
const [newUrl, setNewUrl] = React.useState('');
|
||||
|
||||
const allHosts = React.useMemo(() => {
|
||||
const local = buildLocalHost();
|
||||
const normalizedRemote = configHosts.map((h) => ({
|
||||
...h,
|
||||
url: normalizeHostUrl(h.url) || h.url,
|
||||
}));
|
||||
return [local, ...normalizedRemote];
|
||||
}, [configHosts]);
|
||||
|
||||
const current = React.useMemo(() => resolveCurrentHost(allHosts), [allHosts]);
|
||||
const currentDefaultLabel = React.useMemo(() => {
|
||||
const id = defaultHostId || LOCAL_HOST_ID;
|
||||
return allHosts.find((h) => h.id === id)?.label || 'Local';
|
||||
}, [allHosts, defaultHostId]);
|
||||
|
||||
const persist = React.useCallback(async (nextHosts: DesktopHost[], nextDefaultHostId: string | null) => {
|
||||
if (!isTauriShell()) return;
|
||||
setIsSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
// Persist only remote hosts; Local is derived.
|
||||
const remote = nextHosts.filter((h) => h.id !== LOCAL_HOST_ID);
|
||||
await desktopHostsSet({ hosts: remote, defaultHostId: nextDefaultHostId });
|
||||
setConfigHosts(remote);
|
||||
setDefaultHostId(nextDefaultHostId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
if (!isTauriShell()) return;
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const cfg = await desktopHostsGet();
|
||||
setConfigHosts(cfg.hosts || []);
|
||||
setDefaultHostId(cfg.defaultHostId ?? null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load');
|
||||
setConfigHosts([]);
|
||||
setDefaultHostId(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const probeAll = React.useCallback(async (hosts: DesktopHost[]) => {
|
||||
if (!isTauriShell()) return;
|
||||
setIsProbing(true);
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
hosts.map(async (h) => {
|
||||
const url = normalizeHostUrl(h.url);
|
||||
if (!url) {
|
||||
return [h.id, { status: 'unreachable' as const, latencyMs: 0 } satisfies HostStatus] as const;
|
||||
}
|
||||
const res = await desktopHostProbe(url).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
|
||||
return [h.id, { status: res.status, latencyMs: res.latencyMs } satisfies HostStatus] as const;
|
||||
})
|
||||
);
|
||||
const next: Record<string, HostStatus> = {};
|
||||
for (const [id, val] of results) {
|
||||
next[id] = val;
|
||||
}
|
||||
setStatusById(next);
|
||||
} finally {
|
||||
setIsProbing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setEditingId(null);
|
||||
setEditLabel('');
|
||||
setEditUrl('');
|
||||
setNewLabel('');
|
||||
setNewUrl('');
|
||||
setError('');
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
}, [open, refresh]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
void probeAll(allHosts);
|
||||
}, [open, allHosts, probeAll]);
|
||||
|
||||
const handleSwitch = React.useCallback((host: DesktopHost) => {
|
||||
const origin = host.id === LOCAL_HOST_ID ? getLocalOrigin() : (normalizeHostUrl(host.url) || '');
|
||||
if (!origin) return;
|
||||
const target = toNavigationUrl(origin);
|
||||
|
||||
try {
|
||||
window.location.assign(target);
|
||||
} catch {
|
||||
window.location.href = target;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const beginEdit = React.useCallback((host: DesktopHost) => {
|
||||
setEditingId(host.id);
|
||||
setEditLabel(host.label);
|
||||
setEditUrl(host.url);
|
||||
setError('');
|
||||
}, []);
|
||||
|
||||
const cancelEdit = React.useCallback(() => {
|
||||
setEditingId(null);
|
||||
setEditLabel('');
|
||||
setEditUrl('');
|
||||
}, []);
|
||||
|
||||
const commitEdit = React.useCallback(async () => {
|
||||
if (!editingId) return;
|
||||
if (editingId === LOCAL_HOST_ID) {
|
||||
cancelEdit();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = normalizeHostUrl(editUrl);
|
||||
if (!url) {
|
||||
setError('Invalid URL (must be http/https)');
|
||||
return;
|
||||
}
|
||||
|
||||
const label = (editLabel || url).trim();
|
||||
const nextHosts = configHosts.map((h) => (h.id === editingId ? { ...h, label, url } : h));
|
||||
await persist(nextHosts, defaultHostId);
|
||||
cancelEdit();
|
||||
}, [cancelEdit, configHosts, defaultHostId, editLabel, editUrl, editingId, persist]);
|
||||
|
||||
const addHost = React.useCallback(async () => {
|
||||
const url = normalizeHostUrl(newUrl);
|
||||
if (!url) {
|
||||
setError('Invalid URL (must be http/https)');
|
||||
return;
|
||||
}
|
||||
const label = (newLabel || url).trim();
|
||||
const id = makeId();
|
||||
|
||||
const nextHosts = [{ id, label, url }, ...configHosts];
|
||||
await persist(nextHosts, defaultHostId);
|
||||
setNewLabel('');
|
||||
setNewUrl('');
|
||||
}, [configHosts, defaultHostId, newLabel, newUrl, persist]);
|
||||
|
||||
const deleteHost = React.useCallback(async (id: string) => {
|
||||
if (id === LOCAL_HOST_ID) return;
|
||||
const nextHosts = configHosts.filter((h) => h.id !== id);
|
||||
const nextDefault = defaultHostId === id ? LOCAL_HOST_ID : defaultHostId;
|
||||
await persist(nextHosts, nextDefault);
|
||||
}, [configHosts, defaultHostId, persist]);
|
||||
|
||||
const setDefault = React.useCallback(async (id: string) => {
|
||||
const next = id === LOCAL_HOST_ID ? LOCAL_HOST_ID : id;
|
||||
await persist(configHosts, next);
|
||||
}, [configHosts, persist]);
|
||||
|
||||
if (!isDesktopShell()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tauriAvailable = isTauriShell();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[min(42rem,calc(100vw-2rem))] max-w-none max-h-[70vh] flex flex-col overflow-hidden gap-3">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<RiServerLine className="h-5 w-5" />
|
||||
Instance
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Switch between Local and remote OpenChamber servers
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="typography-meta text-muted-foreground">Current:</span>
|
||||
<span className="typography-ui-label text-foreground truncate">{current.label}</span>
|
||||
<span className="typography-meta text-muted-foreground">Current default:</span>
|
||||
<span className="typography-ui-label text-foreground truncate">{currentDefaultLabel}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => void probeAll(allHosts)}
|
||||
disabled={!tauriAvailable || isLoading || isProbing}
|
||||
>
|
||||
<RiRefreshLine className={cn('h-4 w-4', isProbing && 'animate-spin')} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!tauriAvailable && (
|
||||
<div className="flex-shrink-0 rounded-lg border border-border/50 bg-muted/20 p-3">
|
||||
<div className="typography-meta text-muted-foreground">
|
||||
Instance switcher is limited on this page. Use Local to recover.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="space-y-1">
|
||||
{isLoading ? (
|
||||
<div className="px-2 py-2 text-muted-foreground text-sm">Loading…</div>
|
||||
) : (
|
||||
allHosts.map((host) => {
|
||||
const isLocal = host.id === LOCAL_HOST_ID;
|
||||
const isActive = host.id === current.id;
|
||||
const isDefault = (defaultHostId || LOCAL_HOST_ID) === host.id;
|
||||
const status = statusById[host.id] || null;
|
||||
const isEditing = editingId === host.id;
|
||||
const effectiveUrl = isLocal ? getLocalOrigin() : (normalizeHostUrl(host.url) || host.url);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={host.id}
|
||||
className={cn(
|
||||
'group flex items-center gap-2 px-2.5 py-2 rounded-md overflow-hidden',
|
||||
isEditing ? 'bg-interactive-hover/20' : 'hover:bg-interactive-hover/30'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex items-center gap-2 flex-1 min-w-0 text-left',
|
||||
isEditing && 'pointer-events-none opacity-70'
|
||||
)}
|
||||
onClick={() => handleSwitch(host)}
|
||||
aria-label={`Switch to ${host.label}`}
|
||||
>
|
||||
<span className={cn('h-2 w-2 rounded-full flex-shrink-0', statusDotClass(status?.status ?? null))} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className={cn('typography-ui-label truncate', isActive ? 'text-foreground' : 'text-foreground')}>
|
||||
{host.label}
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="typography-micro text-muted-foreground">Current</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1 typography-micro text-muted-foreground">
|
||||
{statusIcon(status?.status ?? null)}
|
||||
<span>
|
||||
{statusLabel(status?.status ?? null)}
|
||||
{status?.status === 'ok' && typeof status.latencyMs === 'number' ? ` · ${Math.max(0, Math.round(status.latencyMs))}ms ping` : ''}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="typography-micro text-muted-foreground truncate font-mono">
|
||||
{effectiveUrl}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Tooltip delayDuration={700}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'h-8 w-8 rounded-md inline-flex items-center justify-center hover:bg-interactive-hover transition-colors',
|
||||
isDefault
|
||||
? 'text-primary hover:text-primary/80'
|
||||
: 'text-muted-foreground/60 hover:text-primary/80',
|
||||
)}
|
||||
onClick={() => void setDefault(host.id)}
|
||||
aria-label={isDefault ? 'Default instance' : 'Set as default'}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isDefault ? <RiStarFill className="h-4 w-4" /> : <RiStarLine className="h-4 w-4" />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>
|
||||
{isDefault ? 'Default' : 'Set as default'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{!isLocal && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 w-8 rounded-md inline-flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-interactive-hover transition-colors"
|
||||
aria-label="Instance actions"
|
||||
disabled={isSaving}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<RiMore2Line className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-fit min-w-28">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
beginEdit(host);
|
||||
}}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<RiPencilLine className="h-4 w-4 mr-1" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void deleteHost(host.id);
|
||||
}}
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={isSaving}
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4 mr-1" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{isLocal && (
|
||||
<div
|
||||
className="h-8 w-8 opacity-0 pointer-events-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tauriAvailable && editingId && editingId !== LOCAL_HOST_ID && (
|
||||
<div className="flex-shrink-0 rounded-lg border border-border/50 bg-muted/20 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="typography-ui-label font-medium text-foreground">Edit instance</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={cancelEdit} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={() => void commitEdit()} disabled={isSaving}>
|
||||
{isSaving ? <RiLoader4Line className="h-4 w-4 animate-spin" /> : null}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<Input
|
||||
value={editLabel}
|
||||
onChange={(e) => setEditLabel(e.target.value)}
|
||||
placeholder="Label"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<Input
|
||||
value={editUrl}
|
||||
onChange={(e) => setEditUrl(e.target.value)}
|
||||
placeholder="https://host:port"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-shrink-0 rounded-lg border border-border/50 bg-muted/20 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="typography-ui-label font-medium text-foreground">Add instance</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => void addHost()}
|
||||
disabled={!tauriAvailable || isSaving || !newUrl.trim()}
|
||||
>
|
||||
{isSaving ? <RiLoader4Line className="h-4 w-4 animate-spin" /> : null}
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<Input
|
||||
value={newLabel}
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
placeholder="Label (optional)"
|
||||
disabled={!tauriAvailable || isSaving}
|
||||
/>
|
||||
<Input
|
||||
value={newUrl}
|
||||
onChange={(e) => setNewUrl(e.target.value)}
|
||||
placeholder="https://host:port"
|
||||
disabled={!tauriAvailable || isSaving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex-shrink-0 typography-meta text-status-error">{error}</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
type DesktopHostSwitcherButtonProps = {
|
||||
headerIconButtonClass: string;
|
||||
};
|
||||
|
||||
export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHostSwitcherButtonProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [label, setLabel] = React.useState('Local');
|
||||
const [status, setStatus] = React.useState<HostProbeResult['status'] | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isTauriShell()) return;
|
||||
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
try {
|
||||
const cfg = await desktopHostsGet();
|
||||
const local = buildLocalHost();
|
||||
const all = [local, ...(cfg.hosts || [])];
|
||||
const current = resolveCurrentHost(all);
|
||||
if (cancelled) return;
|
||||
setLabel(current.label || 'Instance');
|
||||
const normalized = normalizeHostUrl(current.url);
|
||||
if (!normalized) {
|
||||
setStatus(null);
|
||||
return;
|
||||
}
|
||||
const res = await desktopHostProbe(normalized).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
|
||||
if (cancelled) return;
|
||||
setStatus(res.status);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setLabel('Instance');
|
||||
setStatus(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
const interval = window.setInterval(() => {
|
||||
void run();
|
||||
}, 10_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isDesktopShell()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isCurrentlyLocal = (() => {
|
||||
try {
|
||||
const current = normalizeHostUrl(window.location.origin);
|
||||
const local = normalizeHostUrl(getLocalOrigin());
|
||||
return Boolean(current && local && current === local);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
// Fallback label when Tauri IPC is temporarily unavailable.
|
||||
const fallbackLabel = (() => {
|
||||
try {
|
||||
const host = typeof window !== 'undefined' ? window.location.hostname : '';
|
||||
return host ? host : 'Instance';
|
||||
} catch {
|
||||
return 'Instance';
|
||||
}
|
||||
})();
|
||||
|
||||
const effectiveLabel = isCurrentlyLocal
|
||||
? 'Local'
|
||||
: label === 'Local'
|
||||
? fallbackLabel
|
||||
: label;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="Switch instance"
|
||||
data-oc-host-switcher
|
||||
className={cn(headerIconButtonClass, 'relative w-auto px-3')}
|
||||
>
|
||||
<RiServerLine className="h-5 w-5" />
|
||||
<span className="hidden sm:inline typography-ui-label font-medium text-muted-foreground truncate max-w-[11rem]">
|
||||
{effectiveLabel}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'pointer-events-none absolute top-1.5 right-1.5 h-1.5 w-1.5 rounded-full',
|
||||
statusDotClass(status)
|
||||
)}
|
||||
aria-label="Instance status"
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Instance</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DesktopHostSwitcherDialog open={open} onOpenChange={setOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function DesktopHostSwitcherInline() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
if (!isDesktopShell()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-oc-host-switcher
|
||||
className="w-full justify-center"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<RiServerLine className="h-4 w-4" />
|
||||
Switch instance
|
||||
</Button>
|
||||
<DesktopHostSwitcherDialog open={open} onOpenChange={setOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user