diff --git a/src/components/terminal/TerminalView.tsx b/src/components/terminal/TerminalView.tsx
index 0f12ba0..0ab7930 100644
--- a/src/components/terminal/TerminalView.tsx
+++ b/src/components/terminal/TerminalView.tsx
@@ -56,6 +56,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
statusMessage,
showSuspendedOverlay,
reconnectAttempt,
+ disconnectKind,
connectionIdRef,
shellIdRef,
hasConnectedRef,
@@ -334,6 +335,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
!!currentTab?.needsReconnect || (connectionState === 'disconnected' && !!hasConnectedRef.current)
}
reconnectAttempt={reconnectAttempt}
+ disconnectKind={disconnectKind}
isLocal={!!currentTab?.isLocal}
onReconnect={handleReconnect}
onResume={resumeConnection}
diff --git a/src/components/terminal/TerminalWorkspace.tsx b/src/components/terminal/TerminalWorkspace.tsx
index 665f09f..b7cb087 100644
--- a/src/components/terminal/TerminalWorkspace.tsx
+++ b/src/components/terminal/TerminalWorkspace.tsx
@@ -46,7 +46,12 @@ import { createLogger } from '../../utils/logger'
import type { TerminalViewProps } from './types'
import { readClipboardText, writeClipboardText } from '../../platform/clipboard'
import { isTerminalCopyShortcut, isTerminalPasteShortcut, isTerminalSplitShortcut } from '../../utils/terminalShortcuts'
-import { resolveTerminalInputTargets, shouldBroadcastTerminalInput } from '../../utils/terminalPanes'
+import {
+ panesAfterConnectionStateChange,
+ resolveTerminalInputTargets,
+ shouldBroadcastTerminalInput,
+ shouldCollapseTerminalSplitForOverlay,
+} from '../../utils/terminalPanes'
import { TerminalPipeline } from '../../utils/terminalPipeline'
import { ZmodemSftpBridge, type ZmodemBridgeStatus } from '../../utils/zmodemSftpBridge'
import AiTerminalPanel, { type TerminalAiContext } from '../ai/AiTerminalPanel'
@@ -274,6 +279,8 @@ function PaneTerminal({
void shellResize(sid, term.cols, term.rows)
}
}
+ const resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => onResize()) : null
+ resizeObserver?.observe(container)
window.addEventListener('resize', onResize)
// Fit before advertising size so the primary shell is not born at 80×24.
@@ -315,6 +322,7 @@ function PaneTerminal({
return () => {
disposed = true
setXtermReady(false)
+ resizeObserver?.disconnect()
window.removeEventListener('resize', onResize)
container.removeEventListener('contextmenu', handleContextMenu)
unregisterShellHandler(paneId)
@@ -341,9 +349,13 @@ function PaneTerminal({
safeTerminalFit(fitAddonRef.current, term)
}, [currentTheme, settings.fontFamily, settings.fontSize, settings.scrollback])
- // Resize when shell id attaches
+ // Resize when shell id attaches; drop the stale id when the session is gone.
useEffect(() => {
- if (!shellId || !xtermRef.current) return
+ if (!shellId) {
+ shellIdRef.current = null
+ return
+ }
+ if (!xtermRef.current) return
shellIdRef.current = shellId
safeTerminalFit(fitAddonRef.current, xtermRef.current)
void shellResize(shellId, xtermRef.current.cols, xtermRef.current.rows)
@@ -470,6 +482,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
const canEnableBroadcast = effectiveBroadcastCount >= 2
const requirePro = useEntitlementStore((state) => state.requirePro)
const [aiPanelOpen, setAiPanelOpen] = useState(false)
+ const [shellIoReady, setShellIoReady] = useState(false)
const isLocalTab = !!currentTab?.isLocal
const [contextMenu, setContextMenu] = useState<{
paneId: string
@@ -517,6 +530,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
statusMessage,
showSuspendedOverlay,
reconnectAttempt,
+ disconnectKind,
connectionIdRef,
shellIdRef,
hasConnectedRef,
@@ -544,17 +558,14 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
}, [tabId, direction, panes.length, ratio, updateTab])
// Sync primary shell from connection hook → first pane.
- // Also adopt the pending id while connecting so bash/sh PS1 is not dropped.
+ // Drop every pane's dead shell when the pooled session is gone so Cmd+D
+ // splits recreate after auto-reconnect instead of looking frozen.
useEffect(() => {
- const sid = shellIdRef.current || currentTab?.connectionInstance?.shellId
- if (!sid) return
- if (connectionState !== 'connected' && connectionState !== 'connecting') return
- setPanes((prev) => {
- if (prev.length === 0) return prev
- if (prev[0].shellId === sid) return prev
- if (prev[0].shellId) return prev
- return prev.map((p, i) => (i === 0 ? { ...p, shellId: sid } : p))
- })
+ const primaryShellId =
+ connectionState === 'connected' || connectionState === 'connecting'
+ ? shellIdRef.current || currentTab?.connectionInstance?.shellId || null
+ : null
+ setPanes((prev) => panesAfterConnectionStateChange(prev, connectionState, primaryShellId))
}, [connectionState, currentTab?.connectionInstance?.shellId, shellIdRef])
// Register ready shells (SSH + local) so broadcast can cover this tab's panes.
@@ -596,8 +607,10 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
if (broadcastInput && effectiveBroadcastCount < 2) setBroadcastInput(false)
}, [broadcastInput, effectiveBroadcastCount, setBroadcastInput])
- // Fan-out shell data to panes (SSH + local)
+ // Fan-out shell data to panes (SSH + local). Do not connect until listen() resolves —
+ // otherwise MOTD from bootstrap shows up and the prompt (later events) never arrives.
useEffect(() => {
+ let cancelled = false
const handler = (_e: unknown, _connId: string, shellId: string, data: unknown) => {
for (const h of shellHandlersRef.current.values()) {
h(shellId, data)
@@ -608,11 +621,19 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
if (conn) conn.stats.bytesTransferred += shellPayloadByteLength(data)
}
}
- window.opsxApi?.onShellData(handler)
- window.opsxApi?.onLocalData?.(handler)
+ const api = window.opsxApi
+ const pending = [
+ api?.onShellData ? Promise.resolve(api.onShellData(handler)) : Promise.resolve(),
+ api?.onLocalData ? Promise.resolve(api.onLocalData(handler)) : Promise.resolve(),
+ ]
+ void Promise.all(pending).then(() => {
+ if (!cancelled) setShellIoReady(true)
+ })
return () => {
- window.opsxApi?.removeListener('ssh:shell-data', handler as (...args: unknown[]) => void)
- window.opsxApi?.removeListener('local:data', handler as (...args: unknown[]) => void)
+ cancelled = true
+ setShellIoReady(false)
+ api?.removeListener('ssh:shell-data', handler as (...args: unknown[]) => void)
+ api?.removeListener('local:data', handler as (...args: unknown[]) => void)
}
}, [connectionIdRef])
@@ -712,6 +733,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
const armInitialConnect = useCallback(() => {
if (connectArmedRef.current || hasConnectedRef.current) return
+ if (!shellIoReady) return
const current = getTabById(tabId)
if (!current) return
if (!(isActive || current.connectOnMount)) return
@@ -728,7 +750,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
requestAnimationFrame(() => {
void connect()
})
- }, [connect, getTabById, isActive, restoreSnapshot, snapshotStore, tabId, updateTab, hasConnectedRef])
+ }, [connect, getTabById, isActive, restoreSnapshot, shellIoReady, snapshotStore, tabId, updateTab, hasConnectedRef])
const registerShellHandler = useCallback((paneId: string, handler: (shellId: string, data: unknown) => void) => {
shellHandlersRef.current.set(paneId, handler)
@@ -777,7 +799,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
useEffect(() => {
if (!isActive || hasConnectedRef.current || connectArmedRef.current) return
if (primaryXtermRef.current) armInitialConnect()
- }, [isActive, armInitialConnect, hasConnectedRef])
+ }, [isActive, armInitialConnect, hasConnectedRef, shellIoReady])
const onShellReady = useCallback(
(paneId: string, shellId: string) => {
@@ -1073,12 +1095,21 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
const multi = panes.length > 1
const isLocal = !!currentTab?.isLocal
+ const needsReconnect =
+ !!currentTab?.needsReconnect || (connectionState === 'disconnected' && !!hasConnectedRef.current)
+ const layoutSplit =
+ multi &&
+ !shouldCollapseTerminalSplitForOverlay({
+ connectionState,
+ needsReconnect,
+ showSuspendedOverlay,
+ })
const paneStyle = (index: number, paneId: string): CSSProperties => {
if (maximizedPaneId === paneId) {
return { flex: 1, width: '100%', height: '100%', minWidth: 0, minHeight: 0 }
}
- if (!multi) return { flex: 1 }
+ if (!layoutSplit) return { flex: 1 }
if (panes.length === 2) {
const share = index === 0 ? ratio : 1 - ratio
return direction === 'horizontal'
@@ -1090,7 +1121,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
}
return (
-
+
-
-
+
+
{showSearch && (
{panes.map((pane, index) => (
0 ? 'hidden' : ''}`}
style={paneStyle(index, pane.id)}
>
void reopenPaneShell(pane.id)}
/>
)}
- {multi && panes.length === 2 && index === 0 && (
+ {layoutSplit && panes.length === 2 && index === 0 && (
{
const count = Math.min(MAX_PANES, Math.max(1, panes.length))
diff --git a/src/desktop.d.ts b/src/desktop.d.ts
index 788f98a..c7d83da 100644
--- a/src/desktop.d.ts
+++ b/src/desktop.d.ts
@@ -18,7 +18,7 @@ export interface DesktopApiContract {
) => Promise
sshDisconnect: (
connectionId: string,
- options?: { retainForwards?: boolean }
+ options?: { retainForwards?: boolean; reason?: string }
) => Promise<{ success: boolean; error?: string }>
sshExec: (
connectionId: string,
@@ -27,7 +27,7 @@ export interface DesktopApiContract {
) => Promise<{ success: boolean; result?: string; exitCode?: number; error?: string }>
sshExecCancel: (connectionId: string) => Promise<{ success: boolean; error?: string }>
sshHostMetrics: (connectionId: string) => Promise
- sshKeepalive: (connectionId: string) => Promise<{ success: boolean; error?: string }>
+ sshKeepalive: (connectionId: string) => Promise<{ success: boolean; error?: string; errorCode?: string }>
sshCreateShell: (
connectionId: string,
options?: import('../shared/types/ipc.types').SSHCreateShellRequest,
@@ -81,7 +81,7 @@ export interface DesktopApiContract {
localPort: number,
forwardId?: string
) => Promise
- sshForwardStop: (forwardId: string) => Promise<{ success: boolean; error?: string }>
+ sshForwardStop: (forwardId: string) => Promise
sshForwardList: (connectionId?: string) => Promise
// Sessions
diff --git a/src/hooks/useSFTP.ts b/src/hooks/useSFTP.ts
index 12eeaf6..b464d65 100644
--- a/src/hooks/useSFTP.ts
+++ b/src/hooks/useSFTP.ts
@@ -22,6 +22,8 @@ import {
import { joinLocalPath } from '../utils/localPath'
import i18n from '../i18n'
import { translateErrorMessage } from '../i18n/format'
+import { useSettingsStore } from '../stores/settingsStore'
+import { classifyDisconnectReason, reconnectDelayMs, shouldAutoReconnect } from '../utils/disconnectReason'
const logger = createLogger('useSFTP')
@@ -83,6 +85,7 @@ function localEntryType(entry: LocalDirEntry): FileItem['type'] {
export interface UseSFTPOptions {
tabId: string
+ sessionId?: string
}
export interface UploadSource {
@@ -191,7 +194,7 @@ interface QueuedTransferJob {
total: number
}
-export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
+export function useSFTP({ tabId, sessionId }: UseSFTPOptions): UseSFTPReturn {
const { sessions } = useSessionStore()
const { updateTabConnectionInstance, updateTab } = useTabStore()
const { createConnection } = useConnectionPool()
@@ -224,9 +227,16 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
/** Last successfully listed remote directory — used to resume after reconnect. */
const lastBrowsePathRef = useRef(null)
const remoteHistoryRef = useRef(['/'])
+ const sessionIdRef = useRef(sessionId)
+ const connectRef = useRef<(id: string) => Promise>(async () => {})
+ const intentionalDisconnectRef = useRef(false)
+ const reconnectAttemptRef = useRef(0)
+ const reconnectTimerRef = useRef | null>(null)
+ const connectingGuardRef = useRef(false)
connectionIdRef.current = connectionId
sftpIdRef.current = sftpId
remoteHistoryRef.current = remoteHistory
+ sessionIdRef.current = sessionId
// Transfer state — serial pump avoids concurrent upload+download locking the same SFTP Mutex.
const [transferQueue, setTransferQueue] = useState([])
@@ -487,7 +497,9 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
// Connect to SFTP
const connect = useCallback(
async (sessionId: string) => {
- logger.info('SFTP CONNECT', { sessionId })
+ if (connectingGuardRef.current) return
+ connectingGuardRef.current = true
+ intentionalDisconnectRef.current = false
if (!sessionId) {
logger.error('No sessionId provided')
@@ -535,6 +547,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
setSftpId(sftpResult.sftpId)
setIsConnected(true)
+ reconnectAttemptRef.current = 0
logger.info(`SFTP connected: ${sftpResult.sftpId}`)
getConnectionStore().addSFTP(connId, sftpResult.sftpId)
@@ -551,11 +564,9 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
// Resume last browse path after reconnect; first connect prefers home then /.
const homePreferred = config.username === 'root' ? '/root' : `/home/${config.username}`
const resumePath = lastBrowsePathRef.current
- const tryPaths = [
- ...(resumePath ? [resumePath] : []),
- homePreferred,
- '/',
- ].filter((path, index, all) => path && all.indexOf(path) === index)
+ const tryPaths = [...(resumePath ? [resumePath] : []), homePreferred, '/'].filter(
+ (path, index, all) => path && all.indexOf(path) === index
+ )
let startPath = '/'
for (const path of tryPaths) {
@@ -589,14 +600,21 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
logger.error('SFTP connection failed', err)
setError(i18n.t('errors:connection.failed'))
} finally {
+ connectingGuardRef.current = false
setIsConnecting(false)
}
},
[tabId, createConnection, loadRemoteDirectory, updateTabConnectionInstance, updateTab]
)
+ connectRef.current = connect
// Disconnect
const disconnect = useCallback(async () => {
+ intentionalDisconnectRef.current = true
+ if (reconnectTimerRef.current) {
+ clearTimeout(reconnectTimerRef.current)
+ reconnectTimerRef.current = null
+ }
if (sftpId && connectionId) {
getConnectionStore().removeSFTP(connectionId, sftpId)
}
@@ -822,9 +840,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
} else {
const message =
translateErrorMessage(result.error) ||
- (job.type === 'upload'
- ? i18n.t('sftp:errors.uploadFailed')
- : i18n.t('sftp:errors.downloadFailed'))
+ (job.type === 'upload' ? i18n.t('sftp:errors.uploadFailed') : i18n.t('sftp:errors.downloadFailed'))
patchTransfer(job.id, { status: 'failed', error: message })
transferResultsRef.current.set(job.id, {
status: 'failed',
@@ -841,9 +857,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
} else {
const message =
translateErrorMessage(raw) ||
- (job.type === 'upload'
- ? i18n.t('sftp:errors.uploadFailed')
- : i18n.t('sftp:errors.downloadFailed'))
+ (job.type === 'upload' ? i18n.t('sftp:errors.uploadFailed') : i18n.t('sftp:errors.downloadFailed'))
patchTransfer(job.id, { status: 'failed', error: message })
transferResultsRef.current.set(job.id, {
status: 'failed',
@@ -889,33 +903,30 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
[runTransferPump]
)
- const cancelTransfer = useCallback(
- (taskId: string) => {
- const isActive = activeTransferIdRef.current === taskId
- cancelledTransferIdsRef.current.add(taskId)
- // Drop pending jobs immediately so the pump never starts them.
- transferJobsRef.current = transferJobsRef.current.filter((job) => job.id !== taskId)
- setTransferQueue((prev) => {
- const next = prev.map((task) =>
- task.id === taskId && (task.status === 'pending' || task.status === 'transferring')
- ? { ...task, status: 'canceled' as const }
- : task
- )
- transferQueueRef.current = next
- return next
+ const cancelTransfer = useCallback((taskId: string) => {
+ const isActive = activeTransferIdRef.current === taskId
+ cancelledTransferIdsRef.current.add(taskId)
+ // Drop pending jobs immediately so the pump never starts them.
+ transferJobsRef.current = transferJobsRef.current.filter((job) => job.id !== taskId)
+ setTransferQueue((prev) => {
+ const next = prev.map((task) =>
+ task.id === taskId && (task.status === 'pending' || task.status === 'transferring')
+ ? { ...task, status: 'canceled' as const }
+ : task
+ )
+ transferQueueRef.current = next
+ return next
+ })
+ if (!isActive) {
+ transferResultsRef.current.set(taskId, { status: 'canceled' })
+ cancelledTransferIdsRef.current.delete(taskId)
+ }
+ if (isActive && sftpIdRef.current) {
+ void cancelSftpTransfer(sftpIdRef.current).catch((err) => {
+ logger.warn('cancelSftpTransfer failed', err)
})
- if (!isActive) {
- transferResultsRef.current.set(taskId, { status: 'canceled' })
- cancelledTransferIdsRef.current.delete(taskId)
- }
- if (isActive && sftpIdRef.current) {
- void cancelSftpTransfer(sftpIdRef.current).catch((err) => {
- logger.warn('cancelSftpTransfer failed', err)
- })
- }
- },
- []
- )
+ }
+ }, [])
const retryTransfer = useCallback(
(taskId: string) => {
@@ -975,9 +986,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
return {
completed,
failed: failedTasks.length,
- errors: failedTasks.map((result) =>
- result.error || i18n.t('sftp:errors.uploadFailed')
- ),
+ errors: failedTasks.map((result) => result.error || i18n.t('sftp:errors.uploadFailed')),
}
}
await new Promise((resolve) => setTimeout(resolve, 40))
@@ -1006,8 +1015,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
stats?: { type?: string; isDirectory?: boolean; size?: number }
}
if (statsResult.success && statsResult.stats) {
- isDirectory =
- statsResult.stats.type === 'directory' || Boolean(statsResult.stats.isDirectory)
+ isDirectory = statsResult.stats.type === 'directory' || Boolean(statsResult.stats.isDirectory)
total = isDirectory ? 0 : statsResult.stats.size || 0
}
} catch (err: unknown) {
@@ -1237,9 +1245,9 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
// Do NOT release/disconnect on React unmount — StrictMode remounts would
// kill a healthy SFTP session; tab close goes through disposeTabResources.
useEffect(() => {
- const onClosed = (_event: unknown, closedId: string) => {
+ const onClosed = (_event: unknown, closedId: string, reason?: string) => {
if (!closedId || closedId !== connectionIdRef.current) return
- logger.warn('SFTP underlying SSH connection closed', { connectionId: closedId })
+ logger.warn('SFTP underlying SSH connection closed', { connectionId: closedId, reason })
const sid = sftpIdRef.current
if (sid) {
getConnectionStore().removeSFTP(closedId, sid)
@@ -1248,7 +1256,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
setSftpId(null)
setConnectionId(null)
setRemoteFiles([])
- const closedMsg = i18n.t('errors:connection.closed')
+ const closedMsg = translateErrorMessage(reason) || i18n.t('errors:connection.unexpectedDropped')
setError(closedMsg)
// Keep lastBrowsePathRef so reconnect can resume the directory.
// Fail in-flight queue items so the user can retry after reconnect.
@@ -1268,10 +1276,56 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
return next
})
updateTab(tabId, { connectionInstance: undefined, connectionStatus: 'disconnected' })
+
+ const resumeSessionId = sessionIdRef.current
+ const { settings } = useSettingsStore.getState()
+ const kind = classifyDisconnectReason(reason)
+ if (
+ !resumeSessionId ||
+ intentionalDisconnectRef.current ||
+ !settings.autoReconnect ||
+ !shouldAutoReconnect(kind)
+ ) {
+ return
+ }
+
+ const maxAttempts = settings.maxReconnectAttempts ?? 5
+ const baseDelay = settings.reconnectBaseDelayMs ?? 1500
+ if (reconnectAttemptRef.current >= maxAttempts) {
+ setError(
+ i18n.t('terminal:connection.autoReconnectLimit', {
+ max: maxAttempts,
+ reasonSuffix: i18n.t('terminal:connection.autoReconnectLimitReason', {
+ reason: closedMsg,
+ }),
+ })
+ )
+ return
+ }
+
+ reconnectAttemptRef.current += 1
+ const attempt = reconnectAttemptRef.current
+ const delay = reconnectDelayMs(attempt, baseDelay)
+ setIsConnecting(true)
+ setError(
+ i18n.t('sftp:connection.autoReconnecting', {
+ attempt,
+ max: maxAttempts,
+ })
+ )
+ if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current)
+ reconnectTimerRef.current = setTimeout(() => {
+ reconnectTimerRef.current = null
+ void connectRef.current?.(resumeSessionId)
+ }, delay)
}
window.opsxApi?.onConnectionClosed?.(onClosed)
return () => {
window.opsxApi?.removeListener?.('ssh:connection-closed', onClosed as (...args: unknown[]) => void)
+ if (reconnectTimerRef.current) {
+ clearTimeout(reconnectTimerRef.current)
+ reconnectTimerRef.current = null
+ }
}
}, [tabId, updateTab])
diff --git a/src/hooks/useTerminalConnection.ts b/src/hooks/useTerminalConnection.ts
index 733cf48..c8f1477 100644
--- a/src/hooks/useTerminalConnection.ts
+++ b/src/hooks/useTerminalConnection.ts
@@ -12,7 +12,14 @@ import { createLogger } from '../utils/logger'
import { detectAndPersistSessionDistro } from '../utils/detectSessionDistro'
import i18n from '../i18n'
import { translateErrorMessage } from '../i18n/format'
+import {
+ classifyDisconnectReason,
+ reconnectDelayMs,
+ shouldAutoReconnect,
+ type DisconnectKind,
+} from '../utils/disconnectReason'
import { isDefaultTerminalTitle } from '../utils/tabTitle'
+import { subscribeTabReconnect } from '../utils/tabReconnect'
import type { SSHConnectionConfig } from '../../shared/types/session.types'
const logger = createLogger('useTerminalConnection')
@@ -37,6 +44,7 @@ export interface UseTerminalConnectionReturn {
statusMessage: string
showSuspendedOverlay: boolean
reconnectAttempt: number
+ disconnectKind: DisconnectKind
connectionIdRef: React.MutableRefObject
shellIdRef: React.MutableRefObject
hasConnectedRef: React.MutableRefObject
@@ -63,6 +71,7 @@ export function useTerminalConnection({ tabId, xtermRef }: UseTerminalConnection
const [statusMessage, setStatusMessage] = useState('')
const [showSuspendedOverlay, setShowSuspendedOverlay] = useState(false)
const [reconnectAttempt, setReconnectAttempt] = useState(0)
+ const [disconnectKind, setDisconnectKind] = useState('unexpected')
const { updateTab, updateTabConnectionInstance, updateTabStatus, getTabById } = useTabStore()
@@ -370,12 +379,19 @@ export function useTerminalConnection({ tabId, xtermRef }: UseTerminalConnection
const maxAttempts = settings.maxReconnectAttempts ?? 5
const baseDelay = settings.reconnectBaseDelayMs ?? 1500
+ const kind = classifyDisconnectReason(reason)
+ setDisconnectKind(kind)
+ if (!shouldAutoReconnect(kind)) return
if (reconnectAttemptRef.current >= maxAttempts) {
setStatusMessage(
i18n.t('terminal:connection.autoReconnectLimit', {
max: maxAttempts,
- reasonSuffix: reason ? i18n.t('terminal:connection.autoReconnectLimitReason', { reason }) : '',
+ reasonSuffix: reason
+ ? i18n.t('terminal:connection.autoReconnectLimitReason', {
+ reason: translateErrorMessage(reason),
+ })
+ : '',
})
)
setConnectionState('error')
@@ -384,7 +400,7 @@ export function useTerminalConnection({ tabId, xtermRef }: UseTerminalConnection
reconnectAttemptRef.current += 1
setReconnectAttempt(reconnectAttemptRef.current)
- const delay = Math.min(baseDelay * Math.pow(1.6, reconnectAttemptRef.current - 1), 30000)
+ const delay = reconnectDelayMs(reconnectAttemptRef.current, baseDelay)
const seconds = Math.round(delay / 1000)
const attempt = reconnectAttemptRef.current
@@ -430,12 +446,24 @@ export function useTerminalConnection({ tabId, xtermRef }: UseTerminalConnection
if (!connectionIdRef.current || closedId !== connectionIdRef.current) return
if (intentionalDisconnectRef.current) return
+ const kind = classifyDisconnectReason(reason)
+ if (kind === 'idle-suspend') {
+ setDisconnectKind(kind)
+ setShowSuspendedOverlay(true)
+ setConnectionState('suspended')
+ updateTabStatus(tabId, 'disconnected')
+ return
+ }
+
logger.info('[Terminal] Connection closed unexpectedly', closedId, reason)
connectionIdRef.current = null
shellIdRef.current = null
+ setDisconnectKind(kind)
setConnectionState('disconnected')
updateTabStatus(tabId, 'disconnected')
- xtermRef.current?.writeln(`\r\n\x1b[33m${i18n.t('terminal:connection.disconnectedBanner', { reason })}\x1b[0m`)
+ xtermRef.current?.writeln(
+ `\r\n\x1b[33m${i18n.t('terminal:connection.disconnectedBanner', { reason: translateErrorMessage(reason) })}\x1b[0m`
+ )
scheduleAutoReconnectRef.current?.(reason)
}
@@ -501,6 +529,7 @@ export function useTerminalConnection({ tabId, xtermRef }: UseTerminalConnection
clearReconnectTimer()
reconnectAttemptRef.current = 0
setReconnectAttempt(0)
+ setDisconnectKind('unexpected')
intentionalDisconnectRef.current = false
const tab = getTabById(tabId)
@@ -525,11 +554,14 @@ export function useTerminalConnection({ tabId, xtermRef }: UseTerminalConnection
connect()
}, [connect, updateTab, tabId, xtermRef, clearReconnectTimer, getTabById])
+ useEffect(() => subscribeTabReconnect(tabId, () => handleReconnect()), [tabId, handleReconnect])
+
return {
connectionState,
statusMessage,
showSuspendedOverlay,
reconnectAttempt,
+ disconnectKind,
connectionIdRef,
shellIdRef,
hasConnectedRef,
diff --git a/src/i18n/format.ts b/src/i18n/format.ts
index 712126f..8980251 100644
--- a/src/i18n/format.ts
+++ b/src/i18n/format.ts
@@ -4,21 +4,37 @@ import { intlLocale } from './detectLocale'
/** Map stable backend / FE error phrases to i18n keys; fall back to raw text. */
const ERROR_PHRASE_MAP: Array<{ match: RegExp | string; key: string }> = [
{ match: /连接失败,请检查网络|connection failed/i, key: 'errors:connection.failed' },
- { match: /timed out waiting for input|TMOUT|auto-logout/i, key: 'errors:connection.idleKicked' },
+ { match: /^shell\.idleTimeout$|timed out waiting for input|TMOUT|auto-logout/i, key: 'errors:connection.idleKicked' },
+ { match: /^shell\.exited$/i, key: 'errors:connection.shellExited' },
+ { match: /^shell\.readFailed|远端 Shell 读取失败/i, key: 'errors:connection.shellReadFailed' },
{ match: /连接超时|timed?\s*out/i, key: 'errors:connection.timeout' },
{ match: /认证失败|auth(entication)? failed/i, key: 'errors:auth.failed' },
{ match: /主机不可达|unreachable/i, key: 'errors:connection.hostUnreachable' },
{ match: /保活失败|keepalive failed/i, key: 'errors:connection.keepaliveFailed' },
- { match: /连接已关闭|connection closed/i, key: 'errors:connection.closed' },
+ { match: /closed by user|connection closed/i, key: 'errors:connection.unexpectedDropped' },
+ { match: /连接已关闭/i, key: 'errors:connection.closed' },
{ match: /终端创建失败/i, key: 'errors:shell.createFailed' },
{ match: /命令执行失败/i, key: 'errors:shell.execFailed' },
{ match: /目标地址不能是 0\.0\.0\.0|目标不能是 0\.0\.0\.0/i, key: 'errors:forward.invalidTargetHost' },
- { match: /建立远程端口转发失败: 远端拒绝监听特权端口|建立远程端口转发失败: 远端拒绝特权端口/i, key: 'errors:forward.remoteDeniedPrivileged' },
- { match: /建立远程端口转发失败: 远端拒绝|Unable to complete request for forward-listen|REQUEST_DENIED|Session\(-32\)/i, key: 'errors:forward.remoteDenied' },
+ {
+ match: /建立远程端口转发失败: 远端拒绝监听特权端口|建立远程端口转发失败: 远端拒绝特权端口/i,
+ key: 'errors:forward.remoteDeniedPrivileged',
+ },
+ {
+ match:
+ /建立远程端口转发失败: 远端拒绝|Unable to complete request for forward-listen|REQUEST_DENIED|Session\(-32\)/i,
+ key: 'errors:forward.remoteDenied',
+ },
{ match: /建立远程端口转发失败|端口转发设置失败/i, key: 'errors:forward.failed' },
- { match: /Address already in use|os error 48|EADDRINUSE|监听本地端口失败|本地端口已被占用/i, key: 'errors:forward.alreadyInUse' },
+ {
+ match: /Address already in use|os error 48|EADDRINUSE|监听本地端口失败|本地端口已被占用/i,
+ key: 'errors:forward.alreadyInUse',
+ },
{ match: /SFTP 正在传输文件|transfer is in progress/i, key: 'errors:sftp.busy' },
- { match: /Unable to startup channel|创建 SFTP 通道失败|MaxSessions|channel failure/i, key: 'errors:sftp.channelFailed' },
+ {
+ match: /Unable to startup channel|创建 SFTP 通道失败|MaxSessions|channel failure/i,
+ key: 'errors:sftp.channelFailed',
+ },
{ match: /SFTP 会话不存在|SSH 连接不存在/i, key: 'errors:sftp.sessionGone' },
{ match: /文件过大|编辑上限约 2MB|too large|edit limit/i, key: 'errors:sftp.editTooLarge' },
{ match: /像是二进制|binary content/i, key: 'errors:sftp.editBinary' },
@@ -36,38 +52,53 @@ const ERROR_PHRASE_MAP: Array<{ match: RegExp | string; key: string }> = [
{ match: /主机地址格式/i, key: 'errors:validation.invalidHost' },
{ match: /端口号必须/i, key: 'errors:validation.invalidPort' },
{ match: /凭据格式/i, key: 'errors:validation.invalidCredentials' },
+ { match: /命令已终止|command (was )?(cancelled|canceled)|command terminated/i, key: 'ai:step.terminated' },
{ match: /剪贴板/i, key: 'errors:clipboard.readFailed' },
]
+function lookupErrorCode(code: string): string | null {
+ const coded = i18n.t(`errors:${code}`)
+ if (coded && coded !== `errors:${code}` && coded !== code) return coded
+ return null
+}
+
export function translateErrorMessage(raw: string | undefined | null): string {
const text = (raw || '').trim()
if (!text) return i18n.t('errors:unknown.error')
+ if (text.startsWith('native.')) {
+ const coded = lookupErrorCode(text)
+ if (coded) return coded
+ }
for (const entry of ERROR_PHRASE_MAP) {
- const hit =
- typeof entry.match === 'string' ? text.includes(entry.match) : entry.match.test(text)
+ const hit = typeof entry.match === 'string' ? text.includes(entry.match) : entry.match.test(text)
if (hit) return i18n.t(entry.key)
}
return text
}
-export function formatLocaleDate(
- date: Date,
- options?: Intl.DateTimeFormatOptions
-): string {
+/** Prefer a stable `errorCode` from a command envelope, then fall back to phrase matching. */
+export function translateIpcError(error?: string | null, errorCode?: string | null): string {
+ if (errorCode) {
+ const coded = lookupErrorCode(errorCode)
+ if (coded) return coded
+ }
+ return translateErrorMessage(error)
+}
+
+/** Native `ssh_exec` cancel from Agent Stop. Backend still returns a Chinese phrase. */
+export function isSshExecCancelled(error?: string | null): boolean {
+ return /已终止|command (was )?(cancelled|canceled)|command terminated/i.test(error || '')
+}
+
+export function formatLocaleDate(date: Date, options?: Intl.DateTimeFormatOptions): string {
return date.toLocaleDateString(intlLocale(i18n.language), options)
}
-export function formatLocaleTime(
- date: Date,
- options?: Intl.DateTimeFormatOptions
-): string {
+export function formatLocaleTime(date: Date, options?: Intl.DateTimeFormatOptions): string {
return date.toLocaleTimeString(intlLocale(i18n.language), options)
}
-export function formatLocaleDateTime(
- date: Date,
- options?: Intl.DateTimeFormatOptions
-): string {
+export function formatLocaleDateTime(date: Date, options?: Intl.DateTimeFormatOptions): string {
return date.toLocaleString(intlLocale(i18n.language), options)
}
diff --git a/src/i18n/locales/en/ai.json b/src/i18n/locales/en/ai.json
index 7a36866..93c8f3e 100644
--- a/src/i18n/locales/en/ai.json
+++ b/src/i18n/locales/en/ai.json
@@ -16,10 +16,10 @@
"stop": "Stop",
"send": "Send",
"placeholder": "Task or follow-up… (Enter to send, Shift+Enter for newline)",
- "hintFollowUp": "Follow-ups include this thread's context",
+ "hintFollowUp": "Follow-ups include a truncated summary of this thread",
"hintSelection": "Will include {{lines}} selected lines",
- "hintSteps": "Up to {{max}} steps · nothing persisted",
- "footerNote": "High risk requires confirmation · critical is blocked · session not saved",
+ "hintSteps": "Up to {{max}} steps",
+ "footerNote": "High-risk commands need confirmation · destructive ones are blocked · chat is not saved",
"summarize": "Summarize",
"summarizeTitle": "Summarize the visible terminal output with AI (selection first); never runs commands",
"summarizeRequest": "Summarize the current terminal output"
@@ -77,7 +77,7 @@
"invalidCommand": "Model did not return a single one-line command; execution refused",
"repeatLoop": "Repeated command loop detected; stopped: {{command}}",
"criticalBlocked": "Command hit a destructive hard block; no override: {{command}}",
- "execFailed": "ssh-exec failed: {{error}}",
+ "execFailed": "Remote execution failed: {{error}}",
"execUnknown": "Unknown error",
"execNoOutput": "Command finished with no output",
"maxSteps": "Reached {{max}}-step limit; narrow the scope and retry",
diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json
index 9b7c2a0..d8e0ad7 100644
--- a/src/i18n/locales/en/common.json
+++ b/src/i18n/locales/en/common.json
@@ -33,8 +33,10 @@
"error": {
"title": "Something went wrong",
"body": "The app hit an unexpected problem",
+ "bodyPane": "This tab hit an unexpected problem. Other tabs still work.",
"recoverable": "This error is recoverable. Try refreshing the page.",
"refreshPage": "Refresh page",
+ "retryPane": "Retry this tab",
"restartApp": "Restart app",
"supportHint": "If the problem persists, check logs or contact support"
},
@@ -104,11 +106,12 @@
},
"menu": {
"closeOthers": "Close others",
- "closeRight": "Close tabs to the right",
- "closeLeft": "Close tabs to the left",
+ "closeRight": "Close right tabs",
+ "closeLeft": "Close left tabs",
"closeAll": "Close all",
- "clone": "Duplicate tab",
- "openSftp": "Open SFTP"
+ "clone": "Clone tab",
+ "openSftp": "Open SFTP",
+ "reconnect": "Reconnect"
},
"renameAria": "Rename tab {{title}}",
"renameHint": "Double-click to rename"
diff --git a/src/i18n/locales/en/desktop.json b/src/i18n/locales/en/desktop.json
index d1ddaa4..ee29396 100644
--- a/src/i18n/locales/en/desktop.json
+++ b/src/i18n/locales/en/desktop.json
@@ -35,13 +35,17 @@
"fullscreenAria": "Enter fullscreen",
"exitFullscreen": "Exit fullscreen",
"exitFullscreenAria": "Exit fullscreen",
- "exitFullscreenHint": "Esc / F11 to exit"
+ "exitFullscreenHint": "Esc / F11 to exit",
+ "reconnect": "Reconnect",
+ "reconnectAria": "Reconnect remote desktop"
},
"status": {
"connecting": "Connecting…",
"connected": "Connected",
"disconnected": "Disconnected",
"connectFailed": "Connection failed",
+ "reconnecting": "Auto-reconnecting ({{attempt}}/{{max}})…",
+ "reconnectLimit": "Auto-reconnect limit reached ({{max}})",
"clipboardSynced": "Remote clipboard synced",
"savedToDownloads": "Saved to local Downloads: {{name}}",
"pastedToRemote": "Pasted to remote",
diff --git a/src/i18n/locales/en/errors.json b/src/i18n/locales/en/errors.json
index a98b643..86692f9 100644
--- a/src/i18n/locales/en/errors.json
+++ b/src/i18n/locales/en/errors.json
@@ -41,7 +41,10 @@
"refused": "Connection refused",
"disconnected": "Disconnected",
"keepaliveFailed": "SSH keep-alive failed repeatedly; connection closed",
- "idleKicked": "Remote shell logged out due to idle timeout (TMOUT)"
+ "idleKicked": "Remote shell logged out due to idle timeout (TMOUT)",
+ "unexpectedDropped": "Disconnected (network drop or remote close)",
+ "shellExited": "Remote shell exited (exit, Ctrl+D, or server policy)",
+ "shellReadFailed": "Failed to read from the remote shell"
},
"auth": {
"failed": "Authentication failed. Check the username and password.",
@@ -160,7 +163,7 @@
"invalidCommand": "Model did not return a single one-line command; execution refused",
"repeatLoop": "Repeated command loop detected; stopped: {{command}}",
"criticalBlocked": "Command hit a destructive hard block; no override: {{command}}",
- "execFailed": "ssh-exec failed: {{error}}",
+ "execFailed": "Remote execution failed: {{error}}",
"maxSteps": "Reached {{max}}-step limit; narrow the scope and retry",
"sshRequired": "A connected remote SSH session is required"
},
@@ -168,6 +171,12 @@
"readFailed": "Read failed: {{name}}",
"writeFailed": "Write failed: {{name}}"
},
+ "native": {
+ "taskJoin": "A background task failed. Retry the operation.",
+ "invalidBase64": "File data is not valid Base64.",
+ "knownHostsPath": "Could not locate ~/.ssh/known_hosts.",
+ "invalidHostKeyDecision": "Invalid host key decision."
+ },
"unknown": {
"error": "An unknown error occurred"
}
diff --git a/src/i18n/locales/en/sessions.json b/src/i18n/locales/en/sessions.json
index 5e9ea11..ecc875b 100644
--- a/src/i18n/locales/en/sessions.json
+++ b/src/i18n/locales/en/sessions.json
@@ -132,7 +132,8 @@
"collapse": "Collapse",
"protocol": "Protocol",
"displayName": "Display name (optional)",
- "displayNamePlaceholder": "Defaults to user@host or vnc:host",
+ "displayNamePlaceholder": "Defaults to user@host",
+ "displayNamePlaceholderVnc": "Defaults to the host name",
"host": "Host",
"hostPlaceholder": "192.168.1.10",
"hostHint": "IP or hostname; host:port is also accepted",
@@ -208,7 +209,8 @@
},
"importExport": {
"exportTitle": "Export sessions",
- "exportDefaultPath": "opsxshell-sessions.bak",
+ "exportDefaultPath": "opsxshell-hosts.oxs",
+ "exportFilterOxs": "OpsXShell host backup",
"exportFilterBak": "OpsXShell backup",
"exportFilterJson": "JSON",
"exportSuccess": "Exported {{count}} sessions to:\n{{path}}",
@@ -224,6 +226,7 @@
"exportPasswordMismatch": "Passwords do not match",
"exportPasswordRequired": "A backup password is required to export",
"importTitle": "Import sessions",
+ "importFilterOxs": "OpsXShell host backup",
"importFilterBak": "OpsXShell backup",
"importFilterJson": "JSON",
"importFilterSshConfig": "SSH Config",
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index aa3f4d7..6310c92 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -13,7 +13,7 @@
"ai": "AI models",
"aiDesc": "AI providers",
"about": "About",
- "aboutDesc": "Version and data",
+ "aboutDesc": "Version, contact, and diagnostics",
"account": "Account",
"accountDesc": "Email, license, and device"
},
@@ -89,22 +89,23 @@
"ssh": {
"connection": "Connection",
"keepAlive": "Keep-alive",
- "keepAliveDesc": "Send SSH keep-alive while idle; disconnect only after several missed replies",
+ "keepAliveDesc": "Send SSH protocol keep-alive while idle (not typed into the shell). Beats NAT drops; does not bypass remote TMOUT",
"autoReconnect": "Auto reconnect",
- "autoReconnectDesc": "Automatically retry after unexpected disconnects",
+ "autoReconnectDesc": "Retry after unexpected disconnects while the tab stays open (new shell). Closing the tab, idle suspend, or remote TMOUT are not forced alive",
"timeout": "Connection timeout",
- "timeoutDesc": "Maximum wait time when establishing SSH (seconds)",
- "maxReconnect": "Reconnect attempts",
- "maxReconnectDesc": "Maximum retries after unexpected disconnect",
+ "timeoutDesc": "Maximum wait when establishing an SSH connection (seconds)",
+ "maxReconnect": "Auto-reconnect attempts",
+ "maxReconnectDesc": "Maximum retries after an unexpected disconnect",
"pool": "Connection pool",
- "maxConnections": "Max connections",
- "maxConnectionsDesc": "Shared cap for SSH, SFTP, and batch connections",
+ "maxConnections": "Maximum connections",
+ "maxConnectionsDesc": "Shared by SSH, SFTP, and batch connections",
"maxBackground": "Background connection limit",
- "maxBackgroundDesc": "Evict least recently used connections when over limit",
+ "maxBackgroundDesc": "When exceeded, the least recently used connection is reclaimed first",
"suspend": "Background suspend",
- "suspendDesc": "Minutes before idle background tabs suspend (active Shell/SFTP stay connected)",
+ "suspendDesc": "Minutes before a background tab is suspended (kept alive while a shell or SFTP is active)",
"keepAliveInterval": "Keep-alive interval (sec)",
- "keepAliveIntervalDesc": "How often to ping the server (like ClientAliveInterval). Use 15s or less on flaky Wi‑Fi / NAT"
+ "keepAliveIntervalDesc": "How often to ping the server (like ClientAliveInterval). Use 15s or less on flaky Wi‑Fi / NAT",
+ "sessionPolicyHint": "Keep-alive protects the TCP link, not remote session policy. Bash TMOUT, sshd ClientAlive, and Windows idle GPO still apply. Use tmux/screen on the server for long jobs."
},
"security": {
"credentials": "Credential protection",
@@ -122,16 +123,25 @@
},
"about": {
"tagline": "Simple, practical native ops terminal",
- "appVersion": "App v{{version}}",
- "copyVersion": "Copy version info",
- "runtime": "Runtime",
- "system": "System",
- "localData": "Local data",
+ "appVersion": "v{{version}}",
+ "copyVersion": "Copy version, system, and paths for a GitHub issue",
+ "copyDiagnostics": "Copy diagnostics",
+ "copyPath": "Copy path",
+ "resources": "Resources",
+ "wechat": "WeChat",
+ "copyWechat": "Copy WeChat ID",
+ "github": "Discussions",
+ "githubDesc": "https://github.com/L00J/opsxshell/discussions",
+ "releases": "Releases",
+ "releasesDesc": "Installers and release notes",
+ "licenseLink": "License",
+ "licenseLinkDesc": "MIT License",
+ "diagnostics": "Diagnostics",
"sessionsSettings": "Sessions and settings",
- "masterKeyPath": "Master key",
- "privacyNote": "Passwords and key passphrases are AES-256-GCM encrypted in Rust. No terminal history, no bastion audit trail.",
"license": "MIT License · Rust · XTerm.js",
- "logPath": "Application log"
+ "logPath": "Application log",
+ "reportBug": "Report a bug",
+ "reportBugDesc": "Opens GitHub with this version and platform filled in"
},
"ai": {
"presets": "Provider presets",
@@ -157,7 +167,9 @@
"otherModels": "Other models…",
"customModel": "Custom model",
"backToList": "Back to list",
+ "apiKey": "API Key",
"apiKeyOptional": "API Key (not required)",
+ "endpoint": "Endpoint",
"advanced": "Advanced",
"allowHttp": "Allow HTTP (localhost/LAN)",
"setActive": "Set as active",
diff --git a/src/i18n/locales/en/sftp.json b/src/i18n/locales/en/sftp.json
index 95a73d6..ebc03f1 100644
--- a/src/i18n/locales/en/sftp.json
+++ b/src/i18n/locales/en/sftp.json
@@ -15,7 +15,8 @@
"notConnected": "Not connected",
"connecting": "Connecting...",
"resumeHint": "Reconnect will resume at {{path}}",
- "reconnectingResume": "Reconnecting and resuming {{path}}…"
+ "reconnectingResume": "Reconnecting and resuming {{path}}…",
+ "autoReconnecting": "Auto-reconnecting ({{attempt}}/{{max}})…"
},
"errors": {
"notConnected": "SFTP is not connected",
diff --git a/src/i18n/locales/en/terminal.json b/src/i18n/locales/en/terminal.json
index 57a7203..9268d26 100644
--- a/src/i18n/locales/en/terminal.json
+++ b/src/i18n/locales/en/terminal.json
@@ -34,7 +34,9 @@
"reopenShell": "Reopen shell",
"reopening": "Opening…",
"disconnected": "Disconnected",
- "disconnectedHint": "The SSH session ended or the network dropped. One click below reconnects so you can keep working.",
+ "disconnectedHint": "The network dropped or the server closed SSH. The tab will auto-reconnect while it stays open, or reconnect now with the button below.",
+ "disconnectedKeepalive": "Keep-alive failed; disconnected",
+ "disconnectedKeepaliveHint": "SSH keep-alive got no reply — usually a NAT or idle network drop. No keystrokes are sent into the shell. The tab will auto-reconnect while it stays open.",
"reconnect": "Reconnect",
"suspended": "Connection suspended",
"suspendedHint": "Inactive for a while; connection was suspended to save resources",
diff --git a/src/i18n/locales/ja/ai.json b/src/i18n/locales/ja/ai.json
index 82b954b..1494eee 100644
--- a/src/i18n/locales/ja/ai.json
+++ b/src/i18n/locales/ja/ai.json
@@ -16,10 +16,10 @@
"stop": "停止",
"send": "送信",
"placeholder": "タスクまたはフォローアップ… (Enter で送信 · Shift+Enter で改行)",
- "hintFollowUp": "フォローアップにはこのスレッドのコンテキストが含まれます",
+ "hintFollowUp": "フォローアップにはこのスレッドの要約が含まれます(長い出力は切り詰めます)",
"hintSelection": "選択した {{lines}} 行を含めます",
- "hintSteps": "最大 {{max}} ステップ · 何も保存しません",
- "footerNote": "高リスクは確認が必要 · クリティカルはブロック · セッションは保存されません",
+ "hintSteps": "最大 {{max}} ステップ",
+ "footerNote": "高リスクは先に確認 · 破壊的なコマンドは拒否 · 会話は保存しません",
"summarize": "出力を要約",
"summarizeTitle": "表示中の端末出力を AI が要約します(選択範囲を優先)。コマンドは実行しません",
"summarizeRequest": "現在の端末出力を要約"
@@ -77,7 +77,7 @@
"invalidCommand": "モデルが単一行のコマンドを返しませんでした · 実行を拒否",
"repeatLoop": "繰り返しのコマンドループを検出 · 停止: {{command}}",
"criticalBlocked": "コマンドが破壊的なハードブロックに該当 · 上書き不可: {{command}}",
- "execFailed": "ssh-exec 失敗: {{error}}",
+ "execFailed": "リモート実行に失敗しました: {{error}}",
"execUnknown": "不明なエラー",
"execNoOutput": "コマンドは出力なしで完了",
"maxSteps": "{{max}} ステップの上限に達しました · 範囲を絞って再試行してください",
diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json
index ee6e9f2..ad62b62 100644
--- a/src/i18n/locales/ja/common.json
+++ b/src/i18n/locales/ja/common.json
@@ -33,8 +33,10 @@
"error": {
"title": "問題が発生しました",
"body": "アプリで予期しない問題が発生しました",
+ "bodyPane": "このタブで問題が発生しました。他のタブは引き続き使えます。",
"recoverable": "このエラーは復旧可能です。ページを更新してみてください。",
"refreshPage": "ページを更新",
+ "retryPane": "このタブを再試行",
"restartApp": "アプリを再起動",
"supportHint": "問題が解決しない場合は、ログを確認するかサポートにお問い合わせください"
},
@@ -108,7 +110,8 @@
"closeLeft": "左側のタブを閉じる",
"closeAll": "すべて閉じる",
"clone": "タブを複製",
- "openSftp": "SFTP を開く"
+ "openSftp": "SFTP を開く",
+ "reconnect": "再接続"
},
"renameAria": "タブ {{title}} の名前を変更",
"renameHint": "ダブルクリックで名前変更"
diff --git a/src/i18n/locales/ja/desktop.json b/src/i18n/locales/ja/desktop.json
index 2b0692a..9d05730 100644
--- a/src/i18n/locales/ja/desktop.json
+++ b/src/i18n/locales/ja/desktop.json
@@ -35,13 +35,17 @@
"fullscreenAria": "全画面表示にする",
"exitFullscreen": "全画面を終了",
"exitFullscreenAria": "全画面を終了",
- "exitFullscreenHint": "Esc / F11 で終了"
+ "exitFullscreenHint": "Esc / F11 で終了",
+ "reconnect": "再接続",
+ "reconnectAria": "リモートデスクトップを再接続"
},
"status": {
"connecting": "接続中…",
"connected": "接続済み",
"disconnected": "切断済み",
"connectFailed": "接続失敗",
+ "reconnecting": "自動再接続中({{attempt}}/{{max}})…",
+ "reconnectLimit": "自動再接続の上限に達しました({{max}})",
"clipboardSynced": "リモートのクリップボードを同期しました",
"savedToDownloads": "ローカルの Downloads に保存: {{name}}",
"pastedToRemote": "リモートに貼り付けました",
diff --git a/src/i18n/locales/ja/errors.json b/src/i18n/locales/ja/errors.json
index 91424a2..5ede076 100644
--- a/src/i18n/locales/ja/errors.json
+++ b/src/i18n/locales/ja/errors.json
@@ -41,7 +41,10 @@
"refused": "接続が拒否されました",
"disconnected": "切断されました",
"keepaliveFailed": "SSH キープアライブが連続失敗し、切断されました",
- "idleKicked": "リモート Shell がアイドルタイムアウトで終了しました(TMOUT)"
+ "idleKicked": "リモート Shell がアイドルタイムアウトで終了しました(TMOUT)",
+ "unexpectedDropped": "切断されました(ネットワーク切断またはサーバー側クローズ)",
+ "shellExited": "リモート Shell が終了しました(exit、Ctrl+D、またはサーバーポリシー)",
+ "shellReadFailed": "リモート Shell の読み取りに失敗しました"
},
"auth": {
"failed": "認証に失敗しました。ユーザー名とパスワードを確認してください。",
@@ -160,7 +163,7 @@
"invalidCommand": "モデルが単一行のコマンドを返しませんでした · 実行を拒否",
"repeatLoop": "繰り返しのコマンドループを検出 · 停止: {{command}}",
"criticalBlocked": "コマンドが破壊的なハードブロックに該当 · 上書き不可: {{command}}",
- "execFailed": "ssh-exec 失敗: {{error}}",
+ "execFailed": "リモート実行に失敗しました: {{error}}",
"maxSteps": "{{max}} ステップの上限に達しました · 範囲を絞って再試行してください",
"sshRequired": "接続済みのリモート SSH セッションが必要です"
},
@@ -168,6 +171,12 @@
"readFailed": "読み取り失敗: {{name}}",
"writeFailed": "書き込み失敗: {{name}}"
},
+ "native": {
+ "taskJoin": "バックグラウンド処理に失敗しました。再試行してください。",
+ "invalidBase64": "ファイルデータが有効な Base64 ではありません。",
+ "knownHostsPath": "~/.ssh/known_hosts を特定できません。",
+ "invalidHostKeyDecision": "ホスト鍵の決定が無効です。"
+ },
"unknown": {
"error": "不明なエラーが発生しました"
}
diff --git a/src/i18n/locales/ja/sessions.json b/src/i18n/locales/ja/sessions.json
index 124688b..867850f 100644
--- a/src/i18n/locales/ja/sessions.json
+++ b/src/i18n/locales/ja/sessions.json
@@ -132,7 +132,8 @@
"collapse": "折りたたむ",
"protocol": "プロトコル",
"displayName": "表示名 (任意)",
- "displayNamePlaceholder": "デフォルトは user@host または vnc:host",
+ "displayNamePlaceholder": "デフォルトは user@host",
+ "displayNamePlaceholderVnc": "デフォルトはホスト名",
"host": "ホスト",
"hostPlaceholder": "192.168.1.10",
"hostHint": "IP またはホスト名 · host:port も可",
@@ -208,7 +209,8 @@
},
"importExport": {
"exportTitle": "セッションをエクスポート",
- "exportDefaultPath": "opsxshell-sessions.bak",
+ "exportDefaultPath": "opsxshell-hosts.oxs",
+ "exportFilterOxs": "OpsXShell ホストバックアップ",
"exportFilterBak": "OpsXShell バックアップ",
"exportFilterJson": "JSON",
"exportSuccess": "{{count}} 件のセッションをエクスポート:\n{{path}}",
@@ -224,6 +226,7 @@
"exportPasswordMismatch": "パスワードが一致しません",
"exportPasswordRequired": "エクスポートにはバックアップパスワードが必要です",
"importTitle": "セッションをインポート",
+ "importFilterOxs": "OpsXShell ホストバックアップ",
"importFilterBak": "OpsXShell バックアップ",
"importFilterJson": "JSON",
"importFilterSshConfig": "SSH Config",
diff --git a/src/i18n/locales/ja/settings.json b/src/i18n/locales/ja/settings.json
index 71db638..2da7bc4 100644
--- a/src/i18n/locales/ja/settings.json
+++ b/src/i18n/locales/ja/settings.json
@@ -13,7 +13,7 @@
"ai": "AI モデル",
"aiDesc": "AI プロバイダー",
"about": "情報",
- "aboutDesc": "バージョンとデータ",
+ "aboutDesc": "バージョン、連絡先、診断",
"account": "アカウント",
"accountDesc": "メール、ライセンス、デバイス"
},
@@ -89,9 +89,9 @@
"ssh": {
"connection": "接続",
"keepAlive": "キープアライブ",
- "keepAliveDesc": "アイドル時に SSH キープアライブを送信。数回連続失敗してから切断",
+ "keepAliveDesc": "アイドル時に SSH プロトコルのキープアライブを送信(シェルへは入力しません)。NAT 切断対策であり、遠隔 TMOUT は迂回しません",
"autoReconnect": "自動再接続",
- "autoReconnectDesc": "予期しない切断後に自動で再試行します",
+ "autoReconnectDesc": "タブを開いたままの予期しない切断後に自動再接続(新しいシェル)。タブを閉じた場合、アイドル休止、遠隔 TMOUT は強制維持しません",
"timeout": "接続タイムアウト",
"timeoutDesc": "SSH 接続確立の最大待機時間(秒)",
"maxReconnect": "再接続回数",
@@ -104,7 +104,8 @@
"suspend": "バックグラウンド休止",
"suspendDesc": "アイドルのバックグラウンドタブを何分後に休止するか(稼働中の Shell/SFTP は維持)",
"keepAliveInterval": "キープアライブ間隔(秒)",
- "keepAliveIntervalDesc": "サーバーへの ping 間隔(ClientAliveInterval 相当)。不安定な回線では 15 秒以下を推奨"
+ "keepAliveIntervalDesc": "サーバーへの ping 間隔(ClientAliveInterval 相当)。不安定な回線では 15 秒以下を推奨",
+ "sessionPolicyHint": "キープアライブは TCP 経路を守るもので、遠隔のセッション方針は変えません。Bash TMOUT、sshd ClientAlive、Windows のアイドル GPO は有効です。長時間ジョブはサーバー側で tmux/screen を使ってください。"
},
"security": {
"credentials": "資格情報の保護",
@@ -122,16 +123,25 @@
},
"about": {
"tagline": "シンプルで使いやすいネイティブ運用端末",
- "appVersion": "アプリ v{{version}}",
- "copyVersion": "バージョン情報をコピー",
- "runtime": "ランタイム",
- "system": "システム",
- "localData": "ローカルデータ",
+ "appVersion": "v{{version}}",
+ "copyVersion": "バージョン・環境・パスをコピーして GitHub に貼り付け",
+ "copyDiagnostics": "診断情報をコピー",
+ "copyPath": "パスをコピー",
+ "resources": "リソース",
+ "wechat": "WeChat",
+ "copyWechat": "WeChat ID をコピー",
+ "github": "ディスカッション",
+ "githubDesc": "https://github.com/L00J/opsxshell/discussions",
+ "releases": "リリース",
+ "releasesDesc": "インストーラーと更新内容",
+ "licenseLink": "ライセンス",
+ "licenseLinkDesc": "MIT License",
+ "diagnostics": "診断",
"sessionsSettings": "セッションと設定",
- "masterKeyPath": "マスターキー",
- "privacyNote": "パスワードと鍵パスフレーズは Rust 側で AES-256-GCM 暗号化。コマンド履歴も監査ログも保存しません。",
"license": "MIT License · Rust · XTerm.js",
- "logPath": "アプリログ"
+ "logPath": "アプリログ",
+ "reportBug": "不具合を報告",
+ "reportBugDesc": "GitHub issue を開き、現在のバージョンと環境を記入します"
},
"ai": {
"presets": "プロバイダープリセット",
@@ -151,18 +161,20 @@
"saved": "保存済み",
"retest": "再テストしてください",
"testFailed": "接続テストに失敗しました",
- "available": "OK · {{latency}} ms",
+ "available": "利用可 · {{latency}} ms",
"savedKeepBlank": "保存済み — 空欄で維持",
"notRequired": "不要",
"otherModels": "その他のモデル…",
"customModel": "カスタムモデル",
"backToList": "一覧に戻る",
- "apiKeyOptional": "API Key(不要)",
+ "apiKey": "API キー",
+ "apiKeyOptional": "API キー(不要)",
+ "endpoint": "エンドポイント",
"advanced": "詳細",
"allowHttp": "HTTP を許可(localhost / LAN)",
"setActive": "現在のプロバイダーに設定",
"deleteTitle": "削除",
- "apiKeySaveFailed": "API Key の保存に失敗しました",
+ "apiKeySaveFailed": "API キーの保存に失敗しました",
"providerNotFound": "プロバイダーが見つかりません",
"modelCatalogReadFailed": "モデルカタログの読み取りに失敗しました",
"catalogUnavailable": "models.dev を利用できません",
@@ -175,26 +187,26 @@
}
},
"account": {
- "title": "Account",
- "email": "Email",
- "emailDesc": "Used for checkout and license claims",
- "plan": "Current plan",
- "planFree": "Free",
+ "title": "アカウント",
+ "email": "メール",
+ "emailDesc": "決済とライセンス受け取りに使う連絡先メール",
+ "plan": "現在のプラン",
+ "planFree": "無料",
"planYearly": "Pro 年額",
"planPerpetual": "Pro 永久",
"planTrial": "Pro 体験",
- "expires": "Expires {{date}}",
- "lifetime": "Lifetime",
- "devices": "Seats: up to {{count}} devices",
- "deviceId": "This device ID",
- "saveEmail": "Save email",
- "activate": "Activate license",
- "pasteLicense": "Paste LIC- license key",
- "clear": "Clear local license",
- "deactivate": "Deactivate this device",
- "upgrade": "Upgrade",
- "saved": "Saved",
- "activateOk": "Activated",
+ "expires": "{{date}} まで有効",
+ "lifetime": "無期限",
+ "devices": "デバイス席:最大 {{count}} 台",
+ "deviceId": "このデバイスの ID",
+ "saveEmail": "メールを保存",
+ "activate": "ライセンスを有効化",
+ "pasteLicense": "LIC- ライセンスを貼り付け",
+ "clear": "この端末のライセンスを消去",
+ "deactivate": "この端末の有効化を解除",
+ "upgrade": "Pro にアップグレード",
+ "saved": "保存しました",
+ "activateOk": "有効化しました",
"deviceList": "紐付けデバイス",
"loadingDevices": "デバイスを読み込み中…",
"noDevices": "紐付けデバイスはありません",
diff --git a/src/i18n/locales/ja/sftp.json b/src/i18n/locales/ja/sftp.json
index 12cc3a3..d1fddff 100644
--- a/src/i18n/locales/ja/sftp.json
+++ b/src/i18n/locales/ja/sftp.json
@@ -15,7 +15,8 @@
"notConnected": "未接続",
"connecting": "接続中...",
"resumeHint": "再接続後は {{path}} に戻ります",
- "reconnectingResume": "{{path}} を復元して再接続中…"
+ "reconnectingResume": "{{path}} を復元して再接続中…",
+ "autoReconnecting": "自動再接続中({{attempt}}/{{max}})…"
},
"errors": {
"notConnected": "SFTP に接続されていません",
diff --git a/src/i18n/locales/ja/terminal.json b/src/i18n/locales/ja/terminal.json
index c0575f0..4d9ffd1 100644
--- a/src/i18n/locales/ja/terminal.json
+++ b/src/i18n/locales/ja/terminal.json
@@ -34,7 +34,9 @@
"reopenShell": "シェルを再開",
"reopening": "開いています…",
"disconnected": "切断されました",
- "disconnectedHint": "SSH セッション終了またはネットワーク切断。下のボタンで再接続して作業を続けられます。",
+ "disconnectedHint": "ネットワーク切断、またはサーバーが SSH を閉じました。タブを開いたままなら自動再接続します。下のボタンですぐに再接続もできます。",
+ "disconnectedKeepalive": "キープアライブ失敗で切断",
+ "disconnectedKeepaliveHint": "SSH キープアライブに応答がありません。NAT やアイドル切断が典型です。シェルへキー入力は送りません。タブを開いたままなら自動再接続します。",
"reconnect": "再接続",
"suspended": "接続が一時停止",
"suspendedHint": "しばらく操作がなかったため、リソース節約のため接続を一時停止しました",
diff --git a/src/i18n/locales/ko/ai.json b/src/i18n/locales/ko/ai.json
index 9050b9a..2a826d6 100644
--- a/src/i18n/locales/ko/ai.json
+++ b/src/i18n/locales/ko/ai.json
@@ -16,10 +16,10 @@
"stop": "중지",
"send": "전송",
"placeholder": "작업 또는 후속 질문… (Enter로 전송, Shift+Enter로 줄 바꿈)",
- "hintFollowUp": "후속 질문은 이 스레드의 컨텍스트를 포함합니다",
+ "hintFollowUp": "후속 질문에는 이 스레드 요약이 포함됩니다(너무 길면 잘림)",
"hintSelection": "선택한 {{lines}}줄을 포함합니다",
- "hintSteps": "최대 {{max}}단계 · 저장되지 않음",
- "footerNote": "고위험은 확인 필요 · 치명적 작업은 차단됨 · 세션 저장 안 함",
+ "hintSteps": "최대 {{max}}단계",
+ "footerNote": "고위험은 먼저 확인 · 파괴적 명령은 거부 · 대화는 저장되지 않음",
"summarize": "출력 요약",
"summarizeTitle": "보이는 터미널 출력을 AI로 요약합니다(선택 텍스트 우선). 명령은 실행하지 않습니다",
"summarizeRequest": "현재 터미널 출력 요약"
@@ -77,7 +77,7 @@
"invalidCommand": "모델이 단일 한 줄 명령을 반환하지 않았습니다; 실행이 거부되었습니다",
"repeatLoop": "반복된 명령 루프가 감지되었습니다; 중지됨: {{command}}",
"criticalBlocked": "명령이 파괴적인 하드 차단에 걸렸습니다; 재정의 불가: {{command}}",
- "execFailed": "ssh-exec 실패: {{error}}",
+ "execFailed": "원격 실행 실패: {{error}}",
"execUnknown": "알 수 없는 오류",
"execNoOutput": "명령이 출력 없이 완료됨",
"maxSteps": "{{max}}단계 한도에 도달했습니다; 범위를 좁히고 재시도하세요",
diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json
index 241e6f7..e1ee099 100644
--- a/src/i18n/locales/ko/common.json
+++ b/src/i18n/locales/ko/common.json
@@ -33,8 +33,10 @@
"error": {
"title": "문제가 발생했습니다",
"body": "앱에서 예기치 않은 문제가 발생했습니다",
+ "bodyPane": "이 탭에서 문제가 발생했습니다. 다른 탭은 계속 사용할 수 있습니다.",
"recoverable": "이 오류는 복구 가능합니다. 페이지를 새로고침해 보세요.",
"refreshPage": "페이지 새로고침",
+ "retryPane": "이 탭 다시 시도",
"restartApp": "앱 재시작",
"supportHint": "문제가 지속되면 로그를 확인하거나 지원팀에 문의하세요"
},
@@ -108,7 +110,8 @@
"closeLeft": "왼쪽 탭 닫기",
"closeAll": "모두 닫기",
"clone": "탭 복제",
- "openSftp": "SFTP 열기"
+ "openSftp": "SFTP 열기",
+ "reconnect": "다시 연결"
},
"renameAria": "탭 {{title}} 이름 바꾸기",
"renameHint": "더블 클릭해 이름 변경"
diff --git a/src/i18n/locales/ko/desktop.json b/src/i18n/locales/ko/desktop.json
index 6fee016..84fcef6 100644
--- a/src/i18n/locales/ko/desktop.json
+++ b/src/i18n/locales/ko/desktop.json
@@ -35,13 +35,17 @@
"fullscreenAria": "전체 화면으로 전환",
"exitFullscreen": "전체 화면 종료",
"exitFullscreenAria": "전체 화면 종료",
- "exitFullscreenHint": "Esc / F11로 종료"
+ "exitFullscreenHint": "Esc / F11로 종료",
+ "reconnect": "재연결",
+ "reconnectAria": "원격 데스크톱 다시 연결"
},
"status": {
"connecting": "연결 중…",
"connected": "연결됨",
"disconnected": "연결 끊김",
"connectFailed": "연결 실패",
+ "reconnecting": "자동 재연결 중 ({{attempt}}/{{max}})…",
+ "reconnectLimit": "자동 재연결 한도에 도달했습니다 ({{max}})",
"clipboardSynced": "원격 클립보드 동기화됨",
"savedToDownloads": "로컬 다운로드에 저장됨: {{name}}",
"pastedToRemote": "원격에 붙여넣음",
diff --git a/src/i18n/locales/ko/errors.json b/src/i18n/locales/ko/errors.json
index ae36a3c..00cd5c2 100644
--- a/src/i18n/locales/ko/errors.json
+++ b/src/i18n/locales/ko/errors.json
@@ -41,7 +41,10 @@
"refused": "연결이 거부되었습니다",
"disconnected": "연결 끊김",
"keepaliveFailed": "SSH keep-alive가 연속 실패하여 연결이 닫혔습니다",
- "idleKicked": "원격 Shell이 유휴 시간 초과로 종료되었습니다(TMOUT)"
+ "idleKicked": "원격 Shell이 유휴 시간 초과로 종료되었습니다(TMOUT)",
+ "unexpectedDropped": "연결이 끊겼습니다(네트워크 중단 또는 서버 종료)",
+ "shellExited": "원격 Shell이 종료되었습니다(exit, Ctrl+D 또는 서버 정책)",
+ "shellReadFailed": "원격 Shell 읽기에 실패했습니다"
},
"auth": {
"failed": "인증 실패. 사용자 이름과 비밀번호를 확인하세요.",
@@ -160,7 +163,7 @@
"invalidCommand": "모델이 단일 한 줄 명령을 반환하지 않았습니다; 실행이 거부되었습니다",
"repeatLoop": "반복된 명령 루프가 감지되었습니다; 중지됨: {{command}}",
"criticalBlocked": "명령이 파괴적인 하드 차단에 걸렸습니다; 재정의 불가: {{command}}",
- "execFailed": "ssh-exec 실패: {{error}}",
+ "execFailed": "원격 실행 실패: {{error}}",
"maxSteps": "{{max}}단계 한도에 도달했습니다; 범위를 좁히고 재시도하세요",
"sshRequired": "연결된 원격 SSH 세션이 필요합니다"
},
@@ -168,6 +171,12 @@
"readFailed": "읽기 실패: {{name}}",
"writeFailed": "쓰기 실패: {{name}}"
},
+ "native": {
+ "taskJoin": "백그라운드 작업에 실패했습니다. 다시 시도하세요.",
+ "invalidBase64": "파일 데이터가 올바른 Base64가 아닙니다.",
+ "knownHostsPath": "~/.ssh/known_hosts를 찾을 수 없습니다.",
+ "invalidHostKeyDecision": "호스트 키 결정이 올바르지 않습니다."
+ },
"unknown": {
"error": "알 수 없는 오류가 발생했습니다"
}
diff --git a/src/i18n/locales/ko/sessions.json b/src/i18n/locales/ko/sessions.json
index 765e080..fba60db 100644
--- a/src/i18n/locales/ko/sessions.json
+++ b/src/i18n/locales/ko/sessions.json
@@ -132,7 +132,8 @@
"collapse": "접기",
"protocol": "프로토콜",
"displayName": "표시 이름 (선택 사항)",
- "displayNamePlaceholder": "기본값은 user@host 또는 vnc:host",
+ "displayNamePlaceholder": "기본값은 user@host",
+ "displayNamePlaceholderVnc": "기본값은 호스트 이름",
"host": "호스트",
"hostPlaceholder": "192.168.1.10",
"hostHint": "IP 또는 호스트명; host:port도 허용됨",
@@ -208,7 +209,8 @@
},
"importExport": {
"exportTitle": "세션 내보내기",
- "exportDefaultPath": "opsxshell-sessions.bak",
+ "exportDefaultPath": "opsxshell-hosts.oxs",
+ "exportFilterOxs": "OpsXShell 호스트 백업",
"exportFilterBak": "OpsXShell 백업",
"exportFilterJson": "JSON",
"exportSuccess": "{{count}}개 세션을 다음으로 내보냄:\n{{path}}",
@@ -224,6 +226,7 @@
"exportPasswordMismatch": "비밀번호가 일치하지 않습니다",
"exportPasswordRequired": "내보내기에는 백업 비밀번호가 필요합니다",
"importTitle": "세션 가져오기",
+ "importFilterOxs": "OpsXShell 호스트 백업",
"importFilterBak": "OpsXShell 백업",
"importFilterJson": "JSON",
"importFilterSshConfig": "SSH Config",
diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json
index 2883092..19ec25e 100644
--- a/src/i18n/locales/ko/settings.json
+++ b/src/i18n/locales/ko/settings.json
@@ -13,7 +13,7 @@
"ai": "AI 모델",
"aiDesc": "AI 제공자",
"about": "정보",
- "aboutDesc": "버전 및 데이터",
+ "aboutDesc": "버전, 연락처, 진단",
"account": "계정",
"accountDesc": "이메일, 라이선스, 기기"
},
@@ -89,9 +89,9 @@
"ssh": {
"connection": "연결",
"keepAlive": "연결 유지",
- "keepAliveDesc": "유휴 시 SSH keep-alive를 보냅니다. 연속 실패 후에만 연결을 끊습니다",
+ "keepAliveDesc": "유휴 시 SSH 프로토콜 keep-alive를 보냅니다(셸에 키를 넣지 않음). NAT 끊김만 막으며 원격 TMOUT을 우회하지 않습니다",
"autoReconnect": "자동 재연결",
- "autoReconnectDesc": "예기치 않은 연결 끊김 후 자동으로 재시도",
+ "autoReconnectDesc": "탭이 열린 채로 예기치 않게 끊기면 자동 재연결(새 셸). 탭 닫기, 유휴 일시 중지, 원격 TMOUT은 강제로 유지하지 않습니다",
"timeout": "연결 시간 초과",
"timeoutDesc": "SSH 설정 시 최대 대기 시간(초)",
"maxReconnect": "재연결 시도",
@@ -104,7 +104,8 @@
"suspend": "백그라운드 일시 중지",
"suspendDesc": "유휴 백그라운드 탭을 몇 분 후 일시 중지할지(활성 Shell/SFTP는 유지)",
"keepAliveInterval": "유지 간격(초)",
- "keepAliveIntervalDesc": "서버 ping 주기(ClientAliveInterval과 유사). 불안정한 네트워크에서는 15초 이하 권장"
+ "keepAliveIntervalDesc": "서버 ping 주기(ClientAliveInterval과 유사). 불안정한 네트워크에서는 15초 이하 권장",
+ "sessionPolicyHint": "Keep-alive는 TCP 링크를 지킬 뿐 원격 세션 정책을 바꾸지 않습니다. Bash TMOUT, sshd ClientAlive, Windows 유휴 GPO는 그대로입니다. 긴 작업은 서버에서 tmux/screen을 쓰세요."
},
"security": {
"credentials": "자격 증명 보호",
@@ -122,16 +123,25 @@
},
"about": {
"tagline": "간단하고 실용적인 네이티브 운영 터미널",
- "appVersion": "앱 v{{version}}",
- "copyVersion": "버전 정보 복사",
- "runtime": "런타임",
- "system": "시스템",
- "localData": "로컬 데이터",
+ "appVersion": "v{{version}}",
+ "copyVersion": "버전·시스템·경로를 복사해 GitHub에 붙여넣기",
+ "copyDiagnostics": "진단 정보 복사",
+ "copyPath": "경로 복사",
+ "resources": "리소스",
+ "wechat": "WeChat",
+ "copyWechat": "WeChat ID 복사",
+ "github": "토론",
+ "githubDesc": "https://github.com/L00J/opsxshell/discussions",
+ "releases": "릴리스",
+ "releasesDesc": "설치 패키지와 변경 사항",
+ "licenseLink": "라이선스",
+ "licenseLinkDesc": "MIT License",
+ "diagnostics": "진단",
"sessionsSettings": "세션 및 설정",
- "masterKeyPath": "마스터 키",
- "privacyNote": "비밀번호·키 암호는 Rust에서 AES-256-GCM으로 암호화합니다. 명령 기록·감사 로그는 저장하지 않습니다.",
- "license": "MIT 라이선스 · Rust · XTerm.js",
- "logPath": "앱 로그"
+ "license": "MIT License · Rust · XTerm.js",
+ "logPath": "앱 로그",
+ "reportBug": "버그 신고",
+ "reportBugDesc": "GitHub 이슈를 열고 현재 버전과 시스템을 채웁니다"
},
"ai": {
"presets": "제공자 프리셋",
@@ -157,7 +167,9 @@
"otherModels": "다른 모델…",
"customModel": "사용자 지정 모델",
"backToList": "목록으로",
+ "apiKey": "API 키",
"apiKeyOptional": "API 키 (필요하지 않음)",
+ "endpoint": "엔드포인트",
"advanced": "고급",
"allowHttp": "HTTP 허용 (localhost/LAN)",
"setActive": "활성으로 설정",
@@ -175,26 +187,26 @@
}
},
"account": {
- "title": "Account",
- "email": "Email",
- "emailDesc": "Used for checkout and license claims",
- "plan": "Current plan",
- "planFree": "Free",
+ "title": "계정",
+ "email": "이메일",
+ "emailDesc": "결제 및 라이선스 수령에 쓰는 연락 이메일",
+ "plan": "현재 요금제",
+ "planFree": "무료",
"planYearly": "프로 연간",
"planPerpetual": "프로 영구",
"planTrial": "프로 체험",
- "expires": "Expires {{date}}",
- "lifetime": "Lifetime",
- "devices": "Seats: up to {{count}} devices",
- "deviceId": "This device ID",
- "saveEmail": "Save email",
- "activate": "Activate license",
- "pasteLicense": "Paste LIC- license key",
- "clear": "Clear local license",
- "deactivate": "Deactivate this device",
- "upgrade": "Upgrade",
- "saved": "Saved",
- "activateOk": "Activated",
+ "expires": "{{date}}까지",
+ "lifetime": "평생",
+ "devices": "기기 좌석: 최대 {{count}}대",
+ "deviceId": "이 기기 ID",
+ "saveEmail": "이메일 저장",
+ "activate": "라이선스 활성화",
+ "pasteLicense": "LIC- 라이선스 붙여넣기",
+ "clear": "이 기기의 라이선스 지우기",
+ "deactivate": "이 기기 활성화 해제",
+ "upgrade": "프로로 업그레이드",
+ "saved": "저장됨",
+ "activateOk": "활성화됨",
"deviceList": "바인딩된 기기",
"loadingDevices": "기기 목록 불러오는 중…",
"noDevices": "바인딩된 기기 없음",
diff --git a/src/i18n/locales/ko/sftp.json b/src/i18n/locales/ko/sftp.json
index c78d178..23c92ba 100644
--- a/src/i18n/locales/ko/sftp.json
+++ b/src/i18n/locales/ko/sftp.json
@@ -15,7 +15,8 @@
"notConnected": "연결되지 않음",
"connecting": "연결 중...",
"resumeHint": "다시 연결하면 {{path}}(으)로 복귀합니다",
- "reconnectingResume": "{{path}} 복원하며 다시 연결 중…"
+ "reconnectingResume": "{{path}} 복원하며 다시 연결 중…",
+ "autoReconnecting": "자동 재연결 중 ({{attempt}}/{{max}})…"
},
"errors": {
"notConnected": "SFTP가 연결되지 않았습니다",
diff --git a/src/i18n/locales/ko/terminal.json b/src/i18n/locales/ko/terminal.json
index ee2886b..39ee8f4 100644
--- a/src/i18n/locales/ko/terminal.json
+++ b/src/i18n/locales/ko/terminal.json
@@ -34,7 +34,9 @@
"reopenShell": "셸 다시 열기",
"reopening": "여는 중…",
"disconnected": "연결 끊김",
- "disconnectedHint": "SSH 세션이 종료되었거나 네트워크가 끊겼습니다. 아래를 누르면 다시 연결해 작업을 이어갑니다.",
+ "disconnectedHint": "네트워크가 끊겼거나 서버가 SSH를 닫았습니다. 탭이 열려 있으면 자동 재연결되며, 아래 버튼으로 바로 재연결할 수도 있습니다.",
+ "disconnectedKeepalive": "Keep-alive 실패로 연결 끊김",
+ "disconnectedKeepaliveHint": "SSH keep-alive에 응답이 없습니다. 대개 NAT나 유휴 네트워크 끊김입니다. 셸에 키 입력을 보내지는 않습니다. 탭이 열려 있으면 자동 재연결됩니다.",
"reconnect": "재연결",
"suspended": "연결 일시 중단됨",
"suspendedHint": "오랫동안 활동이 없어 리소스 절약을 위해 연결이 일시 중단되었습니다",
diff --git a/src/i18n/locales/ru/ai.json b/src/i18n/locales/ru/ai.json
index ae21d83..c40155b 100644
--- a/src/i18n/locales/ru/ai.json
+++ b/src/i18n/locales/ru/ai.json
@@ -16,10 +16,10 @@
"stop": "Остановить",
"send": "Отправить",
"placeholder": "Задача или уточнение… (Enter — отправить, Shift+Enter — новая строка)",
- "hintFollowUp": "Уточнения включают контекст этой ветки",
+ "hintFollowUp": "Уточнения включают сжатое резюме этой ветки",
"hintSelection": "Будет включено {{lines}} выбранных строк",
- "hintSteps": "До {{max}} шагов · ничего не сохраняется",
- "footerNote": "Высокий риск требует подтверждения · критический блокируется · сеанс не сохраняется",
+ "hintSteps": "До {{max}} шагов",
+ "footerNote": "Высокий риск — сначала подтверждение · разрушительные команды отклоняются · чат не сохраняется",
"summarize": "Сводка",
"summarizeTitle": "AI суммирует видимый вывод терминала (сначала выделение); команды не выполняются",
"summarizeRequest": "Суммируйте текущий вывод терминала"
@@ -77,7 +77,7 @@
"invalidCommand": "Модель не вернула единственную однострочную команду; выполнение отклонено",
"repeatLoop": "Обнаружен повторяющийся цикл команд; остановлено: {{command}}",
"criticalBlocked": "Команда попала под деструктивную жёсткую блокировку; обход невозможен: {{command}}",
- "execFailed": "Сбой ssh-exec: {{error}}",
+ "execFailed": "Сбой удалённого выполнения: {{error}}",
"execUnknown": "Неизвестная ошибка",
"execNoOutput": "Команда завершилась без вывода",
"maxSteps": "Достигнут лимит в {{max}} шагов; сузьте задачу и повторите",
diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json
index 298f815..d25be77 100644
--- a/src/i18n/locales/ru/common.json
+++ b/src/i18n/locales/ru/common.json
@@ -33,8 +33,10 @@
"error": {
"title": "Что-то пошло не так",
"body": "В приложении возникла непредвиденная проблема",
+ "bodyPane": "В этой вкладке произошла ошибка. Остальные вкладки работают.",
"recoverable": "Эта ошибка обратима. Попробуйте обновить страницу.",
"refreshPage": "Обновить страницу",
+ "retryPane": "Повторить вкладку",
"restartApp": "Перезапустить приложение",
"supportHint": "Если проблема сохраняется, проверьте журналы или обратитесь в поддержку"
},
@@ -104,11 +106,12 @@
},
"menu": {
"closeOthers": "Закрыть остальные",
- "closeRight": "Закрыть вкладки справа",
- "closeLeft": "Закрыть вкладки слева",
+ "closeRight": "Закрыть справа",
+ "closeLeft": "Закрыть слева",
"closeAll": "Закрыть все",
- "clone": "Дублировать вкладку",
- "openSftp": "Открыть SFTP"
+ "clone": "Клонировать вкладку",
+ "openSftp": "Открыть SFTP",
+ "reconnect": "Переподключиться"
},
"renameAria": "Переименовать вкладку {{title}}",
"renameHint": "Двойной клик — переименовать"
diff --git a/src/i18n/locales/ru/desktop.json b/src/i18n/locales/ru/desktop.json
index 25d7894..1fe40ac 100644
--- a/src/i18n/locales/ru/desktop.json
+++ b/src/i18n/locales/ru/desktop.json
@@ -35,13 +35,17 @@
"fullscreenAria": "Развернуть на весь экран",
"exitFullscreen": "Выйти из полного экрана",
"exitFullscreenAria": "Выйти из полного экрана",
- "exitFullscreenHint": "Esc / F11 — выход"
+ "exitFullscreenHint": "Esc / F11 — выход",
+ "reconnect": "Переподключиться",
+ "reconnectAria": "Переподключить удалённый рабочий стол"
},
"status": {
"connecting": "Подключение…",
"connected": "Подключено",
"disconnected": "Отключено",
"connectFailed": "Сбой подключения",
+ "reconnecting": "Автопереподключение ({{attempt}}/{{max}})…",
+ "reconnectLimit": "Достигнут лимит автопереподключения ({{max}})",
"clipboardSynced": "Удалённый буфер обмена синхронизирован",
"savedToDownloads": "Сохранено в локальную папку Downloads: {{name}}",
"pastedToRemote": "Вставлено на удалённый сервер",
diff --git a/src/i18n/locales/ru/errors.json b/src/i18n/locales/ru/errors.json
index 2f4ca06..1dc3197 100644
--- a/src/i18n/locales/ru/errors.json
+++ b/src/i18n/locales/ru/errors.json
@@ -41,7 +41,10 @@
"refused": "В подключении отказано",
"disconnected": "Отключено",
"keepaliveFailed": "SSH keep-alive несколько раз не удался; соединение закрыто",
- "idleKicked": "Удалённая оболочка завершилась по таймауту простоя (TMOUT)"
+ "idleKicked": "Удалённая оболочка завершилась по таймауту простоя (TMOUT)",
+ "unexpectedDropped": "Соединение разорвано (сеть или закрытие на сервере)",
+ "shellExited": "Удалённая оболочка завершилась (exit, Ctrl+D или политика сервера)",
+ "shellReadFailed": "Не удалось прочитать удалённую оболочку"
},
"auth": {
"failed": "Сбой аутентификации. Проверьте имя пользователя и пароль.",
@@ -160,7 +163,7 @@
"invalidCommand": "Модель не вернула единственную однострочную команду; выполнение отклонено",
"repeatLoop": "Обнаружен повторяющийся цикл команд; остановлено: {{command}}",
"criticalBlocked": "Команда попала под деструктивную жёсткую блокировку; обход невозможен: {{command}}",
- "execFailed": "Сбой ssh-exec: {{error}}",
+ "execFailed": "Сбой удалённого выполнения: {{error}}",
"maxSteps": "Достигнут лимит в {{max}} шагов; сузьте задачу и повторите",
"sshRequired": "Требуется подключённый удалённый SSH-сеанс"
},
@@ -168,6 +171,12 @@
"readFailed": "Сбой чтения: {{name}}",
"writeFailed": "Сбой записи: {{name}}"
},
+ "native": {
+ "taskJoin": "Фоновая задача не выполнена. Повторите попытку.",
+ "invalidBase64": "Данные файла не являются корректным Base64.",
+ "knownHostsPath": "Не удалось найти ~/.ssh/known_hosts.",
+ "invalidHostKeyDecision": "Некорректное решение по ключу хоста."
+ },
"unknown": {
"error": "Произошла неизвестная ошибка"
}
diff --git a/src/i18n/locales/ru/sessions.json b/src/i18n/locales/ru/sessions.json
index 9faae5e..f3d00fb 100644
--- a/src/i18n/locales/ru/sessions.json
+++ b/src/i18n/locales/ru/sessions.json
@@ -132,7 +132,8 @@
"collapse": "Свернуть",
"protocol": "Протокол",
"displayName": "Отображаемое имя (необязательно)",
- "displayNamePlaceholder": "По умолчанию user@host или vnc:host",
+ "displayNamePlaceholder": "По умолчанию user@host",
+ "displayNamePlaceholderVnc": "По умолчанию имя хоста",
"host": "Хост",
"hostPlaceholder": "192.168.1.10",
"hostHint": "IP или имя хоста; также принимается host:port",
@@ -208,7 +209,8 @@
},
"importExport": {
"exportTitle": "Экспорт сеансов",
- "exportDefaultPath": "opsxshell-sessions.bak",
+ "exportDefaultPath": "opsxshell-hosts.oxs",
+ "exportFilterOxs": "Резервная копия хостов OpsXShell",
"exportFilterBak": "Резервная копия OpsXShell",
"exportFilterJson": "JSON",
"exportSuccess": "Экспортировано сеансов: {{count}} в:\n{{path}}",
@@ -224,6 +226,7 @@
"exportPasswordMismatch": "Пароли не совпадают",
"exportPasswordRequired": "Для экспорта требуется пароль резервной копии",
"importTitle": "Импорт сеансов",
+ "importFilterOxs": "Резервная копия хостов OpsXShell",
"importFilterBak": "Резервная копия OpsXShell",
"importFilterJson": "JSON",
"importFilterSshConfig": "SSH Config",
diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json
index cb7b07a..8a0bf85 100644
--- a/src/i18n/locales/ru/settings.json
+++ b/src/i18n/locales/ru/settings.json
@@ -13,7 +13,7 @@
"ai": "AI-модели",
"aiDesc": "AI-провайдеры",
"about": "О приложении",
- "aboutDesc": "Версия и данные",
+ "aboutDesc": "Версия, контакты и диагностика",
"account": "Аккаунт",
"accountDesc": "Email, лицензия и устройство"
},
@@ -88,10 +88,10 @@
},
"ssh": {
"connection": "Подключение",
- "keepAlive": "Keep-alive",
- "keepAliveDesc": "Отправлять SSH keep-alive в простое; отключать только после нескольких сбоев",
+ "keepAlive": "Поддержание связи",
+ "keepAliveDesc": "В простое слать протокол SSH keep-alive (не вводить в оболочку). Защищает от NAT, не обходит удалённый TMOUT",
"autoReconnect": "Авто-переподключение",
- "autoReconnectDesc": "Автоматически повторять после неожиданного разрыва",
+ "autoReconnectDesc": "Пока вкладка открыта, после неожиданного разрыва переподключаться (новая оболочка). Закрытие вкладки, простой и удалённый TMOUT не удерживаются силой",
"timeout": "Тайм-аут подключения",
"timeoutDesc": "Максимальное время ожидания при установке SSH (секунды)",
"maxReconnect": "Попытки переподключения",
@@ -104,7 +104,8 @@
"suspend": "Фоновая приостановка",
"suspendDesc": "Через сколько минут приостанавливать простаивающие фоновые вкладки (активные Shell/SFTP остаются)",
"keepAliveInterval": "Интервал keep-alive (сек)",
- "keepAliveIntervalDesc": "Как часто опрашивать сервер (как ClientAliveInterval). При нестабильной сети — ≤15 с"
+ "keepAliveIntervalDesc": "Как часто опрашивать сервер (как ClientAliveInterval). При нестабильной сети — ≤15 с",
+ "sessionPolicyHint": "Keep-alive держит TCP-канал, а не политику удалённого сеанса. Bash TMOUT, sshd ClientAlive и простой GPO Windows по-прежнему действуют. Долгие задачи — через tmux/screen на сервере."
},
"security": {
"credentials": "Защита учётных данных",
@@ -122,16 +123,25 @@
},
"about": {
"tagline": "Простой и удобный нативный терминал для эксплуатации",
- "appVersion": "Приложение v{{version}}",
- "copyVersion": "Копировать сведения о версии",
- "runtime": "Среда выполнения",
- "system": "Система",
- "localData": "Локальные данные",
+ "appVersion": "v{{version}}",
+ "copyVersion": "Скопировать версию, систему и пути для GitHub",
+ "copyDiagnostics": "Копировать диагностику",
+ "copyPath": "Копировать путь",
+ "resources": "Ресурсы",
+ "wechat": "WeChat",
+ "copyWechat": "Скопировать WeChat ID",
+ "github": "Обсуждения",
+ "githubDesc": "https://github.com/L00J/opsxshell/discussions",
+ "releases": "Релизы",
+ "releasesDesc": "Установщики и заметки к выпуску",
+ "licenseLink": "Лицензия",
+ "licenseLinkDesc": "MIT License",
+ "diagnostics": "Диагностика",
"sessionsSettings": "Сеансы и настройки",
- "masterKeyPath": "Мастер-ключ",
- "privacyNote": "Пароли и passphrase шифруются в Rust (AES-256-GCM). Без истории команд и без аудита-бастиона.",
- "license": "Лицензия MIT · Rust · XTerm.js",
- "logPath": "Журнал приложения"
+ "license": "MIT License · Rust · XTerm.js",
+ "logPath": "Журнал приложения",
+ "reportBug": "Сообщить об ошибке",
+ "reportBugDesc": "Открывает GitHub issue с текущей версией и платформой"
},
"ai": {
"presets": "Пресеты провайдеров",
@@ -151,13 +161,15 @@
"saved": "Сохранено",
"retest": "Пожалуйста, перепроверьте",
"testFailed": "Сбой проверки подключения",
- "available": "OK · {{latency}} мс",
+ "available": "Готово · {{latency}} мс",
"savedKeepBlank": "Сохранено — оставьте пустым, чтобы сохранить",
"notRequired": "Не требуется",
"otherModels": "Другие модели…",
"customModel": "Своя модель",
"backToList": "Назад к списку",
- "apiKeyOptional": "API Key (не требуется)",
+ "apiKey": "API-ключ",
+ "apiKeyOptional": "API-ключ (не требуется)",
+ "endpoint": "Адрес API",
"advanced": "Дополнительно",
"allowHttp": "Разрешить HTTP (localhost/LAN)",
"setActive": "Сделать активным",
@@ -175,26 +187,26 @@
}
},
"account": {
- "title": "Account",
- "email": "Email",
- "emailDesc": "Used for checkout and license claims",
- "plan": "Current plan",
- "planFree": "Free",
+ "title": "Аккаунт",
+ "email": "Почта",
+ "emailDesc": "Контактная почта для оплаты и получения лицензии",
+ "plan": "Текущий план",
+ "planFree": "Бесплатный",
"planYearly": "Pro на год",
"planPerpetual": "Pro бессрочно",
"planTrial": "Pro пробный",
- "expires": "Expires {{date}}",
- "lifetime": "Lifetime",
- "devices": "Seats: up to {{count}} devices",
- "deviceId": "This device ID",
- "saveEmail": "Save email",
- "activate": "Activate license",
- "pasteLicense": "Paste LIC- license key",
- "clear": "Clear local license",
- "deactivate": "Deactivate this device",
- "upgrade": "Upgrade",
- "saved": "Saved",
- "activateOk": "Activated",
+ "expires": "Действует до {{date}}",
+ "lifetime": "Бессрочно",
+ "devices": "Места устройств: до {{count}}",
+ "deviceId": "ID этого устройства",
+ "saveEmail": "Сохранить почту",
+ "activate": "Активировать лицензию",
+ "pasteLicense": "Вставьте лицензию LIC-",
+ "clear": "Удалить локальную лицензию",
+ "deactivate": "Отключить активацию на этом устройстве",
+ "upgrade": "Перейти на Pro",
+ "saved": "Сохранено",
+ "activateOk": "Активировано",
"deviceList": "Привязанные устройства",
"loadingDevices": "Загрузка устройств…",
"noDevices": "Нет привязанных устройств",
diff --git a/src/i18n/locales/ru/sftp.json b/src/i18n/locales/ru/sftp.json
index 4b70483..23409da 100644
--- a/src/i18n/locales/ru/sftp.json
+++ b/src/i18n/locales/ru/sftp.json
@@ -15,7 +15,8 @@
"notConnected": "Нет подключения",
"connecting": "Подключение...",
"resumeHint": "После переподключения вернёмся в {{path}}",
- "reconnectingResume": "Переподключение и восстановление {{path}}…"
+ "reconnectingResume": "Переподключение и восстановление {{path}}…",
+ "autoReconnecting": "Автопереподключение ({{attempt}}/{{max}})…"
},
"errors": {
"notConnected": "SFTP не подключён",
diff --git a/src/i18n/locales/ru/terminal.json b/src/i18n/locales/ru/terminal.json
index 01bcb8a..7f493f6 100644
--- a/src/i18n/locales/ru/terminal.json
+++ b/src/i18n/locales/ru/terminal.json
@@ -34,7 +34,9 @@
"reopenShell": "Открыть оболочку снова",
"reopening": "Открытие…",
"disconnected": "Отключено",
- "disconnectedHint": "SSH-сеанс завершился или сеть упала. Один клик ниже — и можно продолжать работу.",
+ "disconnectedHint": "Сеть оборвалась или сервер закрыл SSH. Пока вкладка открыта, будет автопереподключение; можно нажать кнопку ниже.",
+ "disconnectedKeepalive": "Сбой keep-alive, отключено",
+ "disconnectedKeepaliveHint": "SSH keep-alive не ответил — обычно NAT или простой сети. В оболочку клавиши не отправляются. Пока вкладка открыта, будет автопереподключение.",
"reconnect": "Переподключиться",
"suspended": "Подключение приостановлено",
"suspendedHint": "Долгое отсутствие активности; подключение приостановлено для экономии ресурсов",
diff --git a/src/i18n/locales/vi/ai.json b/src/i18n/locales/vi/ai.json
index d1cbbb0..8698cec 100644
--- a/src/i18n/locales/vi/ai.json
+++ b/src/i18n/locales/vi/ai.json
@@ -16,10 +16,10 @@
"stop": "Dừng",
"send": "Gửi",
"placeholder": "Tác vụ hoặc theo dõi… (Enter để gửi, Shift+Enter để xuống dòng)",
- "hintFollowUp": "Các theo dõi bao gồm ngữ cảnh của luồng này",
+ "hintFollowUp": "Câu hỏi tiếp theo kèm tóm tắt luồng này, nội dung quá dài sẽ bị cắt",
"hintSelection": "Sẽ bao gồm {{lines}} dòng đã chọn",
- "hintSteps": "Tối đa {{max}} bước · không lưu gì",
- "footerNote": "Rủi ro cao cần xác nhận · rủi ro nghiêm trọng bị chặn · phiên không được lưu",
+ "hintSteps": "Tối đa {{max}} bước",
+ "footerNote": "Rủi ro cao cần xác nhận trước · lệnh phá hủy bị từ chối · cuộc trò chuyện không được lưu",
"summarize": "Tóm tắt",
"summarizeTitle": "AI tóm tắt đầu ra terminal đang hiển thị (ưu tiên vùng chọn), không chạy lệnh nào",
"summarizeRequest": "Tóm tắt đầu ra terminal hiện tại"
@@ -77,7 +77,7 @@
"invalidCommand": "Mô hình không trả về một lệnh một dòng duy nhất; từ chối thực thi",
"repeatLoop": "Phát hiện vòng lặp lệnh lặp lại; đã dừng: {{command}}",
"criticalBlocked": "Lệnh gặp chặn cứng phá hủy; không có ghi đè: {{command}}",
- "execFailed": "ssh-exec thất bại: {{error}}",
+ "execFailed": "Thực thi từ xa thất bại: {{error}}",
"execUnknown": "Lỗi không xác định",
"execNoOutput": "Lệnh hoàn tất không có đầu ra",
"maxSteps": "Đã đạt giới hạn {{max}} bước; thu hẹp phạm vi và thử lại",
diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json
index 103e288..5c208b0 100644
--- a/src/i18n/locales/vi/common.json
+++ b/src/i18n/locales/vi/common.json
@@ -33,8 +33,10 @@
"error": {
"title": "Đã xảy ra lỗi",
"body": "Ứng dụng gặp sự cố bất ngờ",
+ "bodyPane": "Thẻ này gặp sự cố. Các thẻ khác vẫn dùng được.",
"recoverable": "Lỗi này có thể khôi phục. Hãy thử làm mới trang.",
"refreshPage": "Làm mới trang",
+ "retryPane": "Thử lại thẻ này",
"restartApp": "Khởi động lại ứng dụng",
"supportHint": "Nếu sự cố vẫn tiếp diễn, hãy kiểm tra nhật ký hoặc liên hệ hỗ trợ"
},
@@ -104,11 +106,12 @@
},
"menu": {
"closeOthers": "Đóng các tab khác",
- "closeRight": "Đóng các tab bên phải",
- "closeLeft": "Đóng các tab bên trái",
+ "closeRight": "Đóng tab bên phải",
+ "closeLeft": "Đóng tab bên trái",
"closeAll": "Đóng tất cả",
"clone": "Nhân đôi tab",
- "openSftp": "Mở SFTP"
+ "openSftp": "Mở SFTP",
+ "reconnect": "Kết nối lại"
},
"renameAria": "Đổi tên thẻ {{title}}",
"renameHint": "Nhấn đúp để đổi tên"
diff --git a/src/i18n/locales/vi/desktop.json b/src/i18n/locales/vi/desktop.json
index 737a4aa..16bb285 100644
--- a/src/i18n/locales/vi/desktop.json
+++ b/src/i18n/locales/vi/desktop.json
@@ -35,13 +35,17 @@
"fullscreenAria": "Vào toàn màn hình",
"exitFullscreen": "Thoát toàn màn hình",
"exitFullscreenAria": "Thoát toàn màn hình",
- "exitFullscreenHint": "Esc / F11 để thoát"
+ "exitFullscreenHint": "Esc / F11 để thoát",
+ "reconnect": "Kết nối lại",
+ "reconnectAria": "Kết nối lại máy tính từ xa"
},
"status": {
"connecting": "Đang kết nối…",
"connected": "Đã kết nối",
"disconnected": "Đã ngắt kết nối",
"connectFailed": "Kết nối thất bại",
+ "reconnecting": "Tự kết nối lại ({{attempt}}/{{max}})…",
+ "reconnectLimit": "Đã đạt giới hạn tự kết nối lại ({{max}})",
"clipboardSynced": "Đã đồng bộ clipboard từ xa",
"savedToDownloads": "Đã lưu vào Downloads cục bộ: {{name}}",
"pastedToRemote": "Đã dán sang máy từ xa",
diff --git a/src/i18n/locales/vi/errors.json b/src/i18n/locales/vi/errors.json
index 8fedcb8..4a72dbd 100644
--- a/src/i18n/locales/vi/errors.json
+++ b/src/i18n/locales/vi/errors.json
@@ -41,7 +41,10 @@
"refused": "Kết nối bị từ chối",
"disconnected": "Đã ngắt kết nối",
"keepaliveFailed": "Keep-alive SSH thất bại liên tiếp; đã đóng kết nối",
- "idleKicked": "Shell từ xa đã thoát do hết thời gian chờ (TMOUT)"
+ "idleKicked": "Shell từ xa đã thoát do hết thời gian chờ (TMOUT)",
+ "unexpectedDropped": "Đã ngắt kết nối (mất mạng hoặc máy chủ đóng)",
+ "shellExited": "Shell từ xa đã thoát (exit, Ctrl+D hoặc chính sách máy chủ)",
+ "shellReadFailed": "Không đọc được shell từ xa"
},
"auth": {
"failed": "Xác thực thất bại. Kiểm tra tên người dùng và mật khẩu.",
@@ -160,7 +163,7 @@
"invalidCommand": "Mô hình không trả về một lệnh một dòng duy nhất; từ chối thực thi",
"repeatLoop": "Phát hiện vòng lặp lệnh lặp lại; đã dừng: {{command}}",
"criticalBlocked": "Lệnh gặp chặn cứng phá hủy; không có ghi đè: {{command}}",
- "execFailed": "ssh-exec thất bại: {{error}}",
+ "execFailed": "Thực thi từ xa thất bại: {{error}}",
"maxSteps": "Đã đạt giới hạn {{max}} bước; thu hẹp phạm vi và thử lại",
"sshRequired": "Cần một phiên SSH từ xa đã kết nối"
},
@@ -168,6 +171,12 @@
"readFailed": "Đọc thất bại: {{name}}",
"writeFailed": "Ghi thất bại: {{name}}"
},
+ "native": {
+ "taskJoin": "Tác vụ nền thất bại. Hãy thử lại.",
+ "invalidBase64": "Dữ liệu tệp không phải Base64 hợp lệ.",
+ "knownHostsPath": "Không tìm thấy ~/.ssh/known_hosts.",
+ "invalidHostKeyDecision": "Quyết định khóa máy chủ không hợp lệ."
+ },
"unknown": {
"error": "Đã xảy ra lỗi không xác định"
}
diff --git a/src/i18n/locales/vi/sessions.json b/src/i18n/locales/vi/sessions.json
index fa5d241..634c94d 100644
--- a/src/i18n/locales/vi/sessions.json
+++ b/src/i18n/locales/vi/sessions.json
@@ -132,7 +132,8 @@
"collapse": "Thu gọn",
"protocol": "Giao thức",
"displayName": "Tên hiển thị (tùy chọn)",
- "displayNamePlaceholder": "Mặc định là người_dùng@máy_chủ hoặc vnc:máy_chủ",
+ "displayNamePlaceholder": "Mặc định là user@host",
+ "displayNamePlaceholderVnc": "Mặc định là tên máy chủ",
"host": "Máy chủ",
"hostPlaceholder": "192.168.1.10",
"hostHint": "IP hoặc tên máy chủ; cũng chấp nhận máy_chủ:cổng",
@@ -208,7 +209,8 @@
},
"importExport": {
"exportTitle": "Xuất phiên",
- "exportDefaultPath": "opsxshell-sessions.bak",
+ "exportDefaultPath": "opsxshell-hosts.oxs",
+ "exportFilterOxs": "Sao lưu máy chủ OpsXShell",
"exportFilterBak": "Sao lưu OpsXShell",
"exportFilterJson": "JSON",
"exportSuccess": "Đã xuất {{count}} phiên tới:\n{{path}}",
@@ -224,6 +226,7 @@
"exportPasswordMismatch": "Mật khẩu không khớp",
"exportPasswordRequired": "Cần mật khẩu sao lưu để xuất",
"importTitle": "Nhập phiên",
+ "importFilterOxs": "Sao lưu máy chủ OpsXShell",
"importFilterBak": "Sao lưu OpsXShell",
"importFilterJson": "JSON",
"importFilterSshConfig": "SSH Config",
diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json
index 761ebc4..ba3c547 100644
--- a/src/i18n/locales/vi/settings.json
+++ b/src/i18n/locales/vi/settings.json
@@ -2,7 +2,7 @@
"nav": {
"general": "Chung",
"generalDesc": "Giao diện, ngôn ngữ và bố cục",
- "terminal": "Terminal",
+ "terminal": "Dòng lệnh",
"terminalDesc": "Phông chữ và màu sắc",
"shortcuts": "Phím tắt",
"shortcutsDesc": "Tổ hợp phím",
@@ -13,7 +13,7 @@
"ai": "Mô hình AI",
"aiDesc": "Nhà cung cấp AI",
"about": "Giới thiệu",
- "aboutDesc": "Phiên bản và dữ liệu",
+ "aboutDesc": "Phiên bản, liên hệ và chẩn đoán",
"account": "Tài khoản",
"accountDesc": "Email, giấy phép và thiết bị"
},
@@ -88,10 +88,10 @@
},
"ssh": {
"connection": "Kết nối",
- "keepAlive": "Keep-alive",
- "keepAliveDesc": "Gửi keep-alive SSH khi nhàn rỗi; chỉ ngắt sau vài lần không phản hồi",
+ "keepAlive": "Giữ kết nối",
+ "keepAliveDesc": "Gửi keep-alive giao thức SSH khi nhàn rỗi (không gõ vào shell). Chỉ chống NAT/firewall cắt kết nối, không vượt TMOUT từ xa",
"autoReconnect": "Tự động kết nối lại",
- "autoReconnectDesc": "Tự động thử lại sau khi ngắt kết nối bất ngờ",
+ "autoReconnectDesc": "Tab còn mở và bị ngắt bất ngờ thì tự kết nối lại (shell mới). Đóng tab, tạm dừng nhàn rỗi hoặc TMOUT từ xa không bị giữ sống",
"timeout": "Thời gian chờ kết nối",
"timeoutDesc": "Thời gian chờ tối đa khi thiết lập SSH (giây)",
"maxReconnect": "Số lần kết nối lại",
@@ -104,7 +104,8 @@
"suspend": "Tạm dừng nền",
"suspendDesc": "Số phút trước khi tạm dừng tab nền nhàn rỗi (Shell/SFTP đang chạy vẫn giữ kết nối)",
"keepAliveInterval": "Khoảng keep-alive (giây)",
- "keepAliveIntervalDesc": "Tần suất ping máy chủ (giống ClientAliveInterval). Mạng yếu nên ≤15 giây"
+ "keepAliveIntervalDesc": "Tần suất ping máy chủ (giống ClientAliveInterval). Mạng yếu nên ≤15 giây",
+ "sessionPolicyHint": "Keep-alive bảo vệ liên kết TCP, không phải chính sách phiên từ xa. Bash TMOUT, sshd ClientAlive và GPO nhàn rỗi Windows vẫn áp dụng. Việc chạy lâu hãy dùng tmux/screen trên máy chủ."
},
"security": {
"credentials": "Bảo vệ thông tin xác thực",
@@ -122,16 +123,25 @@
},
"about": {
"tagline": "Terminal vận hành gốc — đơn giản, tiện, dễ dùng",
- "appVersion": "Ứng dụng v{{version}}",
- "copyVersion": "Sao chép thông tin phiên bản",
- "runtime": "Runtime",
- "system": "Hệ thống",
- "localData": "Dữ liệu cục bộ",
+ "appVersion": "v{{version}}",
+ "copyVersion": "Sao chép phiên bản, hệ thống và đường dẫn để dán lên GitHub",
+ "copyDiagnostics": "Sao chép chẩn đoán",
+ "copyPath": "Sao chép đường dẫn",
+ "resources": "Tài nguyên",
+ "wechat": "WeChat",
+ "copyWechat": "Sao chép ID WeChat",
+ "github": "Thảo luận",
+ "githubDesc": "https://github.com/L00J/opsxshell/discussions",
+ "releases": "Bản phát hành",
+ "releasesDesc": "Gói cài đặt và ghi chú",
+ "licenseLink": "Giấy phép",
+ "licenseLinkDesc": "MIT License",
+ "diagnostics": "Chẩn đoán",
"sessionsSettings": "Phiên và cài đặt",
- "masterKeyPath": "Khóa chính",
- "privacyNote": "Mật khẩu và passphrase được Rust mã hóa AES-256-GCM. Không lưu lịch sử lệnh, không audit kiểu bastion.",
- "license": "Giấy phép MIT · Rust · XTerm.js",
- "logPath": "Nhật ký ứng dụng"
+ "license": "MIT License · Rust · XTerm.js",
+ "logPath": "Nhật ký ứng dụng",
+ "reportBug": "Báo lỗi",
+ "reportBugDesc": "Mở GitHub issue và điền sẵn phiên bản cùng hệ thống"
},
"ai": {
"presets": "Cài sẵn nhà cung cấp",
@@ -151,18 +161,20 @@
"saved": "Đã lưu",
"retest": "Vui lòng kiểm tra lại",
"testFailed": "Kiểm tra kết nối thất bại",
- "available": "OK · {{latency}} ms",
+ "available": "Sẵn sàng · {{latency}} ms",
"savedKeepBlank": "Đã lưu — để trống để giữ nguyên",
"notRequired": "Không bắt buộc",
"otherModels": "Các mô hình khác…",
"customModel": "Mô hình tùy chỉnh",
"backToList": "Quay lại danh sách",
- "apiKeyOptional": "API Key (không bắt buộc)",
+ "apiKey": "Khóa API",
+ "apiKeyOptional": "Khóa API (không bắt buộc)",
+ "endpoint": "Địa chỉ API",
"advanced": "Nâng cao",
"allowHttp": "Cho phép HTTP (localhost/LAN)",
"setActive": "Đặt làm hoạt động",
"deleteTitle": "Xóa",
- "apiKeySaveFailed": "Không thể lưu API key",
+ "apiKeySaveFailed": "Không thể lưu khóa API",
"providerNotFound": "Không tìm thấy nhà cung cấp",
"modelCatalogReadFailed": "Không thể đọc danh mục mô hình",
"catalogUnavailable": "models.dev không khả dụng",
@@ -175,26 +187,26 @@
}
},
"account": {
- "title": "Account",
- "email": "Email",
- "emailDesc": "Used for checkout and license claims",
- "plan": "Current plan",
- "planFree": "Free",
+ "title": "Tài khoản",
+ "email": "Thư điện tử",
+ "emailDesc": "Dùng cho thanh toán và nhận giấy phép",
+ "plan": "Gói hiện tại",
+ "planFree": "Miễn phí",
"planYearly": "Pro theo năm",
"planPerpetual": "Pro vĩnh viễn",
"planTrial": "Pro dùng thử",
- "expires": "Expires {{date}}",
- "lifetime": "Lifetime",
- "devices": "Seats: up to {{count}} devices",
- "deviceId": "This device ID",
- "saveEmail": "Save email",
- "activate": "Activate license",
- "pasteLicense": "Paste LIC- license key",
- "clear": "Clear local license",
- "deactivate": "Deactivate this device",
- "upgrade": "Upgrade",
- "saved": "Saved",
- "activateOk": "Activated",
+ "expires": "Hết hạn {{date}}",
+ "lifetime": "Vĩnh viễn",
+ "devices": "Chỗ thiết bị: tối đa {{count}} máy",
+ "deviceId": "ID thiết bị này",
+ "saveEmail": "Lưu thư điện tử",
+ "activate": "Kích hoạt giấy phép",
+ "pasteLicense": "Dán khóa giấy phép LIC-",
+ "clear": "Xóa giấy phép trên máy này",
+ "deactivate": "Hủy kích hoạt máy này",
+ "upgrade": "Nâng cấp Pro",
+ "saved": "Đã lưu",
+ "activateOk": "Đã kích hoạt",
"deviceList": "Thiết bị đã gắn",
"loadingDevices": "Đang tải danh sách thiết bị…",
"noDevices": "Chưa có thiết bị",
diff --git a/src/i18n/locales/vi/sftp.json b/src/i18n/locales/vi/sftp.json
index 926a998..ca53247 100644
--- a/src/i18n/locales/vi/sftp.json
+++ b/src/i18n/locales/vi/sftp.json
@@ -15,7 +15,8 @@
"notConnected": "Chưa kết nối",
"connecting": "Đang kết nối...",
"resumeHint": "Kết nối lại sẽ quay về {{path}}",
- "reconnectingResume": "Đang kết nối lại và khôi phục {{path}}…"
+ "reconnectingResume": "Đang kết nối lại và khôi phục {{path}}…",
+ "autoReconnecting": "Tự kết nối lại ({{attempt}}/{{max}})…"
},
"errors": {
"notConnected": "SFTP chưa được kết nối",
diff --git a/src/i18n/locales/vi/terminal.json b/src/i18n/locales/vi/terminal.json
index b659ae3..b37f397 100644
--- a/src/i18n/locales/vi/terminal.json
+++ b/src/i18n/locales/vi/terminal.json
@@ -34,7 +34,9 @@
"reopenShell": "Mở lại shell",
"reopening": "Đang mở…",
"disconnected": "Đã ngắt kết nối",
- "disconnectedHint": "Phiên SSH đã kết thúc hoặc mạng bị ngắt. Một chạm bên dưới để kết nối lại và tiếp tục.",
+ "disconnectedHint": "Mạng bị ngắt hoặc máy chủ đã đóng SSH. Tab vẫn mở sẽ tự kết nối lại; bạn cũng có thể bấm nút bên dưới.",
+ "disconnectedKeepalive": "Keep-alive thất bại, đã ngắt",
+ "disconnectedKeepaliveHint": "SSH keep-alive không phản hồi — thường do NAT hoặc mạng idle. Không gửi phím vào shell. Tab vẫn mở sẽ tự kết nối lại.",
"reconnect": "Kết nối lại",
"suspended": "Kết nối đã bị tạm dừng",
"suspendedHint": "Không hoạt động một thời gian; kết nối đã bị tạm dừng để tiết kiệm tài nguyên",
diff --git a/src/i18n/locales/zh-CN/ai.json b/src/i18n/locales/zh-CN/ai.json
index 8251bd8..9a09575 100644
--- a/src/i18n/locales/zh-CN/ai.json
+++ b/src/i18n/locales/zh-CN/ai.json
@@ -16,10 +16,10 @@
"stop": "停止",
"send": "发送",
"placeholder": "任务或追问…(Enter 发送,Shift+Enter 换行)",
- "hintFollowUp": "继续追问将带上本轮上下文",
+ "hintFollowUp": "追问会附带本轮摘要,过长会截断",
"hintSelection": "将附带选中 {{lines}} 行",
- "hintSteps": "最多 {{max}} 步 · 不落盘",
- "footerNote": "高风险需确认 · critical 拒绝 · 会话不落盘",
+ "hintSteps": "最多 {{max}} 步",
+ "footerNote": "高风险先确认 · 破坏性命令直接拒绝 · 对话不保存",
"summarize": "总结输出",
"summarizeTitle": "用 AI 总结当前终端可见输出(优先使用选中文本),不执行任何命令",
"summarizeRequest": "总结当前终端输出"
@@ -59,15 +59,15 @@
"insertedClipboard": "第 {{round}} 轮命令已复制到剪贴板(当前终端不可写入);粘贴后请检查再运行。"
},
"confirm": {
- "reject": "Reject",
- "once": "Once",
- "always": "Always",
- "hint": "Enter · Once Esc · Reject Always · 本会话同类命令不再确认",
+ "reject": "拒绝",
+ "once": "执行一次",
+ "always": "本会话允许",
+ "hint": "Enter · 执行一次 Esc · 拒绝 本会话允许 · 同类命令本会话不再确认",
"insert": "插入不执行"
},
"error": {
- "noProvider": "请先在设置 → 大模型配置 Provider",
- "noApiKey": "「{{provider}}」尚未保存 API Key",
+ "noProvider": "请先在设置 → 大模型里配置供应商",
+ "noApiKey": "「{{provider}}」尚未保存 API 密钥",
"emptyTask": "请输入任务或追问",
"sshRequired": "需要已连接的远程 SSH",
"modelEmpty": "模型未返回内容",
@@ -77,7 +77,7 @@
"invalidCommand": "模型未返回唯一的单行命令,已拒绝执行",
"repeatLoop": "检测到重复命令循环,已停止:{{command}}",
"criticalBlocked": "命令命中破坏性操作硬红线,不提供确认放行:{{command}}",
- "execFailed": "ssh-exec 失败:{{error}}",
+ "execFailed": "远程执行失败:{{error}}",
"execUnknown": "未知错误",
"execNoOutput": "命令执行完成,无输出",
"maxSteps": "已达 {{max}} 步上限,请缩小范围后重试",
diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json
index f5c34b6..6f8e75e 100644
--- a/src/i18n/locales/zh-CN/common.json
+++ b/src/i18n/locales/zh-CN/common.json
@@ -33,8 +33,10 @@
"error": {
"title": "出错了",
"body": "应用遇到意外问题",
+ "bodyPane": "此标签遇到意外问题,其它标签仍可使用",
"recoverable": "此错误可以恢复,请尝试刷新页面",
"refreshPage": "刷新页面",
+ "retryPane": "重试此标签",
"restartApp": "重启应用",
"supportHint": "如果问题持续存在,请检查日志或联系支持"
},
@@ -108,7 +110,8 @@
"closeLeft": "关闭左侧标签",
"closeAll": "关闭全部",
"clone": "克隆标签",
- "openSftp": "打开 SFTP"
+ "openSftp": "打开 SFTP",
+ "reconnect": "重新连接"
},
"renameAria": "重命名标签 {{title}}",
"renameHint": "双击重命名"
diff --git a/src/i18n/locales/zh-CN/desktop.json b/src/i18n/locales/zh-CN/desktop.json
index 176f841..efaba8c 100644
--- a/src/i18n/locales/zh-CN/desktop.json
+++ b/src/i18n/locales/zh-CN/desktop.json
@@ -35,13 +35,17 @@
"fullscreenAria": "进入全屏",
"exitFullscreen": "退出全屏",
"exitFullscreenAria": "退出全屏",
- "exitFullscreenHint": "Esc / F11 退出"
+ "exitFullscreenHint": "Esc / F11 退出",
+ "reconnect": "重新连接",
+ "reconnectAria": "重新连接远程桌面"
},
"status": {
"connecting": "正在连接…",
"connected": "已连接",
"disconnected": "已断开",
"connectFailed": "连接失败",
+ "reconnecting": "自动重连中({{attempt}}/{{max}})…",
+ "reconnectLimit": "自动重连已达上限({{max}})",
"clipboardSynced": "已同步远端剪贴板",
"savedToDownloads": "已保存到本机下载文件夹:{{name}}",
"pastedToRemote": "已粘贴到远端",
diff --git a/src/i18n/locales/zh-CN/errors.json b/src/i18n/locales/zh-CN/errors.json
index bda5adf..cc5d5f2 100644
--- a/src/i18n/locales/zh-CN/errors.json
+++ b/src/i18n/locales/zh-CN/errors.json
@@ -41,7 +41,10 @@
"refused": "连接被拒绝",
"disconnected": "连接已断开",
"keepaliveFailed": "SSH 心跳连续失败,连接已关闭",
- "idleKicked": "远端 Shell 因空闲超时退出(TMOUT)"
+ "idleKicked": "远端 Shell 因空闲超时退出(TMOUT)",
+ "unexpectedDropped": "连接已断开(网络中断或服务器关闭)",
+ "shellExited": "远端 Shell 已退出(exit、Ctrl+D 或服务器策略)",
+ "shellReadFailed": "远端 Shell 读取失败"
},
"auth": {
"failed": "认证失败,请检查用户名和密码",
@@ -151,8 +154,8 @@
"nativeOnly": "串口仅在 Tauri 原生应用中可用"
},
"ai": {
- "noProvider": "请先在设置 → 大模型配置 Provider",
- "noApiKey": "「{{provider}}」尚未保存 API Key",
+ "noProvider": "请先在设置 → 大模型里配置供应商",
+ "noApiKey": "「{{provider}}」尚未保存 API 密钥",
"modelEmpty": "模型未返回内容",
"protocolFailed": "模型连续两次未遵循协议,已停止",
"noConclusion": "模型未返回可用结论",
@@ -160,7 +163,7 @@
"invalidCommand": "模型未返回唯一的单行命令,已拒绝执行",
"repeatLoop": "检测到重复命令循环,已停止:{{command}}",
"criticalBlocked": "命令命中破坏性操作硬红线,不提供确认放行:{{command}}",
- "execFailed": "ssh-exec 失败:{{error}}",
+ "execFailed": "远程执行失败:{{error}}",
"maxSteps": "已达 {{max}} 步上限,请缩小范围后重试",
"sshRequired": "需要已连接的远程 SSH"
},
@@ -168,6 +171,12 @@
"readFailed": "读取失败: {{name}}",
"writeFailed": "写入失败: {{name}}"
},
+ "native": {
+ "taskJoin": "后台任务失败,请重试",
+ "invalidBase64": "文件数据不是有效的 Base64",
+ "knownHostsPath": "无法定位 ~/.ssh/known_hosts",
+ "invalidHostKeyDecision": "主机密钥决定无效"
+ },
"unknown": {
"error": "发生未知错误"
}
diff --git a/src/i18n/locales/zh-CN/sessions.json b/src/i18n/locales/zh-CN/sessions.json
index fa023ac..bfb6232 100644
--- a/src/i18n/locales/zh-CN/sessions.json
+++ b/src/i18n/locales/zh-CN/sessions.json
@@ -132,7 +132,8 @@
"collapse": "收起",
"protocol": "协议",
"displayName": "显示名称(可选)",
- "displayNamePlaceholder": "自动用 user@host 或 vnc:host",
+ "displayNamePlaceholder": "自动用 user@host",
+ "displayNamePlaceholderVnc": "自动用主机名",
"host": "主机",
"hostPlaceholder": "192.168.1.10",
"hostHint": "填写 IP 或域名;也可粘贴 host:port",
@@ -208,7 +209,8 @@
},
"importExport": {
"exportTitle": "导出会话",
- "exportDefaultPath": "opsxshell-sessions.bak",
+ "exportDefaultPath": "opsxshell-hosts.oxs",
+ "exportFilterOxs": "OpsXShell 主机备份",
"exportFilterBak": "OpsXShell 备份",
"exportFilterJson": "JSON",
"exportSuccess": "已导出 {{count}} 个会话到:\n{{path}}",
@@ -224,6 +226,7 @@
"exportPasswordMismatch": "两次输入的密码不一致",
"exportPasswordRequired": "必须设置备份密码才能导出",
"importTitle": "导入会话",
+ "importFilterOxs": "OpsXShell 主机备份",
"importFilterBak": "OpsXShell 备份",
"importFilterJson": "JSON",
"importFilterSshConfig": "SSH Config",
diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json
index d0298c2..51b2211 100644
--- a/src/i18n/locales/zh-CN/settings.json
+++ b/src/i18n/locales/zh-CN/settings.json
@@ -11,9 +11,9 @@
"security": "安全",
"securityDesc": "凭据与主机密钥",
"ai": "大模型",
- "aiDesc": "AI Provider",
+ "aiDesc": "AI 供应商",
"about": "关于",
- "aboutDesc": "版本与数据",
+ "aboutDesc": "版本、联系与诊断",
"account": "账户",
"accountDesc": "邮箱、许可证与设备"
},
@@ -89,9 +89,9 @@
"ssh": {
"connection": "连接",
"keepAlive": "保持心跳",
- "keepAliveDesc": "空闲时发送 SSH 心跳;连续多次无响应才会断开",
+ "keepAliveDesc": "空闲时发送 SSH 协议心跳(不写入 Shell)。只挡 NAT/防火墙丢连接,不会绕过远端 TMOUT",
"autoReconnect": "自动重连",
- "autoReconnectDesc": "意外断开后自动尝试重连",
+ "autoReconnectDesc": "标签还开着、意外断线时自动重连(新 Shell)。关标签、空闲挂起或远端 TMOUT 不会硬续",
"timeout": "连接超时",
"timeoutDesc": "建立 SSH 连接的最长等待时间(秒)",
"maxReconnect": "自动重连次数",
@@ -104,7 +104,8 @@
"suspend": "后台挂起",
"suspendDesc": "后台标签空闲多少分钟后挂起(有活跃 Shell/SFTP 时保持连接)",
"keepAliveInterval": "心跳间隔(秒)",
- "keepAliveIntervalDesc": "向服务器发送心跳的频率(类似 ClientAliveInterval)。不稳定网络建议 ≤15 秒"
+ "keepAliveIntervalDesc": "向服务器发送心跳的频率(类似 ClientAliveInterval)。不稳定网络建议 ≤15 秒",
+ "sessionPolicyHint": "心跳保的是链路,不是远端会话策略。Bash TMOUT、sshd ClientAlive 和 Windows 空闲 GPO 仍会生效;长任务请在服务器上用 tmux/screen。"
},
"security": {
"credentials": "凭据保护",
@@ -122,23 +123,32 @@
},
"about": {
"tagline": "简单、便捷、人性化的原生运维终端",
- "appVersion": "应用 v{{version}}",
- "copyVersion": "复制版本信息",
- "runtime": "运行环境",
- "system": "系统",
- "localData": "本地数据",
+ "appVersion": "v{{version}}",
+ "copyVersion": "复制版本、系统和路径,便于贴到 GitHub",
+ "copyDiagnostics": "复制诊断信息",
+ "copyPath": "复制路径",
+ "resources": "资源",
+ "wechat": "微信",
+ "copyWechat": "复制微信号",
+ "github": "讨论",
+ "githubDesc": "https://github.com/L00J/opsxshell/discussions",
+ "releases": "版本发布",
+ "releasesDesc": "安装包与更新说明",
+ "licenseLink": "许可证",
+ "licenseLinkDesc": "MIT License",
+ "diagnostics": "诊断",
"sessionsSettings": "会话与设置",
- "masterKeyPath": "加密主密钥",
- "privacyNote": "密码与密钥口令由 Rust 使用 AES-256-GCM 加密。不保存终端命令历史,也不做堡垒审计。",
"license": "MIT License · Rust · XTerm.js",
- "logPath": "应用日志"
+ "logPath": "应用日志",
+ "reportBug": "报告问题",
+ "reportBugDesc": "打开 GitHub issue,并带上当前版本与系统"
},
"ai": {
"presets": "供应商预设",
"add": "添加",
"configured": "已配置",
"configuredHint": "选择供应商后点添加",
- "emptyHint": "添加一个 Provider 开始",
+ "emptyHint": "添加一个供应商开始",
"noModel": "未选模型",
"name": "名称",
"model": "模型",
@@ -157,13 +167,15 @@
"otherModels": "其他模型…",
"customModel": "自定义模型",
"backToList": "返回列表",
- "apiKeyOptional": "API Key(无需)",
+ "apiKey": "API 密钥",
+ "apiKeyOptional": "API 密钥(无需)",
+ "endpoint": "接口地址",
"advanced": "高级",
"allowHttp": "允许 HTTP(本机/内网)",
"setActive": "设为当前",
"deleteTitle": "删除",
- "apiKeySaveFailed": "API Key 保存失败",
- "providerNotFound": "Provider 不存在",
+ "apiKeySaveFailed": "API 密钥保存失败",
+ "providerNotFound": "供应商不存在",
"modelCatalogReadFailed": "模型目录读取失败",
"catalogUnavailable": "models.dev 暂不可用",
"family": {
diff --git a/src/i18n/locales/zh-CN/sftp.json b/src/i18n/locales/zh-CN/sftp.json
index fdae24e..50fdfb9 100644
--- a/src/i18n/locales/zh-CN/sftp.json
+++ b/src/i18n/locales/zh-CN/sftp.json
@@ -15,7 +15,8 @@
"notConnected": "未连接",
"connecting": "连接中...",
"resumeHint": "重连后将回到 {{path}}",
- "reconnectingResume": "正在重连并恢复 {{path}}…"
+ "reconnectingResume": "正在重连并恢复 {{path}}…",
+ "autoReconnecting": "自动重连中({{attempt}}/{{max}})…"
},
"errors": {
"notConnected": "SFTP 尚未连接",
diff --git a/src/i18n/locales/zh-CN/terminal.json b/src/i18n/locales/zh-CN/terminal.json
index 01ce9da..dab40ab 100644
--- a/src/i18n/locales/zh-CN/terminal.json
+++ b/src/i18n/locales/zh-CN/terminal.json
@@ -34,7 +34,9 @@
"reopenShell": "重新打开 Shell",
"reopening": "正在打开…",
"disconnected": "连接已断开",
- "disconnectedHint": "SSH 会话已结束或网络中断。点下方一键重新连接,即可继续工作。",
+ "disconnectedHint": "网络中断或服务器关闭了 SSH。标签还开着时会自动重连;也可点下方立即重连。",
+ "disconnectedKeepalive": "心跳失败,连接已断开",
+ "disconnectedKeepaliveHint": "SSH 心跳连续无响应,多半是 NAT 或网络丢了空闲连接。不会向 Shell 发送按键。标签还开着时会自动重连。",
"reconnect": "重新连接",
"suspended": "连接已挂起",
"suspendedHint": "长时间未操作,连接已进入挂起以节省资源",
diff --git a/src/i18n/locales/zh-TW/ai.json b/src/i18n/locales/zh-TW/ai.json
index ebf40b1..ceff0ec 100644
--- a/src/i18n/locales/zh-TW/ai.json
+++ b/src/i18n/locales/zh-TW/ai.json
@@ -16,10 +16,10 @@
"stop": "停止",
"send": "傳送",
"placeholder": "任務或追問…(Enter 傳送,Shift+Enter 換行)",
- "hintFollowUp": "繼續追問將帶上本輪上下文",
+ "hintFollowUp": "追問會附帶本輪摘要,過長會截斷",
"hintSelection": "將附帶選中 {{lines}} 行",
- "hintSteps": "最多 {{max}} 步 · 不落盤",
- "footerNote": "高風險需確認 · critical 拒絕 · 連線不落盤",
+ "hintSteps": "最多 {{max}} 步",
+ "footerNote": "高風險先確認 · 破壞性命令直接拒絕 · 對話不儲存",
"summarize": "摘要輸出",
"summarizeTitle": "用 AI 摘要目前終端機可見輸出(優先使用選取文字),不執行任何命令",
"summarizeRequest": "摘要目前終端機輸出"
@@ -59,15 +59,15 @@
"insertedClipboard": "第 {{round}} 輪命令已複製到剪貼簿(目前終端機不可寫入);貼上後請檢查再執行。"
},
"confirm": {
- "reject": "Reject",
- "once": "Once",
- "always": "Always",
- "hint": "Enter · Once Esc · Reject Always · 本連線同類命令不再確認",
+ "reject": "拒絕",
+ "once": "執行一次",
+ "always": "本連線允許",
+ "hint": "Enter · 執行一次 Esc · 拒絕 本連線允許 · 同類命令本連線不再確認",
"insert": "插入不執行"
},
"error": {
- "noProvider": "請先在設定 → 大模型設定 Provider",
- "noApiKey": "「{{provider}}」尚未儲存 API Key",
+ "noProvider": "請先在設定 → 大模型裡設定供應商",
+ "noApiKey": "「{{provider}}」尚未儲存 API 金鑰",
"emptyTask": "請輸入任務或追問",
"sshRequired": "需要已連線的遠端 SSH",
"modelEmpty": "模型未傳回內容",
@@ -77,7 +77,7 @@
"invalidCommand": "模型未傳回唯一的單行命令,已拒絕執行",
"repeatLoop": "偵測到重複命令迴圈,已停止:{{command}}",
"criticalBlocked": "命令命中破壞性操作硬紅線,不提供確認放行:{{command}}",
- "execFailed": "ssh-exec 失敗:{{error}}",
+ "execFailed": "遠端執行失敗:{{error}}",
"execUnknown": "未知錯誤",
"execNoOutput": "命令執行完成,無輸出",
"maxSteps": "已達 {{max}} 步上限,請縮小範圍後重試",
diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json
index df3d6a0..41e10c4 100644
--- a/src/i18n/locales/zh-TW/common.json
+++ b/src/i18n/locales/zh-TW/common.json
@@ -33,8 +33,10 @@
"error": {
"title": "出錯了",
"body": "應用遇到意外問題",
+ "bodyPane": "此分頁遇到意外問題,其它分頁仍可使用",
"recoverable": "此錯誤可恢復,請嘗試重新整理頁面",
"refreshPage": "重新整理頁面",
+ "retryPane": "重試此分頁",
"restartApp": "重新啟動應用",
"supportHint": "若問題持續存在,請檢查日誌或聯繫支援"
},
@@ -108,7 +110,8 @@
"closeLeft": "關閉左側分頁",
"closeAll": "關閉全部",
"clone": "克隆分頁",
- "openSftp": "開啟 SFTP"
+ "openSftp": "開啟 SFTP",
+ "reconnect": "重新連線"
},
"renameAria": "重新命名標籤 {{title}}",
"renameHint": "雙擊重新命名"
diff --git a/src/i18n/locales/zh-TW/desktop.json b/src/i18n/locales/zh-TW/desktop.json
index 4ff452a..b483d56 100644
--- a/src/i18n/locales/zh-TW/desktop.json
+++ b/src/i18n/locales/zh-TW/desktop.json
@@ -35,13 +35,17 @@
"fullscreenAria": "進入全螢幕",
"exitFullscreen": "結束全螢幕",
"exitFullscreenAria": "結束全螢幕",
- "exitFullscreenHint": "Esc / F11 結束"
+ "exitFullscreenHint": "Esc / F11 結束",
+ "reconnect": "重新連線",
+ "reconnectAria": "重新連線遠端桌面"
},
"status": {
"connecting": "正在連線…",
"connected": "已連線",
"disconnected": "已中斷",
"connectFailed": "連線失敗",
+ "reconnecting": "自動重連中({{attempt}}/{{max}})…",
+ "reconnectLimit": "自動重連已達上限({{max}})",
"clipboardSynced": "已同步遠端剪貼簿",
"savedToDownloads": "已儲存到本機下載資料夾:{{name}}",
"pastedToRemote": "已貼上到遠端",
diff --git a/src/i18n/locales/zh-TW/errors.json b/src/i18n/locales/zh-TW/errors.json
index 762f4de..d47a21a 100644
--- a/src/i18n/locales/zh-TW/errors.json
+++ b/src/i18n/locales/zh-TW/errors.json
@@ -41,7 +41,10 @@
"refused": "連線被拒絕",
"disconnected": "連線已中斷",
"keepaliveFailed": "SSH 心跳連續失敗,連線已關閉",
- "idleKicked": "遠端 Shell 因閒置逾時退出(TMOUT)"
+ "idleKicked": "遠端 Shell 因閒置逾時退出(TMOUT)",
+ "unexpectedDropped": "連線已中斷(網路中斷或伺服器關閉)",
+ "shellExited": "遠端 Shell 已退出(exit、Ctrl+D 或伺服器策略)",
+ "shellReadFailed": "遠端 Shell 讀取失敗"
},
"auth": {
"failed": "認證失敗,請檢查使用者名稱和密碼",
@@ -151,8 +154,8 @@
"nativeOnly": "序列埠僅在 Tauri 原生應用中可用"
},
"ai": {
- "noProvider": "請先在設定 → 大模型設定 Provider",
- "noApiKey": "「{{provider}}」尚未儲存 API Key",
+ "noProvider": "請先在設定 → 大模型裡設定供應商",
+ "noApiKey": "「{{provider}}」尚未儲存 API 金鑰",
"modelEmpty": "模型未傳回內容",
"protocolFailed": "模型連續兩次未遵循協定,已停止",
"noConclusion": "模型未傳回可用結論",
@@ -160,7 +163,7 @@
"invalidCommand": "模型未傳回唯一的單行命令,已拒絕執行",
"repeatLoop": "偵測到重複命令迴圈,已停止:{{command}}",
"criticalBlocked": "命令命中破壞性操作硬紅線,不提供確認放行:{{command}}",
- "execFailed": "ssh-exec 失敗:{{error}}",
+ "execFailed": "遠端執行失敗:{{error}}",
"maxSteps": "已達 {{max}} 步上限,請縮小範圍後重試",
"sshRequired": "需要已連線的遠端 SSH"
},
@@ -168,6 +171,12 @@
"readFailed": "讀取失敗: {{name}}",
"writeFailed": "寫入失敗: {{name}}"
},
+ "native": {
+ "taskJoin": "背景工作失敗,請重試",
+ "invalidBase64": "檔案資料不是有效的 Base64",
+ "knownHostsPath": "無法定位 ~/.ssh/known_hosts",
+ "invalidHostKeyDecision": "主機金鑰決定無效"
+ },
"unknown": {
"error": "發生未知錯誤"
}
diff --git a/src/i18n/locales/zh-TW/sessions.json b/src/i18n/locales/zh-TW/sessions.json
index 0880f37..dc90316 100644
--- a/src/i18n/locales/zh-TW/sessions.json
+++ b/src/i18n/locales/zh-TW/sessions.json
@@ -132,7 +132,8 @@
"collapse": "收起",
"protocol": "通訊協定",
"displayName": "顯示名稱(可選)",
- "displayNamePlaceholder": "自動用 user@host 或 vnc:host",
+ "displayNamePlaceholder": "自動用 user@host",
+ "displayNamePlaceholderVnc": "自動用主機名稱",
"host": "主機",
"hostPlaceholder": "192.168.1.10",
"hostHint": "填寫 IP 或網域名稱;也可貼上 host:port",
@@ -208,7 +209,8 @@
},
"importExport": {
"exportTitle": "匯出連線",
- "exportDefaultPath": "opsxshell-sessions.bak",
+ "exportDefaultPath": "opsxshell-hosts.oxs",
+ "exportFilterOxs": "OpsXShell 主機備份",
"exportFilterBak": "OpsXShell 備份",
"exportFilterJson": "JSON",
"exportSuccess": "已匯出 {{count}} 個連線到:\n{{path}}",
@@ -224,6 +226,7 @@
"exportPasswordMismatch": "兩次輸入的密碼不一致",
"exportPasswordRequired": "必須設定備份密碼才能匯出",
"importTitle": "匯入連線",
+ "importFilterOxs": "OpsXShell 主機備份",
"importFilterBak": "OpsXShell 備份",
"importFilterJson": "JSON",
"importFilterSshConfig": "SSH Config",
diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json
index b2563e8..bd2c6a8 100644
--- a/src/i18n/locales/zh-TW/settings.json
+++ b/src/i18n/locales/zh-TW/settings.json
@@ -11,9 +11,9 @@
"security": "安全",
"securityDesc": "憑證與主機金鑰",
"ai": "大模型",
- "aiDesc": "AI Provider",
+ "aiDesc": "AI 供應商",
"about": "關於",
- "aboutDesc": "版本與資料",
+ "aboutDesc": "版本、聯絡與診斷",
"account": "帳戶",
"accountDesc": "電子郵件、授權與裝置"
},
@@ -89,9 +89,9 @@
"ssh": {
"connection": "連線",
"keepAlive": "保持心跳",
- "keepAliveDesc": "閒置時發送 SSH 心跳;連續多次無回應才會斷線",
+ "keepAliveDesc": "閒置時發送 SSH 協定心跳(不寫入 Shell)。只擋 NAT/防火牆丟連線,不會繞過遠端 TMOUT",
"autoReconnect": "自動重連",
- "autoReconnectDesc": "意外斷線後自動嘗試重連",
+ "autoReconnectDesc": "分頁仍開著、意外斷線時自動重連(新 Shell)。關閉分頁、閒置掛起或遠端 TMOUT 不會硬續",
"timeout": "連線逾時",
"timeoutDesc": "建立 SSH 連線的最長等待時間(秒)",
"maxReconnect": "自動重連次數",
@@ -104,7 +104,8 @@
"suspend": "背景掛起",
"suspendDesc": "背景標籤閒置多少分鐘後掛起(有活躍 Shell/SFTP 時保持連線)",
"keepAliveInterval": "心跳間隔(秒)",
- "keepAliveIntervalDesc": "向伺服器發送心跳的頻率(類似 ClientAliveInterval)。不穩定網路建議 ≤15 秒"
+ "keepAliveIntervalDesc": "向伺服器發送心跳的頻率(類似 ClientAliveInterval)。不穩定網路建議 ≤15 秒",
+ "sessionPolicyHint": "心跳保的是鏈路,不是遠端工作階段策略。Bash TMOUT、sshd ClientAlive 與 Windows 閒置 GPO 仍會生效;長任務請在伺服器上用 tmux/screen。"
},
"security": {
"credentials": "憑證保護",
@@ -122,23 +123,32 @@
},
"about": {
"tagline": "簡單、便捷、人性化的原生維運終端",
- "appVersion": "應用 v{{version}}",
- "copyVersion": "複製版本資訊",
- "runtime": "執行環境",
- "system": "系統",
- "localData": "本機資料",
+ "appVersion": "v{{version}}",
+ "copyVersion": "複製版本、系統與路徑,方便貼到 GitHub",
+ "copyDiagnostics": "複製診斷資訊",
+ "copyPath": "複製路徑",
+ "resources": "資源",
+ "wechat": "微信",
+ "copyWechat": "複製微信帳號",
+ "github": "討論",
+ "githubDesc": "https://github.com/L00J/opsxshell/discussions",
+ "releases": "版本發布",
+ "releasesDesc": "安裝包與更新說明",
+ "licenseLink": "授權",
+ "licenseLinkDesc": "MIT License",
+ "diagnostics": "診斷",
"sessionsSettings": "連線與設定",
- "masterKeyPath": "加密主金鑰",
- "privacyNote": "密碼與金鑰口令由 Rust 使用 AES-256-GCM 加密。不保存終端機指令歷史,也不做堡壘稽核。",
"license": "MIT License · Rust · XTerm.js",
- "logPath": "應用程式日誌"
+ "logPath": "應用程式日誌",
+ "reportBug": "回報問題",
+ "reportBugDesc": "開啟 GitHub issue,並帶上目前版本與系統"
},
"ai": {
"presets": "供應商預設",
"add": "新增",
"configured": "已設定",
"configuredHint": "選擇供應商後點新增",
- "emptyHint": "新增一個 Provider 開始",
+ "emptyHint": "新增一個供應商開始",
"noModel": "未選模型",
"name": "名稱",
"model": "模型",
@@ -157,13 +167,15 @@
"otherModels": "其他模型…",
"customModel": "自訂模型",
"backToList": "返回列表",
- "apiKeyOptional": "API Key(無需)",
+ "apiKey": "API 金鑰",
+ "apiKeyOptional": "API 金鑰(無需)",
+ "endpoint": "端點",
"advanced": "進階",
"allowHttp": "允許 HTTP(本機/內網)",
"setActive": "設為目前",
"deleteTitle": "刪除",
- "apiKeySaveFailed": "API Key 儲存失敗",
- "providerNotFound": "Provider 不存在",
+ "apiKeySaveFailed": "API 金鑰儲存失敗",
+ "providerNotFound": "供應商不存在",
"modelCatalogReadFailed": "模型目錄讀取失敗",
"catalogUnavailable": "models.dev 暫不可用",
"family": {
diff --git a/src/i18n/locales/zh-TW/sftp.json b/src/i18n/locales/zh-TW/sftp.json
index 943ef35..219c69d 100644
--- a/src/i18n/locales/zh-TW/sftp.json
+++ b/src/i18n/locales/zh-TW/sftp.json
@@ -15,7 +15,8 @@
"notConnected": "未連線",
"connecting": "連線中...",
"resumeHint": "重連後將回到 {{path}}",
- "reconnectingResume": "正在重連並恢復 {{path}}…"
+ "reconnectingResume": "正在重連並恢復 {{path}}…",
+ "autoReconnecting": "自動重連中({{attempt}}/{{max}})…"
},
"errors": {
"notConnected": "SFTP 尚未連線",
diff --git a/src/i18n/locales/zh-TW/terminal.json b/src/i18n/locales/zh-TW/terminal.json
index 1b094f4..1d079d0 100644
--- a/src/i18n/locales/zh-TW/terminal.json
+++ b/src/i18n/locales/zh-TW/terminal.json
@@ -34,7 +34,9 @@
"reopenShell": "重新開啟 Shell",
"reopening": "正在開啟…",
"disconnected": "連線已中斷",
- "disconnectedHint": "SSH 連線已結束或網路中斷。點下方一鍵重新連線,即可繼續工作。",
+ "disconnectedHint": "網路中斷或伺服器關閉了 SSH。分頁仍開著時會自動重連;也可點下方立即重連。",
+ "disconnectedKeepalive": "心跳失敗,連線已中斷",
+ "disconnectedKeepaliveHint": "SSH 心跳連續無回應,多半是 NAT 或網路丟了閒置連線。不會向 Shell 發送按鍵。分頁仍開著時會自動重連。",
"reconnect": "重新連線",
"suspended": "連線已暫停",
"suspendedHint": "長時間未操作,連線已進入暫停以節省資源",
diff --git a/src/index.css b/src/index.css
index 3a8b35c..735417e 100644
--- a/src/index.css
+++ b/src/index.css
@@ -214,6 +214,8 @@
html,
body {
+ width: 100%;
+ height: 100%;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
'Helvetica Neue', sans-serif;
@@ -225,20 +227,100 @@ body {
}
#root {
- width: 100vw;
- height: 100vh;
+ width: 100%;
+ height: 100%;
}
-/* Window chrome utilities
- * Tauri: drag region must be large enough to move the window.
- * Interactive controls inside drag areas MUST set no-drag.
+/*
+ * App chrome contract
+ *
+ * Layout:
+ * TitleBar (macOS overlay drag)
+ * Body: Sidebar | Main (TabBar + TabContent)
+ * StatusBar (full window width)
+ * Overlays portaled to document.body — never sit in the flex flow
+ *
+ * Drag: ONLY `data-tauri-drag-region` on empty chrome (title bar, tab-bar
+ * spacer). Do not also call startDragging() in React, and do not paint
+ * -webkit-app-region: drag on those nodes — the two fight and the window
+ * stops moving. Interactive controls use .app-no-drag.
+ *
+ * Surfaces:
+ * UI chrome → surface tokens (theme-aware)
+ * Terminal canvas → --color-terminal-bg (always dark)
+ *
+ * Height: fill the webview with 100% (html/body/#root/.app-shell), not 100vh.
+ * Overlay title-bar + windowed WKWebView makes 100vh shorter than the window,
+ * which shows up as a strip above the status bar.
*/
+.app-shell {
+ display: flex;
+ height: 100%;
+ max-height: 100%;
+ min-height: 0;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.app-overlay {
+ position: fixed;
+ inset: 0;
+ pointer-events: auto;
+ -webkit-app-region: no-drag;
+ app-region: no-drag;
+}
+
+.app-workspace {
+ display: flex;
+ height: 100%;
+ min-height: 0;
+ flex-direction: column;
+ overflow: hidden;
+ background: var(--color-surface-base);
+}
+
+.app-workspace-body {
+ display: flex;
+ min-height: 0;
+ min-width: 0;
+ flex: 1;
+ align-items: stretch;
+ overflow: hidden;
+}
+
+.terminal-canvas {
+ position: relative;
+ min-height: 0;
+ min-width: 0;
+ flex: 1;
+ overflow: hidden;
+ background: var(--color-terminal-bg);
+}
+
+.app-agent-panel {
+ display: flex;
+ width: 430px;
+ max-width: 42vw;
+ min-height: 0;
+ align-self: stretch;
+ flex-shrink: 0;
+ flex-direction: column;
+ overflow: hidden;
+ border-left: 1px solid var(--hairline);
+ background: var(--color-surface-raised);
+}
+
+.app-statusbar {
+ flex-shrink: 0;
+}
+
.drag,
.titlebar-drag {
-webkit-app-region: drag;
app-region: drag;
}
+.app-no-drag,
.no-drag,
.titlebar-no-drag {
-webkit-app-region: no-drag;
@@ -253,12 +335,20 @@ body.sftp-pointer-dragging * {
cursor: grabbing !important;
}
+/* Sidebar session list: keep the arrow. Group headers are