Add i18n foundation and translations (#1027)

* feat: add i18n foundation

* feat: localize sessions sidebar

* Localize multirun/scheduled tasks and fix dialog dropdown interactions

* localize git sidebar surface and add zh-CN keys

* feat(ui): localize context panel, diff/plan views, and context sidebar content

* fix(config): resolve user config home via fs/home before embedded home

* localize header/chat UI and complete model/worktree panel strings

* localize worktree + github issue/pr dialog flows

* localize settings sections and split settings i18n dictionaries

* localize additional settings sections and sidebars

* localize more settings pages and dialogs

* fix settings select trigger localization

* localize tunnel settings ui surface

* localize additional settings sections

* localize keyboard shortcuts labels in settings

* localize terminal and utility dialogs surfaces

* feat(i18n): localize remaining UI strings

* Add Ukrainian locale

* Add Spanish locale

* Add Brazilian Portuguese locale

* Polish locale translations
This commit is contained in:
Bohdan Triapitsyn
2026-04-26 14:03:39 +03:00
committed by GitHub
parent 87db2ea210
commit 7d7285655d
198 changed files with 24173 additions and 4365 deletions
@@ -41,6 +41,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { toast } from '@/components/ui';
import { copyTextToClipboard } from '@/lib/clipboard';
import { openExternalUrl } from '@/lib/url';
import { useI18n, type I18nKey } from '@/lib/i18n';
import {
desktopSshLogsClear,
desktopSshLogs,
@@ -58,34 +59,34 @@ const isPortInUseError = (error: unknown): boolean => {
return message.includes('address already in use') || message.includes('eaddrinuse') || message.includes('port already in use');
};
const phaseLabel = (phase?: string): string => {
const phaseLabelKey = (phase?: string): I18nKey => {
switch (phase) {
case 'config_resolved':
return 'Resolving configuration';
return 'settings.remoteInstances.page.phase.resolvingConfiguration';
case 'auth_check':
return 'Checking auth';
return 'settings.remoteInstances.page.phase.checkingAuth';
case 'master_connecting':
return 'Establishing SSH';
return 'settings.remoteInstances.page.phase.establishingSsh';
case 'remote_probe':
return 'Probing remote';
return 'settings.remoteInstances.page.phase.probingRemote';
case 'installing':
return 'Installing OpenChamber';
return 'settings.remoteInstances.page.phase.installingOpenChamber';
case 'updating':
return 'Updating OpenChamber';
return 'settings.remoteInstances.page.phase.updatingOpenChamber';
case 'server_detecting':
return 'Detecting server';
return 'settings.remoteInstances.page.phase.detectingServer';
case 'server_starting':
return 'Starting server';
return 'settings.remoteInstances.page.phase.startingServer';
case 'forwarding':
return 'Forwarding ports';
return 'settings.remoteInstances.page.phase.forwardingPorts';
case 'ready':
return 'Ready';
return 'settings.remoteInstances.sidebar.phase.ready';
case 'degraded':
return 'Reconnecting';
return 'settings.remoteInstances.page.phase.reconnecting';
case 'error':
return 'Error';
return 'settings.remoteInstances.sidebar.phase.error';
default:
return 'Idle';
return 'settings.remoteInstances.sidebar.phase.idle';
}
};
@@ -161,14 +162,14 @@ const HintLabel: React.FC<{ label: string; hint: React.ReactNode }> = ({ label,
);
};
const forwardTypeDescription = (type: DesktopSshPortForwardType): string => {
const forwardTypeDescriptionKey = (type: DesktopSshPortForwardType): I18nKey => {
switch (type) {
case 'remote':
return 'Remote (-R): expose a port on the remote machine and send that traffic back to this laptop.';
return 'settings.remoteInstances.page.forwardTypeDescription.remote';
case 'dynamic':
return 'Dynamic (-D): create a local SOCKS5 proxy on this laptop (for apps that support SOCKS proxy settings).';
return 'settings.remoteInstances.page.forwardTypeDescription.dynamic';
default:
return 'Local (-L): open a port on this laptop and send it to a remote host:port over SSH (use this to access remote services locally).';
return 'settings.remoteInstances.page.forwardTypeDescription.local';
}
};
@@ -254,6 +255,7 @@ const normalizeForSave = (instance: DesktopSshInstance): DesktopSshInstance => {
};
export const RemoteInstancesPage: React.FC = () => {
const { t } = useI18n();
const instances = useDesktopSshStore((state) => state.instances);
const statusesById = useDesktopSshStore((state) => state.statusesById);
const importCandidates = useDesktopSshStore((state) => state.importCandidates);
@@ -379,13 +381,13 @@ export const RemoteInstancesPage: React.FC = () => {
const normalized = normalizeForSave(draft);
if (!normalized.sshCommand.trim()) {
toast.error('SSH command is required');
toast.error(t('settings.remoteInstances.page.toast.sshCommandRequired'));
return;
}
if (normalized.localForward.bindHost === '0.0.0.0') {
const allow = window.confirm(
'Binding local forwards to 0.0.0.0 makes the forwarded port reachable from other devices on your network. Continue?',
t('settings.remoteInstances.page.confirm.bindAllInterfaces'),
);
if (!allow) {
return;
@@ -397,7 +399,7 @@ export const RemoteInstancesPage: React.FC = () => {
normalized.auth.sshPassword.value?.trim() &&
normalized.auth.sshPassword.store !== 'settings'
) {
const store = window.confirm('Store SSH password in settings.json as plaintext?');
const store = window.confirm(t('settings.remoteInstances.page.confirm.storeSshPasswordPlaintext'));
normalized.auth.sshPassword.store = store ? 'settings' : 'never';
if (!store) {
normalized.auth.sshPassword.value = undefined;
@@ -409,7 +411,7 @@ export const RemoteInstancesPage: React.FC = () => {
normalized.auth.openchamberPassword.value?.trim() &&
normalized.auth.openchamberPassword.store !== 'settings'
) {
const store = window.confirm('Store OpenChamber UI password in settings.json as plaintext?');
const store = window.confirm(t('settings.remoteInstances.page.confirm.storeUiPasswordPlaintext'));
normalized.auth.openchamberPassword.store = store ? 'settings' : 'never';
if (!store) {
normalized.auth.openchamberPassword.value = undefined;
@@ -418,13 +420,13 @@ export const RemoteInstancesPage: React.FC = () => {
try {
await upsertInstance(normalized);
toast.success('SSH instance saved');
toast.success(t('settings.remoteInstances.page.toast.instanceSaved'));
} catch (error) {
toast.error('Failed to save SSH instance', {
toast.error(t('settings.remoteInstances.page.toast.saveFailed'), {
description: error instanceof Error ? error.message : String(error),
});
}
}, [draft, upsertInstance]);
}, [draft, t, upsertInstance]);
const createImportedInstance = React.useCallback(
async (host: string, destination: string): Promise<boolean> => {
@@ -432,10 +434,10 @@ export const RemoteInstancesPage: React.FC = () => {
try {
await createFromCommand(id, `ssh ${destination}`, host);
setSelectedId(id);
toast.success('SSH instance created');
toast.success(t('settings.remoteInstances.page.toast.instanceCreated'));
return true;
} catch (error) {
toast.error('Failed to create SSH instance', {
toast.error(t('settings.remoteInstances.sidebar.toast.createFailed'), {
description: error instanceof Error ? error.message : String(error),
});
return false;
@@ -471,7 +473,7 @@ export const RemoteInstancesPage: React.FC = () => {
return;
}
if (!destination) {
toast.error('Destination is required');
toast.error(t('settings.remoteInstances.page.toast.destinationRequired'));
return;
}
@@ -485,7 +487,7 @@ export const RemoteInstancesPage: React.FC = () => {
} finally {
setPatternCreating(false);
}
}, [createImportedInstance, patternDestination, patternHost]);
}, [createImportedInstance, patternDestination, patternHost, t]);
const connectWithPortRecovery = React.useCallback(async () => {
if (!selectedInstance) return;
@@ -497,7 +499,7 @@ export const RemoteInstancesPage: React.FC = () => {
throw error;
}
const allow = window.confirm('Local port is already in use. Pick a random free local port and retry?');
const allow = window.confirm(t('settings.remoteInstances.sidebar.confirm.localPortInUseRetry'));
if (!allow) {
throw error;
}
@@ -512,9 +514,9 @@ export const RemoteInstancesPage: React.FC = () => {
await upsertInstance(nextInstance);
await connect(nextInstance.id);
toast.success('Retried with a random local port');
toast.success(t('settings.remoteInstances.sidebar.toast.retriedWithRandomPort'));
}
}, [connect, selectedInstance, upsertInstance]);
}, [connect, selectedInstance, t, upsertInstance]);
const readLogsForInstance = React.useCallback(async (id: string) => {
const lines = await desktopSshLogs(id, 600);
@@ -576,15 +578,15 @@ export const RemoteInstancesPage: React.FC = () => {
const handleCopyAllLogs = React.useCallback(() => {
if (!logLinesText.trim()) {
toast.error('No logs to copy');
toast.error(t('settings.remoteInstances.page.toast.noLogsToCopy'));
return;
}
void copyTextToClipboard(logLinesText).then((result) => {
if (result.ok) {
toast.success('Logs copied');
toast.success(t('settings.remoteInstances.page.toast.logsCopied'));
}
});
}, [logLinesText]);
}, [logLinesText, t]);
const handleClearLogs = React.useCallback(async () => {
if (!draft) {
@@ -593,28 +595,28 @@ export const RemoteInstancesPage: React.FC = () => {
try {
await desktopSshLogsClear(draft.id);
setLogDialogLines([]);
toast.success('Logs cleared');
toast.success(t('settings.remoteInstances.page.toast.logsCleared'));
} catch (error) {
toast.error('Failed to clear logs', {
toast.error(t('settings.remoteInstances.page.toast.clearLogsFailed'), {
description: error instanceof Error ? error.message : String(error),
});
}
}, [draft]);
}, [draft, t]);
const handleOpenCurrentInstance = React.useCallback(async () => {
if (!status?.localUrl) {
toast.error('Instance URL is not available yet');
toast.error(t('settings.remoteInstances.page.toast.instanceUrlUnavailable'));
return;
}
const target = status.localUrl.trim();
if (!target) {
toast.error('Instance URL is not available yet');
toast.error(t('settings.remoteInstances.page.toast.instanceUrlUnavailable'));
return;
}
navigateToUrl(target);
}, [status?.localUrl]);
}, [status?.localUrl, t]);
const handlePrimaryConnectionAction = React.useCallback(() => {
if (!draft) {
@@ -625,15 +627,19 @@ export const RemoteInstancesPage: React.FC = () => {
const operation = canDisconnect ? disconnect(draft.id) : connectWithPortRecovery();
void operation
.catch((error) => {
const actionLabel = canDisconnect ? (isReady ? 'disconnect' : 'cancel connection') : 'connect';
toast.error(`Failed to ${actionLabel}`, {
const key = canDisconnect
? (isReady
? 'settings.remoteInstances.page.toast.disconnectFailed'
: 'settings.remoteInstances.page.toast.cancelConnectionFailed')
: 'settings.remoteInstances.page.toast.connectFailed';
toast.error(t(key), {
description: error instanceof Error ? error.message : String(error),
});
})
.finally(() => {
setIsPrimaryActionPending(false);
});
}, [canDisconnect, connectWithPortRecovery, disconnect, draft, isReady]);
}, [canDisconnect, connectWithPortRecovery, disconnect, draft, isReady, t]);
const handleRetryAction = React.useCallback(() => {
if (!draft) {
@@ -651,22 +657,22 @@ export const RemoteInstancesPage: React.FC = () => {
void operation
.catch((error) => {
toast.error('Retry failed', {
toast.error(t('settings.remoteInstances.page.toast.retryFailed'), {
description: error instanceof Error ? error.message : String(error),
});
})
.finally(() => {
setIsRetryPending(false);
});
}, [connectWithPortRecovery, disconnect, draft, isConnecting, isReconnecting, retry]);
}, [connectWithPortRecovery, disconnect, draft, isConnecting, isReconnecting, retry, t]);
const retryButtonLabel = isConnecting
? 'Connecting...'
? t('settings.remoteInstances.page.actions.connecting')
: isReconnecting
? reconnectAppearsStuck
? 'Reconnect now'
: 'Reconnecting...'
: 'Retry';
? t('settings.remoteInstances.page.actions.reconnectNow')
: t('settings.remoteInstances.page.actions.reconnecting')
: t('settings.remoteInstances.sidebar.actions.retry');
const canRetry =
!isPrimaryActionPending &&
@@ -674,30 +680,34 @@ export const RemoteInstancesPage: React.FC = () => {
(statusPhase === 'error' || statusPhase === 'idle' || !statusPhase || (isReconnecting && reconnectAppearsStuck)) &&
!isConnecting;
const primaryButtonLabel = isReady ? 'Disconnect' : canDisconnect ? 'Cancel' : 'Connect';
const primaryButtonLabel = isReady
? t('settings.remoteInstances.sidebar.actions.disconnect')
: canDisconnect
? t('settings.remoteInstances.page.actions.cancel')
: t('settings.remoteInstances.sidebar.actions.connect');
if (!draft) {
return (
<SettingsPageLayout>
<div className="mb-8">
<div className="mb-1 px-1 space-y-0.5">
<h3 className="typography-ui-header font-medium text-foreground">Remote Instances</h3>
<p className="typography-meta text-muted-foreground">Manage SSH-backed OpenChamber instances.</p>
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.title')}</h3>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.description')}</p>
</div>
<section className="px-2 pb-2 pt-0 space-y-3">
<p className="typography-meta text-muted-foreground">Select an instance from the sidebar or import one from SSH config.</p>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.empty.selectInstance')}</p>
</section>
</div>
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
<div className="mb-1 px-1 space-y-0.5">
<h3 className="typography-ui-header font-medium text-foreground">Import from SSH config</h3>
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.import.sectionTitle')}</h3>
</div>
<section className="px-2 pb-2 pt-0">
{isImportsLoading ? (
<p className="typography-meta text-muted-foreground">Loading SSH hosts...</p>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p>
) : importCandidates.length === 0 ? (
<p className="typography-meta text-muted-foreground">No SSH config hosts found.</p>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.noneFound')}</p>
) : (
<div className="space-y-2">
{importCandidates.map((candidate) => (
@@ -705,7 +715,7 @@ export const RemoteInstancesPage: React.FC = () => {
<div className="min-w-0">
<div className="typography-ui-label text-foreground truncate">
{candidate.host}
{candidate.pattern ? ' (pattern)' : ''}
{candidate.pattern ? ` ${t('settings.remoteInstances.page.import.patternSuffix')}` : ''}
</div>
<div className="typography-micro text-muted-foreground">{candidate.source} config</div>
</div>
@@ -716,7 +726,7 @@ export const RemoteInstancesPage: React.FC = () => {
className="!font-normal"
onClick={() => void handleImportCandidate(candidate.host, candidate.pattern)}
>
Create
{t('settings.remoteInstances.page.actions.create')}
</Button>
</div>
))}
@@ -735,9 +745,9 @@ export const RemoteInstancesPage: React.FC = () => {
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create from wildcard pattern</DialogTitle>
<DialogTitle>{t('settings.remoteInstances.page.patternDialog.title')}</DialogTitle>
<DialogDescription>
{patternHost ? `${patternHost} requires a concrete destination.` : 'Enter destination.'}
{patternHost ? t('settings.remoteInstances.page.patternDialog.descriptionWithHost', { host: patternHost }) : t('settings.remoteInstances.page.patternDialog.description')}
</DialogDescription>
</DialogHeader>
<form
@@ -750,15 +760,15 @@ export const RemoteInstancesPage: React.FC = () => {
<Input
value={patternDestination}
onChange={(event) => setPatternDestination(event.target.value)}
placeholder="user@host"
placeholder={t('settings.remoteInstances.page.patternDialog.destinationPlaceholder')}
autoFocus
/>
<div className="flex items-center justify-end gap-2">
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={closePatternDialog} disabled={patternCreating}>
Cancel
{t('settings.common.actions.cancel')}
</Button>
<Button type="submit" size="xs" className="!font-normal" disabled={patternCreating}>
Create
{t('settings.remoteInstances.page.actions.create')}
</Button>
</div>
</form>
@@ -777,16 +787,16 @@ export const RemoteInstancesPage: React.FC = () => {
<h2 className="typography-ui-header font-semibold text-foreground truncate">{instanceTitle}</h2>
<div className="mt-1 flex flex-wrap items-center gap-2 typography-meta text-muted-foreground">
<span className={`h-2.5 w-2.5 rounded-full ${phaseDotClass(statusPhase)}`} />
<span>{phaseLabel(statusPhase)}</span>
<span>{t(phaseLabelKey(statusPhase))}</span>
{status?.localUrl ? <span className="font-mono text-foreground/80">{status.localUrl}</span> : null}
{reconnectAppearsStuck ? <span>reconnect stale</span> : null}
{reconnectAppearsStuck ? <span>{t('settings.remoteInstances.page.status.reconnectStale')}</span> : null}
</div>
</div>
<div className="mb-8">
<div className="mb-1 px-1 space-y-0.5">
<h3 className="typography-ui-header font-medium text-foreground">Actions</h3>
<p className="typography-meta text-muted-foreground">Connect, inspect logs, and manage this instance.</p>
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.actions')}</h3>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.actionsDescription')}</p>
</div>
<section className="px-2 pb-2 pt-0 space-y-3">
<div className="flex flex-wrap items-center gap-2">
@@ -822,7 +832,7 @@ export const RemoteInstancesPage: React.FC = () => {
}}
>
<RiTerminalWindowLine className="h-3.5 w-3.5" />
Logs
{t('settings.remoteInstances.page.actions.logs')}
</Button>
<Button
type="button"
@@ -830,27 +840,27 @@ export const RemoteInstancesPage: React.FC = () => {
size="xs"
className="!font-normal text-[var(--status-error)] border-[var(--status-error)]/30 hover:text-[var(--status-error)]"
onClick={() => {
const ok = window.confirm('Remove this SSH instance?');
const ok = window.confirm(t('settings.remoteInstances.page.confirm.removeInstance'));
if (!ok) return;
void removeInstance(draft.id)
.then(() => {
setSelectedId(null);
toast.success('SSH instance removed');
toast.success(t('settings.remoteInstances.page.toast.instanceRemoved'));
})
.catch((err) => {
toast.error('Failed to remove SSH instance', {
toast.error(t('settings.remoteInstances.page.toast.removeInstanceFailed'), {
description: err instanceof Error ? err.message : String(err),
});
});
}}
>
<RiDeleteBinLine className="h-3.5 w-3.5" />
Remove
{t('settings.remoteInstances.sidebar.actions.remove')}
</Button>
</div>
{status?.localUrl ? (
<div className="flex flex-wrap items-center gap-2 typography-meta text-muted-foreground">
<span>Current local URL:</span>
<span>{t('settings.remoteInstances.page.status.currentLocalUrl')}</span>
<span className="font-mono text-foreground/90">{status.localUrl}</span>
</div>
) : null}
@@ -859,12 +869,12 @@ export const RemoteInstancesPage: React.FC = () => {
<div className="mb-8">
<div className="mb-1 px-1 space-y-0.5">
<h3 className="typography-ui-header font-medium text-foreground">Instance</h3>
<p className="typography-meta text-muted-foreground">Core SSH settings.</p>
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.instance')}</h3>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.instanceDescription')}</p>
</div>
<section className="px-2 pb-2 pt-0 space-y-3">
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<span className="typography-ui-label text-foreground w-56 shrink-0">SSH command</span>
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.sshCommand')}</span>
<Input
className="h-7 md:max-w-xl"
value={draft.sshCommand}
@@ -874,11 +884,11 @@ export const RemoteInstancesPage: React.FC = () => {
sshCommand: event.target.value,
}))
}
placeholder="ssh -J jump user@host"
placeholder={t('settings.remoteInstances.page.field.sshCommandPlaceholder')}
/>
</div>
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<span className="typography-ui-label text-foreground w-56 shrink-0">Nickname</span>
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.nickname')}</span>
<Input
className="h-7 md:max-w-sm"
value={draft.nickname || ''}
@@ -888,11 +898,11 @@ export const RemoteInstancesPage: React.FC = () => {
nickname: event.target.value,
}))
}
placeholder="Production Host"
placeholder={t('settings.remoteInstances.page.field.nicknamePlaceholder')}
/>
</div>
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<span className="typography-ui-label text-foreground w-56 shrink-0">Connection timeout (sec)</span>
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.connectionTimeoutSeconds')}</span>
<NumberInput
containerClassName="w-fit"
min={5}
@@ -913,16 +923,16 @@ export const RemoteInstancesPage: React.FC = () => {
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
<div className="mb-1 px-1 space-y-0.5">
<h3 className="typography-ui-header font-medium text-foreground">Remote server</h3>
<p className="typography-meta text-muted-foreground">How OpenChamber is discovered or started on the remote machine.</p>
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.remoteServer')}</h3>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.remoteServerDescription')}</p>
</div>
<section className="px-2 pb-2 pt-0 space-y-3">
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<div className="w-56 shrink-0">
<HintLabel
label="Mode"
hint="Managed installs/updates and starts OpenChamber remotely. External assumes it is already running."
/>
<HintLabel
label={t('settings.remoteInstances.page.field.mode')}
hint={t('settings.remoteInstances.page.field.modeHint')}
/>
</div>
<Select
value={draft.remoteOpenchamber.mode}
@@ -937,21 +947,21 @@ export const RemoteInstancesPage: React.FC = () => {
}
>
<SelectTrigger className="h-7 w-fit min-w-[140px]">
<SelectValue placeholder="Select mode" />
<SelectValue placeholder={t('settings.remoteInstances.page.field.modePlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="managed">Managed (auto start)</SelectItem>
<SelectItem value="external">External (already running)</SelectItem>
<SelectItem value="managed">{t('settings.remoteInstances.page.field.modeManaged')}</SelectItem>
<SelectItem value="external">{t('settings.remoteInstances.page.field.modeExternal')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<div className="w-56 shrink-0">
<HintLabel
label="Preferred remote port"
hint="Port OpenChamber should use on the remote host. Leave empty to let the runtime choose."
/>
<HintLabel
label={t('settings.remoteInstances.page.field.preferredRemotePort')}
hint={t('settings.remoteInstances.page.field.preferredRemotePortHint')}
/>
</div>
<NumberInput
containerClassName="w-fit"
@@ -978,7 +988,7 @@ export const RemoteInstancesPage: React.FC = () => {
},
}));
}}
emptyLabel="Auto"
emptyLabel={t('settings.remoteInstances.page.field.auto')}
/>
</div>
@@ -986,8 +996,8 @@ export const RemoteInstancesPage: React.FC = () => {
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<div className="w-56 shrink-0">
<HintLabel
label="Install method"
hint="How OpenChamber gets installed/updated remotely when mode is Managed."
label={t('settings.remoteInstances.page.field.installMethod')}
hint={t('settings.remoteInstances.page.field.installMethodHint')}
/>
</div>
<Select
@@ -1006,13 +1016,13 @@ export const RemoteInstancesPage: React.FC = () => {
}
>
<SelectTrigger className="h-7 w-fit min-w-[140px]">
<SelectValue placeholder="Select install method" />
<SelectValue placeholder={t('settings.remoteInstances.page.field.selectInstallMethodPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="bun">bun</SelectItem>
<SelectItem value="npm">npm</SelectItem>
<SelectItem value="download_release">download release</SelectItem>
<SelectItem value="upload_bundle">upload bundle</SelectItem>
<SelectItem value="download_release">{t('settings.remoteInstances.page.field.installMethodDownloadRelease')}</SelectItem>
<SelectItem value="upload_bundle">{t('settings.remoteInstances.page.field.installMethodUploadBundle')}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -1022,8 +1032,8 @@ export const RemoteInstancesPage: React.FC = () => {
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<div className="w-56 shrink-0">
<HintLabel
label="Keep server running"
hint="If enabled, OpenChamber daemon is left running remotely when you disconnect."
label={t('settings.remoteInstances.page.field.keepServerRunning')}
hint={t('settings.remoteInstances.page.field.keepServerRunningHint')}
/>
</div>
<div className="flex w-full items-center gap-2 md:max-w-xs">
@@ -1047,23 +1057,23 @@ export const RemoteInstancesPage: React.FC = () => {
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
<div className="mb-1 px-1 space-y-0.5">
<h3 className="typography-ui-header font-medium text-foreground">Main tunnel</h3>
<p className="typography-meta text-muted-foreground">Primary local URL that points to the remote OpenChamber server.</p>
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.mainTunnel')}</h3>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.mainTunnelDescription')}</p>
</div>
<section className="px-2 pb-2 pt-0 space-y-3">
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<div className="w-56 shrink-0">
<HintLabel
label="Bind host"
hint="Network interface for the main local URL. Use 127.0.0.1/localhost for local-only access."
/>
<HintLabel
label={t('settings.remoteInstances.page.field.bindHost')}
hint={t('settings.remoteInstances.page.field.bindHostHint')}
/>
</div>
<Select
value={draft.localForward.bindHost}
onValueChange={(value) => {
if (value === '0.0.0.0') {
const allow = window.confirm(
'Binding to 0.0.0.0 exposes forwarded ports to your local network. Continue?',
t('settings.remoteInstances.page.confirm.bindAllInterfaces'),
);
if (!allow) return;
}
@@ -1077,7 +1087,7 @@ export const RemoteInstancesPage: React.FC = () => {
}}
>
<SelectTrigger className="h-7 w-fit min-w-[140px]">
<SelectValue placeholder="Select bind host" />
<SelectValue placeholder={t('settings.remoteInstances.page.field.selectBindHostPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="127.0.0.1">127.0.0.1</SelectItem>
@@ -1089,10 +1099,10 @@ export const RemoteInstancesPage: React.FC = () => {
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<div className="w-56 shrink-0">
<HintLabel
label="Preferred local port"
hint="Preferred local port for the main OpenChamber tunnel. Leave empty for auto-select."
/>
<HintLabel
label={t('settings.remoteInstances.page.field.preferredLocalPort')}
hint={t('settings.remoteInstances.page.field.preferredLocalPortHint')}
/>
</div>
<div className="flex w-full items-center gap-2 md:max-w-sm">
<NumberInput
@@ -1120,14 +1130,14 @@ export const RemoteInstancesPage: React.FC = () => {
},
}));
}}
emptyLabel="Auto"
emptyLabel={t('settings.remoteInstances.page.field.auto')}
/>
<Button
type="button"
variant="outline"
size="xs"
className="!font-normal h-7 w-7 px-0"
title="Pick random port"
title={t('settings.remoteInstances.page.actions.pickRandomPort')}
onClick={() =>
updateDraft((current) => ({
...current,
@@ -1147,12 +1157,12 @@ export const RemoteInstancesPage: React.FC = () => {
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
<div className="mb-1 px-1 space-y-0.5">
<h3 className="typography-ui-header font-medium text-foreground">Authentication</h3>
<p className="typography-meta text-muted-foreground">Optional credentials for SSH and remote UI.</p>
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.authentication')}</h3>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.authenticationDescription')}</p>
</div>
<section className="px-2 pb-2 pt-0 space-y-3">
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<span className="typography-ui-label text-foreground w-56 shrink-0">SSH password (optional)</span>
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.sshPasswordOptional')}</span>
<Input
className="h-7 md:max-w-sm"
type="password"
@@ -1170,12 +1180,12 @@ export const RemoteInstancesPage: React.FC = () => {
},
}))
}
placeholder="Password or key passphrase"
placeholder={t('settings.remoteInstances.page.field.sshPasswordPlaceholder')}
/>
</div>
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<span className="typography-ui-label text-foreground w-56 shrink-0">OpenChamber UI password (optional)</span>
<span className="typography-ui-label text-foreground w-56 shrink-0">{t('settings.remoteInstances.page.field.uiPasswordOptional')}</span>
<Input
className="h-7 md:max-w-sm"
type="password"
@@ -1193,7 +1203,7 @@ export const RemoteInstancesPage: React.FC = () => {
},
}))
}
placeholder="Protect remote UI with password"
placeholder={t('settings.remoteInstances.page.field.uiPasswordPlaceholder')}
/>
</div>
</section>
@@ -1201,12 +1211,12 @@ export const RemoteInstancesPage: React.FC = () => {
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
<div className="mb-1 px-1 space-y-0.5">
<h3 className="typography-ui-header font-medium text-foreground">Port Forwards</h3>
<p className="typography-meta text-muted-foreground">Optional extra SSH forwards in addition to the primary OpenChamber tunnel.</p>
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.section.portForwards')}</h3>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.section.portForwardsDescription')}</p>
</div>
<section className="px-2 pb-2 pt-0 space-y-2">
{draft.portForwards.length === 0 ? (
<p className="typography-micro text-muted-foreground/80">No extra forwards configured yet.</p>
<p className="typography-micro text-muted-foreground/80">{t('settings.remoteInstances.page.empty.noExtraForwards')}</p>
) : null}
{draft.portForwards.map((forward, index) => {
@@ -1238,7 +1248,7 @@ export const RemoteInstancesPage: React.FC = () => {
const isForwardOpen = Boolean(expandedForwards[forward.id]);
const typeLabel = forward.type === 'local' ? 'Local (-L)' : forward.type === 'remote' ? 'Remote (-R)' : 'Dynamic (-D)';
const typeLabel = forward.type === 'local' ? t('settings.remoteInstances.page.forwardType.local') : forward.type === 'remote' ? t('settings.remoteInstances.page.forwardType.remote') : t('settings.remoteInstances.page.forwardType.dynamic');
return (
<Collapsible
@@ -1261,7 +1271,7 @@ export const RemoteInstancesPage: React.FC = () => {
</CollapsibleTrigger>
</div>
<div className="flex items-center gap-2">
<Switch checked={forward.enabled} onCheckedChange={(checked) => updateForward((item) => ({ ...item, enabled: checked }))} aria-label="Enable forward" />
<Switch checked={forward.enabled} onCheckedChange={(checked) => updateForward((item) => ({ ...item, enabled: checked }))} aria-label={t('settings.remoteInstances.page.actions.enableForwardAria')} />
<Button
type="button"
variant="ghost"
@@ -1280,12 +1290,12 @@ export const RemoteInstancesPage: React.FC = () => {
</div>
<CollapsibleContent className="pt-2">
<div className="space-y-0 pb-2">
<p className="typography-meta text-muted-foreground mb-3">{forwardTypeDescription(forward.type)}</p>
<p className="typography-meta text-muted-foreground mb-3">{t(forwardTypeDescriptionKey(forward.type))}</p>
<div className="flex flex-col gap-1.5 py-1.5 md:flex-row md:items-center md:gap-8">
<div className="w-56 shrink-0">
<HintLabel
label="Forward type"
hint="Local (-L): laptop -> remote service. Remote (-R): remote machine -> this laptop. Dynamic (-D): local SOCKS5 proxy."
label={t('settings.remoteInstances.page.field.forwardType')}
hint={t('settings.remoteInstances.page.field.forwardTypeHint')}
/>
</div>
<Select
@@ -1298,12 +1308,12 @@ export const RemoteInstancesPage: React.FC = () => {
}
>
<SelectTrigger className="h-7 w-fit min-w-[140px]">
<SelectValue placeholder="Type" />
<SelectValue placeholder={t('settings.remoteInstances.page.field.typePlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">Local (-L)</SelectItem>
<SelectItem value="remote">Remote (-R)</SelectItem>
<SelectItem value="dynamic">Dynamic (-D)</SelectItem>
<SelectItem value="local">{t('settings.remoteInstances.page.forwardType.local')}</SelectItem>
<SelectItem value="remote">{t('settings.remoteInstances.page.forwardType.remote')}</SelectItem>
<SelectItem value="dynamic">{t('settings.remoteInstances.page.forwardType.dynamic')}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -1322,7 +1332,7 @@ export const RemoteInstancesPage: React.FC = () => {
localHost: event.target.value,
}))
}
placeholder="127.0.0.1"
placeholder={t('settings.remoteInstances.page.field.localHostPlaceholder')}
/>
<span className="text-muted-foreground">:</span>
<NumberInput
@@ -1344,7 +1354,7 @@ export const RemoteInstancesPage: React.FC = () => {
localPort: undefined,
}));
}}
emptyLabel="Auto"
emptyLabel={t('settings.remoteInstances.page.field.auto')}
/>
</div>
</div>
@@ -1364,7 +1374,7 @@ export const RemoteInstancesPage: React.FC = () => {
remoteHost: event.target.value,
}))
}
placeholder="127.0.0.1"
placeholder={t('settings.remoteInstances.page.field.remoteHostPlaceholder')}
/>
<span className="text-muted-foreground">:</span>
<NumberInput
@@ -1386,7 +1396,7 @@ export const RemoteInstancesPage: React.FC = () => {
remotePort: undefined,
}));
}}
emptyLabel="Auto"
emptyLabel={t('settings.remoteInstances.page.field.auto')}
/>
</div>
</div>
@@ -1398,27 +1408,27 @@ export const RemoteInstancesPage: React.FC = () => {
<>
<RiComputerLine className="h-3.5 w-3.5" />
<span className="font-mono text-foreground">{localEndpoint}</span>
<span>(local SOCKS5)</span>
<span>{t('settings.remoteInstances.page.preview.localSocks5')}</span>
</>
) : forward.type === 'remote' ? (
<>
<RiServerLine className="h-3.5 w-3.5" />
<span className="font-mono text-foreground">{remoteEndpoint}</span>
<span>(remote)</span>
<span>{t('settings.remoteInstances.page.preview.remote')}</span>
<RiArrowRightLine className="h-3.5 w-3.5" />
<RiComputerLine className="h-3.5 w-3.5" />
<span className="font-mono text-foreground">{localEndpoint}</span>
<span>(local)</span>
<span>{t('settings.remoteInstances.page.preview.local')}</span>
</>
) : (
<>
<RiComputerLine className="h-3.5 w-3.5" />
<span className="font-mono text-foreground">{localEndpoint}</span>
<span>(local)</span>
<span>{t('settings.remoteInstances.page.preview.local')}</span>
<RiArrowRightLine className="h-3.5 w-3.5" />
<RiServerLine className="h-3.5 w-3.5" />
<span className="font-mono text-foreground">{remoteEndpoint}</span>
<span>(remote)</span>
<span>{t('settings.remoteInstances.page.preview.remote')}</span>
</>
)}
</div>
@@ -1432,13 +1442,13 @@ export const RemoteInstancesPage: React.FC = () => {
onClick={() => {
void openExternalUrl(localEndpointUrl).then((opened) => {
if (!opened) {
toast.error('Failed to open local endpoint');
toast.error(t('settings.remoteInstances.page.toast.openLocalEndpointFailed'));
}
});
}}
>
<RiExternalLinkLine className="h-3.5 w-3.5" />
Open local
{t('settings.remoteInstances.page.actions.openLocal')}
</Button>
) : null}
</div>
@@ -1466,20 +1476,20 @@ export const RemoteInstancesPage: React.FC = () => {
}}
>
<RiAddLine className="h-3.5 w-3.5" />
Add forward
{t('settings.remoteInstances.page.actions.addForward')}
</Button>
</section>
</div>
<div className="mb-8 border-t border-[var(--surface-subtle)] pt-8">
<div className="mb-1 px-1 space-y-0.5">
<h3 className="typography-ui-header font-medium text-foreground">Import from SSH config</h3>
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.remoteInstances.page.import.sectionTitle')}</h3>
</div>
<section className="px-2 pb-2 pt-0">
{isImportsLoading ? (
<p className="typography-meta text-muted-foreground">Loading SSH hosts...</p>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.loading')}</p>
) : importCandidates.length === 0 ? (
<p className="typography-meta text-muted-foreground">No SSH hosts available.</p>
<p className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.import.noneAvailable')}</p>
) : (
<div>
{importCandidates.slice(0, 8).map((candidate, index) => (
@@ -1501,7 +1511,7 @@ export const RemoteInstancesPage: React.FC = () => {
className="!font-normal"
onClick={() => void handleImportCandidate(candidate.host, candidate.pattern)}
>
Import
{t('settings.common.actions.import')}
</Button>
</div>
))}
@@ -1513,7 +1523,7 @@ export const RemoteInstancesPage: React.FC = () => {
<div className="sticky bottom-0 z-10 -mx-3 sm:-mx-6 bg-[var(--surface-background)] border-t border-[var(--interactive-border)] px-3 sm:px-6 py-3">
<div className="flex items-center gap-2">
<Button type="button" size="xs" className="!font-normal" onClick={() => void handleSave()} disabled={!hasChanges || isSaving}>
Save changes
{t('settings.common.actions.saveChanges')}
</Button>
{status?.localUrl ? (
<>
@@ -1525,13 +1535,13 @@ export const RemoteInstancesPage: React.FC = () => {
onClick={() => {
void copyTextToClipboard(status.localUrl || '').then((result) => {
if (result.ok) {
toast.success('Local URL copied');
toast.success(t('settings.remoteInstances.page.toast.localUrlCopied'));
}
});
}}
>
<RiFileCopyLine className="h-3.5 w-3.5" />
Copy local URL
{t('settings.remoteInstances.page.actions.copyLocalUrl')}
</Button>
<Button
type="button"
@@ -1543,7 +1553,7 @@ export const RemoteInstancesPage: React.FC = () => {
}}
>
<RiExternalLinkLine className="h-3.5 w-3.5" />
Open
{t('settings.remoteInstances.page.actions.open')}
</Button>
</>
) : null}
@@ -1554,28 +1564,28 @@ export const RemoteInstancesPage: React.FC = () => {
<Dialog open={logDialogOpen} onOpenChange={setLogDialogOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>SSH Logs</DialogTitle>
<DialogTitle>{t('settings.remoteInstances.page.logsDialog.title')}</DialogTitle>
<DialogDescription>
{draft?.nickname?.trim() || draft?.sshParsed?.destination || draft?.id || 'Selected instance'}
{draft?.nickname?.trim() || draft?.sshParsed?.destination || draft?.id || t('settings.remoteInstances.page.logsDialog.selectedInstanceFallback')}
</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-end gap-2">
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={handleCopyAllLogs} disabled={logDialogLoading || !logLinesText.trim()}>
<RiFileCopyLine className="h-3.5 w-3.5" />
Copy all
{t('settings.common.actions.copyAll')}
</Button>
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => void handleClearLogs()} disabled={logDialogLoading}>
<RiDeleteBinLine className="h-3.5 w-3.5" />
Clear
{t('settings.common.actions.clear')}
</Button>
</div>
{logDialogLoading ? (
<div className="typography-meta text-muted-foreground">Loading logs...</div>
<div className="typography-meta text-muted-foreground">{t('settings.remoteInstances.page.logsDialog.loading')}</div>
) : logDialogError ? (
<div className="typography-meta text-[var(--status-error)]">{logDialogError}</div>
) : (
<pre className="max-h-[55vh] overflow-auto rounded-md border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-3 typography-micro text-foreground whitespace-pre-wrap break-words">
{logDialogLines.length > 0 ? logDialogLines.join('\n') : 'No SSH logs yet.'}
{logDialogLines.length > 0 ? logDialogLines.join('\n') : t('settings.remoteInstances.page.logsDialog.empty')}
</pre>
)}
</DialogContent>
@@ -1591,9 +1601,11 @@ export const RemoteInstancesPage: React.FC = () => {
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create from wildcard pattern</DialogTitle>
<DialogTitle>{t('settings.remoteInstances.page.patternDialog.title')}</DialogTitle>
<DialogDescription>
{patternHost ? `${patternHost} requires a concrete destination.` : 'Enter destination.'}
{patternHost
? t('settings.remoteInstances.page.patternDialog.descriptionWithHost', { host: patternHost })
: t('settings.remoteInstances.page.patternDialog.description')}
</DialogDescription>
</DialogHeader>
<form
@@ -1606,15 +1618,15 @@ export const RemoteInstancesPage: React.FC = () => {
<Input
value={patternDestination}
onChange={(event) => setPatternDestination(event.target.value)}
placeholder="user@host"
placeholder={t('settings.remoteInstances.page.patternDialog.destinationPlaceholder')}
autoFocus
/>
<div className="flex items-center justify-end gap-2">
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={closePatternDialog} disabled={patternCreating}>
Cancel
{t('settings.common.actions.cancel')}
</Button>
<Button type="submit" size="xs" className="!font-normal" disabled={patternCreating}>
Create
{t('settings.common.actions.create')}
</Button>
</div>
</form>
@@ -7,6 +7,7 @@ import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
import { useUIStore } from '@/stores/useUIStore';
import { toast } from '@/components/ui';
import type { DesktopSshInstance } from '@/lib/desktopSsh';
import { useI18n } from '@/lib/i18n';
type RemoteInstancesSidebarProps = {
onItemSelect?: () => void;
@@ -28,30 +29,31 @@ const isPortInUseError = (error: unknown): boolean => {
return message.includes('address already in use') || message.includes('eaddrinuse') || message.includes('port already in use');
};
const phaseLabel = (phase?: string): string => {
const phaseLabelKey = (phase?: string) => {
switch (phase) {
case 'ready':
return 'Ready';
return 'settings.remoteInstances.sidebar.phase.ready';
case 'error':
return 'Error';
return 'settings.remoteInstances.sidebar.phase.error';
case 'degraded':
return 'Reconnect';
return 'settings.remoteInstances.sidebar.phase.reconnect';
case 'installing':
return 'Installing';
return 'settings.remoteInstances.sidebar.phase.installing';
case 'updating':
return 'Updating';
return 'settings.remoteInstances.sidebar.phase.updating';
case 'forwarding':
return 'Forwarding';
return 'settings.remoteInstances.sidebar.phase.forwarding';
case 'server_starting':
return 'Starting';
return 'settings.remoteInstances.sidebar.phase.starting';
case 'master_connecting':
return 'Connecting';
return 'settings.remoteInstances.sidebar.phase.connecting';
default:
return 'Idle';
return 'settings.remoteInstances.sidebar.phase.idle';
}
};
export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({ onItemSelect }) => {
const { t } = useI18n();
const instances = useDesktopSshStore((state) => state.instances);
const statusesById = useDesktopSshStore((state) => state.statusesById);
const isLoading = useDesktopSshStore((state) => state.isLoading);
@@ -89,15 +91,15 @@ export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({
const handleAdd = React.useCallback(async () => {
const id = makeId();
try {
await createFromCommand(id, 'ssh user@example.com', 'New SSH Instance');
await createFromCommand(id, 'ssh user@example.com', t('settings.remoteInstances.sidebar.newSshInstanceName'));
setSelectedId(id);
onItemSelect?.();
} catch (error) {
toast.error('Failed to create SSH instance', {
toast.error(t('settings.remoteInstances.sidebar.toast.createFailed'), {
description: error instanceof Error ? error.message : String(error),
});
}
}, [createFromCommand, onItemSelect, setSelectedId]);
}, [createFromCommand, onItemSelect, setSelectedId, t]);
const connectWithPortRecovery = React.useCallback(async (instance: DesktopSshInstance) => {
try {
@@ -108,7 +110,7 @@ export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({
throw error;
}
const allow = window.confirm('Local port is already in use. Pick a random free local port and retry?');
const allow = window.confirm(t('settings.remoteInstances.sidebar.confirm.localPortInUseRetry'));
if (!allow) {
throw error;
}
@@ -123,25 +125,25 @@ export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({
await upsertInstance(nextInstance);
await connect(nextInstance.id);
toast.success('Retried with a random local port');
toast.success(t('settings.remoteInstances.sidebar.toast.retriedWithRandomPort'));
}
}, [connect, upsertInstance]);
}, [connect, t, upsertInstance]);
return (
<SettingsSidebarLayout
variant="background"
header={
<div className="border-b px-3 pt-4 pb-3">
<h2 className="text-base font-semibold text-foreground mb-3">Remote Instances</h2>
<h2 className="text-base font-semibold text-foreground mb-3">{t('settings.remoteInstances.sidebar.title')}</h2>
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">Total {instances.length}</span>
<span className="typography-meta text-muted-foreground">{t('settings.remoteInstances.sidebar.total', { count: instances.length })}</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 -my-1 text-muted-foreground"
onClick={() => void handleAdd()}
aria-label="Add SSH instance"
aria-label={t('settings.remoteInstances.sidebar.actions.addSshInstance')}
>
<RiAddLine className="size-4" />
</Button>
@@ -153,7 +155,7 @@ export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({
const status = statusesById[instance.id];
const selected = instance.id === selectedId;
const title = instance.nickname?.trim() || instance.sshParsed?.destination || instance.id;
const metadata = `${phaseLabel(status?.phase)}${status?.localUrl ? ` · ${status.localUrl}` : ''}`;
const metadata = `${t(phaseLabelKey(status?.phase))}${status?.localUrl ? ` · ${status.localUrl}` : ''}`;
const isReady = status?.phase === 'ready';
const canRetry = status?.phase === 'error' || status?.phase === 'degraded';
@@ -169,31 +171,36 @@ export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({
}}
actions={[
{
label: isReady ? 'Disconnect' : 'Connect',
label: isReady ? t('settings.remoteInstances.sidebar.actions.disconnect') : t('settings.remoteInstances.sidebar.actions.connect'),
icon: isReady ? RiStopLine : RiPlug2Line,
onClick: () => {
const op = isReady ? disconnect(instance.id) : connectWithPortRecovery(instance);
void op.catch((error) => {
toast.error(`Failed to ${isReady ? 'disconnect' : 'connect'} instance`, {
toast.error(
isReady
? t('settings.remoteInstances.sidebar.toast.disconnectFailed')
: t('settings.remoteInstances.sidebar.toast.connectFailed'),
{
description: error instanceof Error ? error.message : String(error),
});
}
);
});
},
},
{
label: 'Retry',
label: t('settings.remoteInstances.sidebar.actions.retry'),
icon: RiRefreshLine,
onClick: () => {
if (!canRetry) return;
void retry(instance.id).catch((error) => {
toast.error('Failed to retry connection', {
toast.error(t('settings.remoteInstances.sidebar.toast.retryFailed'), {
description: error instanceof Error ? error.message : String(error),
});
});
},
},
{
label: 'Remove',
label: t('settings.remoteInstances.sidebar.actions.remove'),
icon: RiDeleteBinLine,
destructive: true,
onClick: () => {
@@ -203,7 +210,7 @@ export const RemoteInstancesSidebar: React.FC<RemoteInstancesSidebarProps> = ({
setSelectedId(next?.id || null);
}
}).catch((error) => {
toast.error('Failed to remove instance', {
toast.error(t('settings.remoteInstances.sidebar.toast.removeFailed'), {
description: error instanceof Error ? error.message : String(error),
});
});