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:
committed by
GitHub
parent
87db2ea210
commit
7d7285655d
@@ -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),
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user