diff --git a/src/renderer/src/monitor/MonitorPanel.tsx b/src/renderer/src/monitor/MonitorPanel.tsx index 8cc93da..dbc7c9e 100644 --- a/src/renderer/src/monitor/MonitorPanel.tsx +++ b/src/renderer/src/monitor/MonitorPanel.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react' import type { SysinfoMeta, SysinfoSample } from '@shared/sysinfo' -import { t } from '@shared/i18n' +import { DEFAULT_LANGUAGE, tFor, type Language } from '@shared/i18n' +import { useSettingsStore } from '@renderer/settings/store' import { useWorkspaceModeStore } from '../workspace/workspaceModeStore' import './monitor.css' @@ -119,14 +120,14 @@ function formatSize(mb: number): string { } /** human-readable uptime */ -function humanizeUptime(sec: number): string { +function humanizeUptime(lang: Language, sec: number): string { const s = Math.floor(sec) - if (s < 60) return t('ssh.monitor.uptimeSeconds', { n: s }) + if (s < 60) return tFor(lang, 'ssh.monitor.uptimeSeconds', { n: s }) const mins = Math.floor(s / 60) - if (s < 3600) return t('ssh.monitor.uptimeMinutes', { n: mins }) + if (s < 3600) return tFor(lang, 'ssh.monitor.uptimeMinutes', { n: mins }) const hours = Math.floor(s / 3600) - if (s < 86400) return t('ssh.monitor.uptimeHours', { h: hours, m: Math.floor((s % 3600) / 60) }) - return t('ssh.monitor.uptimeDays', { n: Math.floor(s / 86400) }) + if (s < 86400) return tFor(lang, 'ssh.monitor.uptimeHours', { h: hours, m: Math.floor((s % 3600) / 60) }) + return tFor(lang, 'ssh.monitor.uptimeDays', { n: Math.floor(s / 86400) }) } function Bar({ pct, color }: { pct: number; color: string }) { @@ -142,6 +143,10 @@ function Bar({ pct, color }: { pct: number; color: string }) { } export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Element { + // Rendered inside a dockview panel, which is never re-rendered as part of the + // App→Workspace tree, so the language is subscribed here and every string is + // resolved with `tFor` instead of the module-level language `t()` reads. + const language = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE) const [meta, setMeta] = useState(null) const [sample, setSample] = useState(null) const sampleRef = useRef(null) @@ -188,14 +193,14 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen if (sample?.error) { return (
-
{t('ssh.monitor.interrupted', { error: sample.error })}
+
{tFor(language, 'ssh.monitor.interrupted', { error: sample.error })}
) } return (
- {t('ssh.monitor.collecting')} + {tFor(language, 'ssh.monitor.collecting')}
) } @@ -210,7 +215,7 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen return (
{failed && ( -
{t('ssh.monitor.interrupted', { error: sample.error ?? '' })}
+
{tFor(language, 'ssh.monitor.interrupted', { error: sample.error ?? '' })}
)}
@@ -222,19 +227,19 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
CPU - {cpu.usage.toFixed(0)}% · {t('ssh.monitor.cores', { n: cpu.cores })} + {cpu.usage.toFixed(0)}% · {tFor(language, 'ssh.monitor.cores', { n: cpu.cores })}
- {t('ssh.monitor.loadavg', { value: cpu.loadavg.map((v) => v.toFixed(1)).join(' / ') })} + {tFor(language, 'ssh.monitor.loadavg', { value: cpu.loadavg.map((v) => v.toFixed(1)).join(' / ') })}
-
+
- {t('ssh.monitor.mem')} + {tFor(language, 'ssh.monitor.mem')} {formatSize(mem.usedMb)} / {formatSize(mem.totalMb)} @@ -248,9 +253,9 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
{disks.length > 0 && ( -
+
- {t('ssh.monitor.disks')} + {tFor(language, 'ssh.monitor.disks')}
{disks.slice(0, 6).map((d) => { @@ -273,9 +278,9 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
)} -
+
- {t('ssh.monitor.net')} + {tFor(language, 'ssh.monitor.net')}
↓ @@ -296,7 +301,7 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
- {t('ssh.monitor.uptime')} {humanizeUptime(sample.uptimeSec)} + {tFor(language, 'ssh.monitor.uptime')} {humanizeUptime(language, sample.uptimeSec)}
) diff --git a/src/renderer/src/sftp/FilePanel.tsx b/src/renderer/src/sftp/FilePanel.tsx index 1c00057..06a27d5 100644 --- a/src/renderer/src/sftp/FilePanel.tsx +++ b/src/renderer/src/sftp/FilePanel.tsx @@ -19,7 +19,8 @@ import { import { App, Button, Checkbox, Dropdown, Input, Modal, Popconfirm, Tooltip } from 'antd' import type { MenuProps } from 'antd' import type { SftpEntry, TransferProgressEvent } from '@shared/sftp' -import { getLanguage, t } from '@shared/i18n' +import { DEFAULT_LANGUAGE, tFor, type Language } from '@shared/i18n' +import { useSettingsStore } from '@renderer/settings/store' import './sftp.css' /** Remote file browser for one SSH session (M4). */ @@ -89,9 +90,9 @@ function bitsToOctal(bits: PermBits): string { return special > 0 ? `${special}${perms}` : perms } -/** t() falls back to the key itself when a translation is missing. */ -function tOr(key: string, fallback: string): string { - const value = t(key) +/** tFor() falls back to the key itself when a translation is missing. */ +function tOr(lang: Language, key: string, fallback: string): string { + const value = tFor(lang, key) return value === key ? fallback : value } @@ -126,12 +127,20 @@ function ownerLabel(e: SftpEntry): string { } /** Permission-grid labels are resolved per render so they follow the language. */ -function permRowLabels(): string[] { - return [t('ssh.file.permRowOwner'), t('ssh.file.permRowGroup'), t('ssh.file.permRowOther')] +function permRowLabels(lang: Language): string[] { + return [ + tFor(lang, 'ssh.file.permRowOwner'), + tFor(lang, 'ssh.file.permRowGroup'), + tFor(lang, 'ssh.file.permRowOther') + ] } -function permColLabels(): string[] { - return [t('ssh.file.permColRead'), t('ssh.file.permColWrite'), t('ssh.file.permColExec')] +function permColLabels(lang: Language): string[] { + return [ + tFor(lang, 'ssh.file.permColRead'), + tFor(lang, 'ssh.file.permColWrite'), + tFor(lang, 'ssh.file.permColExec') + ] } /** @@ -196,9 +205,11 @@ interface PermissionModalProps { entry: SftpEntry | null onClose: () => void onSaved: () => void + /** from FilePanel, which owns the language subscription — see there */ + language: Language } -function PermissionModal({ open, sessionId, entry, onClose, onSaved }: PermissionModalProps): React.JSX.Element { +function PermissionModal({ open, sessionId, entry, onClose, onSaved, language }: PermissionModalProps): React.JSX.Element { const { message } = App.useApp() const [bits, setBits] = useState(emptyBits) const [octal, setOctal] = useState('') @@ -208,8 +219,8 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio const hasUidGid = entry != null && (entry.uid != null || entry.gid != null) /** lstat failed while listing → no mode to compare against or preserve. */ const modeKnown = Boolean(entry?.mode) - const rowLabels = permRowLabels() - const colLabels = permColLabels() + const rowLabels = permRowLabels(language) + const colLabels = permColLabels(language) const modeStr = useMemo(() => { if (!entry) return { type: '-', str: '---------' } @@ -302,7 +313,7 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio await window.api.chownRemote(sessionId, entry.path, u, g) } if (modeChanged || ownerChanged) { - message.success(t('ssh.file.permApplied')) + message.success(tFor(language, 'ssh.file.permApplied')) } onClose() onSaved() @@ -316,13 +327,13 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio return ( @@ -355,11 +366,11 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
- {t('ssh.file.octal')} + {tFor(language, 'ssh.file.octal')} applyOctal(e.target.value)} - placeholder={t('ssh.file.octalPlaceholder')} + placeholder={tFor(language, 'ssh.file.octalPlaceholder')} addonAfter={bitsToModeStr(modeStr.type, bits)} />
@@ -370,7 +381,7 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio setUid(e.target.value)} - placeholder={hasUidGid ? t('ssh.file.keepEmpty') : '0'} + placeholder={hasUidGid ? tFor(language, 'ssh.file.keepEmpty') : '0'} />
@@ -378,14 +389,14 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio setGid(e.target.value)} - placeholder={hasUidGid ? t('ssh.file.keepEmpty') : '0'} + placeholder={hasUidGid ? tFor(language, 'ssh.file.keepEmpty') : '0'} />
-
{t('ssh.file.ownerHint')}
- {entry && !modeKnown &&
{t('ssh.file.modeUnavailable')}
} +
{tFor(language, 'ssh.file.ownerHint')}
+ {entry && !modeKnown &&
{tFor(language, 'ssh.file.modeUnavailable')}
} {(idInvalid(uid) || idInvalid(gid)) && ( -
{t('main.sftp.invalidUidGid')}
+
{tFor(language, 'main.sftp.invalidUidGid')}
)}
@@ -397,8 +408,15 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio * lifetime, so dragging the SSH divider (which re-renders SshBottomPanel on * every pointermove) no longer re-renders the whole file browser. Switching to * a different directory still works — it is internal state. + * + * The stable prop is also why the language has to be subscribed here: memo + * blocks the parent's re-render, and the panel lives in dockview's own render + * slot on top of that, so nothing else would ever re-render it. Every string + * below therefore goes through `tFor(language, …)` — `t()` would read the + * module-level language of whatever render last touched it. */ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element { + const language = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE) const { message, modal } = App.useApp() const [dir, setDir] = useState('/') const [entries, setEntries] = useState([]) @@ -640,9 +658,9 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps): startUpload(files) return } - const desc = tOr('ssh.file.overwriteDesc', '') + const desc = tOr(language, 'ssh.file.overwriteDesc', '') modal.confirm({ - title: tOr('ssh.file.overwriteTitle', t('ssh.file.uploadFile')), + title: tOr(language, 'ssh.file.overwriteTitle', tFor(language, 'ssh.file.uploadFile')), content: (
{existing.map((name) => ( @@ -651,9 +669,9 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps): {desc &&
{desc}
}
), - okText: t('common.ok'), + okText: tFor(language, 'common.ok'), okButtonProps: { danger: true }, - cancelText: t('common.cancel'), + cancelText: tFor(language, 'common.cancel'), onOk: () => startUpload(files) }) } catch (err) { @@ -721,16 +739,13 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps): void (async () => { try { await navigator.clipboard.writeText(entry.path) - message.success(t('ssh.file.pathCopied')) + message.success(tFor(language, 'ssh.file.pathCopied')) } catch { - message.error(t('ssh.file.copyPathFailed')) + message.error(tFor(language, 'ssh.file.copyPathFailed')) } })() } - /** Active language; a dep of the menu below so translated labels refresh. */ - const language = getLanguage() - /** opens the mkdir modal in create-a-file mode (no-op: new files unsupported) */ const menu: MenuProps = useMemo(() => { const target = menuTarget @@ -738,51 +753,51 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps): const canAction = target != null return { items: [ - { key: 'refresh', label: t('ssh.file.refresh'), icon: }, + { key: 'refresh', label: tFor(language, 'ssh.file.refresh'), icon: }, { type: 'divider' }, { key: 'open', - label: isDir ? t('ssh.file.open') : t('ssh.file.download'), + label: isDir ? tFor(language, 'ssh.file.open') : tFor(language, 'ssh.file.download'), icon: isDir ? : , disabled: !canAction }, { key: 'download', - label: t('ssh.file.download'), + label: tFor(language, 'ssh.file.download'), icon: , disabled: !canAction || Boolean(isDir) }, - { key: 'upload', label: t('ssh.file.upload'), icon: }, + { key: 'upload', label: tFor(language, 'ssh.file.upload'), icon: }, { key: 'rename', - label: t('ssh.file.rename'), + label: tFor(language, 'ssh.file.rename'), icon: , disabled: !canAction }, { type: 'submenu', key: 'new', - label: t('ssh.file.new'), + label: tFor(language, 'ssh.file.new'), icon: , children: [ - { key: 'mkdir', label: t('ssh.file.newFolder'), icon: } + { key: 'mkdir', label: tFor(language, 'ssh.file.newFolder'), icon: } // 新建文件:无现成 SFTP 创建文件 API(main 仅 mkdir/rename/chmod/chown),不做 ] }, { type: 'divider' }, { key: 'copyPath', - label: t('ssh.file.copyPath'), + label: tFor(language, 'ssh.file.copyPath'), icon: , disabled: !canAction }, { key: 'permission', - label: t('ssh.file.permission'), + label: tFor(language, 'ssh.file.permission'), icon: , disabled: !canAction }, - { key: 'delete', label: t('common.delete'), icon: , danger: true, disabled: !canAction } + { key: 'delete', label: tFor(language, 'common.delete'), icon: , danger: true, disabled: !canAction } ], onClick: ({ key }) => { const entry = menuTarget @@ -808,11 +823,11 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps): setPermOpen(true) } else if (key === 'delete') { modal.confirm({ - title: t('ssh.file.deleteTitle'), + title: tFor(language, 'ssh.file.deleteTitle'), content: entry ? `${entry.name}(${entry.path})` : undefined, - okText: t('common.delete'), + okText: tFor(language, 'common.delete'), okButtonProps: { danger: true }, - cancelText: t('common.cancel'), + cancelText: tFor(language, 'common.cancel'), onOk: () => new Promise((resolve, reject) => { if (!entry) { @@ -864,7 +879,7 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps): {e.isDir ? '' : fmtSize(e.size)} {fmtTime(e.mtime)} - + {e.mode ?? '----------'} @@ -888,7 +903,7 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps): return (
- + )} {/* Cancelled rows dismiss themselves after 3s, but one that also diff --git a/src/renderer/src/terminal/TerminalView.tsx b/src/renderer/src/terminal/TerminalView.tsx index 30730fa..a3c3b67 100644 --- a/src/renderer/src/terminal/TerminalView.tsx +++ b/src/renderer/src/terminal/TerminalView.tsx @@ -7,7 +7,7 @@ import { SearchAddon } from '@xterm/addon-search' import '@xterm/xterm/css/xterm.css' import { ClearOutlined, CopyOutlined, ExportOutlined, FolderOpenOutlined, PauseOutlined, SearchOutlined, SelectOutlined, SnippetsOutlined, SoundOutlined } from '@ant-design/icons' import { Checkbox, Modal } from 'antd' -import { t } from '@shared/i18n' +import { DEFAULT_LANGUAGE, tFor, type Language } from '@shared/i18n' import { getThemeById } from '@shared/theme' import type { CommandItem } from '@shared/commands' import { highlightModeOf, type TerminalSettings } from '@shared/settings' @@ -113,7 +113,25 @@ interface SearchBarProps { onNext: () => void onClose: () => void enabled: boolean - result: string + result: SearchResult + /** Subscribed in TerminalView; passed down so the bar's own labels follow a + * language switch (a module-level `t()` would be read at the wrong moment). */ + language: Language +} + +/** + * Result slot of the search bar: the addon's match counter, or a manual find's + * verdict. Kept as a tag rather than as a rendered string because both + * producers run outside the render (a click handler and an addon event), so a + * stored string would keep the language it was built in for as long as it is + * on screen. + */ +type SearchResult = { index: number; count: number } | 'found' | 'notFound' | null + +function searchResultText(result: SearchResult, language: Language): string { + if (result === null) return '' + if (typeof result === 'object') return `${result.index}/${result.count}` + return tFor(language, result === 'found' ? 'terminal.search.found' : 'terminal.search.notFound') } /** M5: cap for the floating completion popup height in px. */ @@ -125,14 +143,14 @@ function estimatePopupHeight(itemCount: number): number { return Math.min(SUGGEST_POPUP_H, Math.max(70, itemCount * 22 + 28)) } -function SearchBar({ value, onChange, onPrev, onNext, onClose, enabled, result }: SearchBarProps): React.JSX.Element { +function SearchBar({ value, onChange, onPrev, onNext, onClose, enabled, result, language }: SearchBarProps): React.JSX.Element { return (
onChange(e.target.value)} @@ -142,10 +160,10 @@ function SearchBar({ value, onChange, onPrev, onNext, onClose, enabled, result } else if (e.key === 'Escape') onClose() }} /> - - - {result} - + + + {searchResultText(result, language)} +
) } @@ -299,6 +317,12 @@ export function TerminalView({ })) ) const rendererMode = useSettingsStore((s) => s.settings.terminal.rendererMode) + // dockview owns this component's render slot, so an App→Workspace re-render + // never reaches it: the language must be subscribed here and passed to + // `tFor` explicitly, or every label would keep the language it was first + // rendered with. One single-field selector — kept out of the five shallow + // groups below so a language write cannot re-run their work. + const language = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE) const highlightOpts = useSettingsStore( useShallow((s) => ({ mode: highlightModeOf(s.settings.terminal.highlightMode), @@ -441,7 +465,7 @@ export function TerminalView({ const [searchOpen, setSearchOpen] = useState(false) const [searchTerm, setSearchTerm] = useState('') - const [resultInfo, setResultInfo] = useState('') + const [searchResult, setSearchResult] = useState(null) // Right-click context menu: position + whether a selection existed at open // time (decides if 复制 is enabled). const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null) @@ -573,7 +597,7 @@ export function TerminalView({ searchOpenRef.current = false searchRef.current?.clearDecorations() setSearchOpen(false) - setResultInfo('') + setSearchResult(null) termRef.current?.focus() }, []) @@ -585,7 +609,9 @@ export function TerminalView({ dir === 1 ? addon.findNext(q, { decorations: SEARCH_DECORATIONS }) : addon.findPrevious(q, { decorations: SEARCH_DECORATIONS }) - setResultInfo(found ? t('terminal.search.found') : t('terminal.search.notFound')) + // Store the verdict, not the string: this runs from a click handler, and a + // stored translation would stay in the language it was produced in. + setSearchResult(found ? 'found' : 'notFound') }, []) /** Record a submitted command line; cd-style lines also update cwd memory. */ @@ -837,7 +863,12 @@ export function TerminalView({ const searchAddon = new SearchAddon() searchAddon.onDidChangeResults((ev) => { if (searchOpenRef.current && searchTermRef.current.trim()) { - setResultInfo(`${Math.max(ev.resultIndex + 1, 0)}/${ev.resultCount}`) + // Keep the identity of an unchanged counter: this fires on every search + // step in a busy pane, and React bails out only on an unchanged value. + const next = { index: Math.max(ev.resultIndex + 1, 0), count: ev.resultCount } + setSearchResult((prev) => + typeof prev === 'object' && prev?.index === next.index && prev.count === next.count ? prev : next + ) } }) term.loadAddon(searchAddon) @@ -1300,7 +1331,7 @@ export function TerminalView({ onClick={() => runMenuAction(() => void copySelection())} > - {t('common.copy')} + {tFor(language, 'common.copy')} Ctrl+Shift+C
@@ -1340,7 +1371,8 @@ export function TerminalView({ onNext={() => search(1)} onClose={closeSearch} enabled={searchTerm.trim().length > 0} - result={resultInfo} + result={searchResult} + language={language} /> )}
@@ -1348,8 +1380,8 @@ export function TerminalView({
-

{t('terminal.paste.message')}

+

{tFor(language, 'terminal.paste.message')}

setPaste((prev) => (prev ? { ...prev, noPrompt: e.target.checked } : prev))} > - {t('terminal.paste.noPrompt')} + {tFor(language, 'terminal.paste.noPrompt')} setPaste((prev) => (prev ? { ...prev, disableDetection: e.target.checked } : prev))} > - {t('terminal.paste.disableDetection')} + {tFor(language, 'terminal.paste.disableDetection')} -

{t('terminal.paste.hint')}

+

{tFor(language, 'terminal.paste.hint')}

{/* Relative-positioned wrapper around the xterm surface: the suggestion @@ -1436,7 +1468,7 @@ export function TerminalView({ ))}
-
{t('terminal.suggest.hint')}
+
{tFor(language, 'terminal.suggest.hint')}
)}
@@ -1459,8 +1491,8 @@ export function TerminalView({ )} {dead && (
-
{t('terminal.dead.message', { code: exitCode })}
- +
{tFor(language, 'terminal.dead.message', { code: exitCode })}
+
)}
diff --git a/src/renderer/src/workspace/SshBottomPanel.tsx b/src/renderer/src/workspace/SshBottomPanel.tsx index 5b715b6..c36dec7 100644 --- a/src/renderer/src/workspace/SshBottomPanel.tsx +++ b/src/renderer/src/workspace/SshBottomPanel.tsx @@ -1,18 +1,21 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react' -import { t } from '@shared/i18n' +import { tFor, type Language } from '@shared/i18n' import { FilePanel } from '../sftp/FilePanel' export interface SshBottomPanelProps { sessionId: string /** The terminal surface rendered in the flexible top region. */ terminal: ReactNode + /** From TerminalPanel, the dockview-rendered parent that owns the language + * subscription — this panel only re-renders along with it. */ + language: Language } /** File browser height in px — clamped to [120, 60% of container height]. */ const MIN_HEIGHT = 120 -export function SshBottomPanel({ sessionId, terminal }: SshBottomPanelProps): React.JSX.Element { +export function SshBottomPanel({ sessionId, terminal, language }: SshBottomPanelProps): React.JSX.Element { /** Current file-browser height in px. */ const [filesHeight, setFilesHeight] = useState(220) /** Divider drag in flight — drives the accent highlight. */ @@ -82,7 +85,7 @@ export function SshBottomPanel({ sessionId, terminal }: SshBottomPanelProps): Re className={'ssh-bottom-divider' + (resizing ? ' is-resizing' : '')} role="separator" aria-orientation="horizontal" - aria-label={t('ssh.bottomPanel.resize')} + aria-label={tFor(language, 'ssh.bottomPanel.resize')} onPointerDown={onDividerPointerDown} onPointerMove={onDividerPointerMove} onPointerUp={endDrag} diff --git a/src/renderer/src/workspace/SshHostBadge.tsx b/src/renderer/src/workspace/SshHostBadge.tsx index b33071c..6b91191 100644 --- a/src/renderer/src/workspace/SshHostBadge.tsx +++ b/src/renderer/src/workspace/SshHostBadge.tsx @@ -1,17 +1,22 @@ -import { t } from '@shared/i18n' +import { tFor, type Language } from '@shared/i18n' /** * Small capsule badge showing the SSH host label (e.g. `root@36.151.147.123`) * on an SSH terminal tab. Host labels are often long, so the display string is * truncated with an ellipsis rather than wrapped; the full label is always * available via the native `title` tooltip and the tab-level tooltip. + * + * `language` comes from the tab component instead of a subscription of its own: + * the badge lives in dockview's tab render slot, so it follows TerminalTab's + * re-renders — but `t()` would then read the module-level language, which is + * only as fresh as the last App render. */ -export function SshHostBadge({ label }: { label: string }): React.JSX.Element { +export function SshHostBadge({ label, language }: { label: string; language: Language }): React.JSX.Element { return ( {label} diff --git a/src/renderer/src/workspace/TerminalPanel.tsx b/src/renderer/src/workspace/TerminalPanel.tsx index 93f3b9c..6d1a24f 100644 --- a/src/renderer/src/workspace/TerminalPanel.tsx +++ b/src/renderer/src/workspace/TerminalPanel.tsx @@ -3,7 +3,8 @@ import type { IDockviewPanelProps } from 'dockview-react' import { Button } from 'antd' import { AreaChartOutlined } from '@ant-design/icons' -import { t } from '@shared/i18n' +import { tFor, DEFAULT_LANGUAGE } from '@shared/i18n' +import { useSettingsStore } from '@renderer/settings/store' import { PanelErrorBoundary } from '../ErrorBoundary' import { TerminalView } from '../terminal/TerminalView' import { MonitorPanel } from '../monitor/MonitorPanel' @@ -42,6 +43,12 @@ export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps /** Right sidebar (monitor) visibility for ssh sessions — expanded by default. */ const [sideOpen, setSideOpen] = useState(true) + // dockview renders panel content from its own portal slot, so the panel never + // re-renders as part of the App→Workspace tree; the language is subscribed + // here and handed to `tFor` explicitly rather than read from the module-level + // language that `t()` uses. + const language = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE) + // M6 broadcast: keep the session in the broadcast registry while mounted so // it can be picked as a target / receive fan-out writes. Registrations are // keyed by panel id — an SSH split shows one session in two panes, and @@ -84,20 +91,20 @@ export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps // is remounted on retry; the shell (tab, broadcast registration) survives. return (
- + {isSsh && (