fix(i18n): 语言切换后 dockview/memo 组件不再停留旧语言

dockview 渲染的组件(面板内容/标签/header actions)不在 App→Workspace 的
重渲染路径上,React.memo 且 prop 恒定的组件也被挡住——它们的渲染期 t()
读模块级 current,不重渲染就停留旧值。日文界面下「+」按钮 tooltip、
SFTP 表头、搜索条等因此停留在中文。

修法沿用仓库既有惯例:组件自己订阅 settings.system.language,渲染文案改
tFor(language, …)(显式语言,不依赖模块级状态)。共 8 文件 145 处:

- TerminalTab / SshHostBadge / TerminalPanel / SshBottomPanel /
  TabActions(+按钮)/ BroadcastToggleButton / BroadcastPopover
- FilePanel(memo,60处) / PermissionModal / 传输面板行 / MonitorPanel
- TerminalView:搜索条、右键菜单、工具栏 tooltip、粘贴确认、结束遮罩;
  搜索命中提示改为「存标签、渲染期翻译」(消除 effect 闭包过期风险)

刻意不改:事件回调动作时构造的字符串(t() 在调用时刻已是新语言);
不给 DockviewReact 加 key={language}(会重挂载杀掉全部会话);
retitleAutoTitles 的存储标题机制保持不变。
This commit is contained in:
Bill committed 2026-10-10 15:18:48 +08:00
1 parent c94e3358ea
commit 402bf368fc
9 files changed
+263 -171

No files matched your search

+23 -18
View File
@@ -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<SysinfoMeta | null>(null)
const [sample, setSample] = useState<SysinfoSample | null>(null)
const sampleRef = useRef<SysinfoSample | null>(null)
@@ -188,14 +193,14 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
if (sample?.error) {
return (
<div className="mm-root mm-empty mm-failed">
<div className="mm-error">{t('ssh.monitor.interrupted', { error: sample.error })}</div>
<div className="mm-error">{tFor(language, 'ssh.monitor.interrupted', { error: sample.error })}</div>
</div>
)
}
return (
<div className="mm-root mm-empty">
<span className="mm-dots"><span /> <span /> <span /></span>
{t('ssh.monitor.collecting')}
{tFor(language, 'ssh.monitor.collecting')}
</div>
)
}
@@ -210,7 +215,7 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
return (
<div className={`mm-root${failed ? ' mm-failed' : ''}`}>
{failed && (
<div className="mm-error">{t('ssh.monitor.interrupted', { error: sample.error ?? '' })}</div>
<div className="mm-error">{tFor(language, 'ssh.monitor.interrupted', { error: sample.error ?? '' })}</div>
)}
<header className="mm-header">
@@ -222,19 +227,19 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
<div className="mm-block-row">
<span className="mm-label">CPU</span>
<span className="mm-value" style={{ color: cpuColor(cpu.usage) }}>
{cpu.usage.toFixed(0)}%<span className="mm-value-sub"> · {t('ssh.monitor.cores', { n: cpu.cores })}</span>
{cpu.usage.toFixed(0)}%<span className="mm-value-sub"> · {tFor(language, 'ssh.monitor.cores', { n: cpu.cores })}</span>
</span>
</div>
<Bar pct={cpu.usage} color={cpuColor(cpu.usage)} />
<div className="mm-sub mm-load">
{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(' / ') })}
</div>
<HistoryChart data={cpuHist} lineColor="#3fb950" fillColor="rgba(63,185,80,0.15)" height={48} maxY={100} />
</section>
<section className="mm-block" aria-label={t('ssh.monitor.mem')}>
<section className="mm-block" aria-label={tFor(language, 'ssh.monitor.mem')}>
<div className="mm-block-row">
<span className="mm-label">{t('ssh.monitor.mem')}</span>
<span className="mm-label">{tFor(language, 'ssh.monitor.mem')}</span>
<span className="mm-mem-size">
{formatSize(mem.usedMb)} / {formatSize(mem.totalMb)}
</span>
@@ -248,9 +253,9 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
</section>
{disks.length > 0 && (
<section className="mm-block" aria-label={t('ssh.monitor.disks')}>
<section className="mm-block" aria-label={tFor(language, 'ssh.monitor.disks')}>
<div className="mm-block-row">
<span className="mm-label">{t('ssh.monitor.disks')}</span>
<span className="mm-label">{tFor(language, 'ssh.monitor.disks')}</span>
</div>
<div className="mm-disks">
{disks.slice(0, 6).map((d) => {
@@ -273,9 +278,9 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
</section>
)}
<section className="mm-block" aria-label={t('ssh.monitor.net')}>
<section className="mm-block" aria-label={tFor(language, 'ssh.monitor.net')}>
<div className="mm-block-row">
<span className="mm-label">{t('ssh.monitor.net')}</span>
<span className="mm-label">{tFor(language, 'ssh.monitor.net')}</span>
<div className="mm-net">
<span className="mm-net-item mm-rx">
<span className="mm-arrow mm-up">↓</span>
@@ -296,7 +301,7 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
</section>
<footer className="mm-footer">
{t('ssh.monitor.uptime')} <span className="mm-uptime">{humanizeUptime(sample.uptimeSec)}</span>
{tFor(language, 'ssh.monitor.uptime')} <span className="mm-uptime">{humanizeUptime(language, sample.uptimeSec)}</span>
</footer>
</div>
)
+87 -71
View File
@@ -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<PermBits>(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 (
<Modal
title={t('ssh.file.permTitle', { name: entry?.name ?? '' })}
title={tFor(language, 'ssh.file.permTitle', { name: entry?.name ?? '' })}
open={open}
onOk={onSave}
onCancel={onClose}
confirmLoading={saving}
okText={t('common.save')}
cancelText={t('common.cancel')}
okText={tFor(language, 'common.save')}
cancelText={tFor(language, 'common.cancel')}
okButtonProps={{ disabled: idInvalid(uid) || idInvalid(gid) }}
width={380}
>
@@ -355,11 +366,11 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
</div>
<div className="sftp-perm-octal-input">
<span className="sftp-perm-label">{t('ssh.file.octal')}</span>
<span className="sftp-perm-label">{tFor(language, 'ssh.file.octal')}</span>
<Input
value={octal}
onChange={(e) => applyOctal(e.target.value)}
placeholder={t('ssh.file.octalPlaceholder')}
placeholder={tFor(language, 'ssh.file.octalPlaceholder')}
addonAfter={bitsToModeStr(modeStr.type, bits)}
/>
</div>
@@ -370,7 +381,7 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
<Input
value={uid}
onChange={(e) => setUid(e.target.value)}
placeholder={hasUidGid ? t('ssh.file.keepEmpty') : '0'}
placeholder={hasUidGid ? tFor(language, 'ssh.file.keepEmpty') : '0'}
/>
</div>
<div className="sftp-perm-own-field">
@@ -378,14 +389,14 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
<Input
value={gid}
onChange={(e) => setGid(e.target.value)}
placeholder={hasUidGid ? t('ssh.file.keepEmpty') : '0'}
placeholder={hasUidGid ? tFor(language, 'ssh.file.keepEmpty') : '0'}
/>
</div>
</div>
<div className="sftp-perm-hint">{t('ssh.file.ownerHint')}</div>
{entry && !modeKnown && <div className="sftp-perm-hint">{t('ssh.file.modeUnavailable')}</div>}
<div className="sftp-perm-hint">{tFor(language, 'ssh.file.ownerHint')}</div>
{entry && !modeKnown && <div className="sftp-perm-hint">{tFor(language, 'ssh.file.modeUnavailable')}</div>}
{(idInvalid(uid) || idInvalid(gid)) && (
<div className="sftp-perm-hint">{t('main.sftp.invalidUidGid')}</div>
<div className="sftp-perm-hint">{tFor(language, 'main.sftp.invalidUidGid')}</div>
)}
</div>
</Modal>
@@ -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<SftpEntry[]>([])
@@ -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: (
<div>
{existing.map((name) => (
@@ -651,9 +669,9 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
{desc && <div>{desc}</div>}
</div>
),
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: <ReloadOutlined /> },
{ key: 'refresh', label: tFor(language, 'ssh.file.refresh'), icon: <ReloadOutlined /> },
{ 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 ? <FolderOpenOutlined /> : <DownloadOutlined />,
disabled: !canAction
},
{
key: 'download',
label: t('ssh.file.download'),
label: tFor(language, 'ssh.file.download'),
icon: <DownloadOutlined />,
disabled: !canAction || Boolean(isDir)
},
{ key: 'upload', label: t('ssh.file.upload'), icon: <UploadOutlined /> },
{ key: 'upload', label: tFor(language, 'ssh.file.upload'), icon: <UploadOutlined /> },
{
key: 'rename',
label: t('ssh.file.rename'),
label: tFor(language, 'ssh.file.rename'),
icon: <FormOutlined />,
disabled: !canAction
},
{
type: 'submenu',
key: 'new',
label: t('ssh.file.new'),
label: tFor(language, 'ssh.file.new'),
icon: <FileAddOutlined />,
children: [
{ key: 'mkdir', label: t('ssh.file.newFolder'), icon: <FolderAddOutlined /> }
{ key: 'mkdir', label: tFor(language, 'ssh.file.newFolder'), icon: <FolderAddOutlined /> }
// 新建文件:无现成 SFTP 创建文件 API(main 仅 mkdir/rename/chmod/chown),不做
]
},
{ type: 'divider' },
{
key: 'copyPath',
label: t('ssh.file.copyPath'),
label: tFor(language, 'ssh.file.copyPath'),
icon: <CopyOutlined />,
disabled: !canAction
},
{
key: 'permission',
label: t('ssh.file.permission'),
label: tFor(language, 'ssh.file.permission'),
icon: <SafetyOutlined />,
disabled: !canAction
},
{ key: 'delete', label: t('common.delete'), icon: <DeleteOutlined />, danger: true, disabled: !canAction }
{ key: 'delete', label: tFor(language, 'common.delete'), icon: <DeleteOutlined />, 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<void>((resolve, reject) => {
if (!entry) {
@@ -864,7 +879,7 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
{e.isDir ? '' : fmtSize(e.size)}
</span>
<span className="sftp-time">{fmtTime(e.mtime)}</span>
<span className="sftp-mode" title={e.mode ?? t('ssh.file.modeUnavailable')}>
<span className="sftp-mode" title={e.mode ?? tFor(language, 'ssh.file.modeUnavailable')}>
{e.mode ?? '----------'}
</span>
<span className="sftp-owner" title={`UID ${e.uid ?? '—'} / GID ${e.gid ?? '—'}`}>
@@ -888,7 +903,7 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
return (
<div className="sftp-panel" style={scaleVars}>
<div className="sftp-toolbar">
<Tooltip title={t('ssh.file.up')}>
<Tooltip title={tFor(language, 'ssh.file.up')}>
<Button
type="text"
size="small"
@@ -897,10 +912,10 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
onClick={() => void refresh(parent)}
/>
</Tooltip>
<Tooltip title={t('ssh.file.refresh')}>
<Tooltip title={tFor(language, 'ssh.file.refresh')}>
<Button type="text" size="small" icon={<ReloadOutlined />} onClick={() => void refresh(dir)} />
</Tooltip>
<Tooltip title={t('ssh.file.newFolder')}>
<Tooltip title={tFor(language, 'ssh.file.newFolder')}>
<Button
type="text"
size="small"
@@ -911,10 +926,10 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
}}
/>
</Tooltip>
<Tooltip title={t('ssh.file.uploadFile')}>
<Tooltip title={tFor(language, 'ssh.file.uploadFile')}>
<Button type="text" size="small" icon={<UploadOutlined />} onClick={doUpload} />
</Tooltip>
<Tooltip title={t('ssh.file.downloadSelected')}>
<Tooltip title={tFor(language, 'ssh.file.downloadSelected')}>
<Button
type="text"
size="small"
@@ -923,7 +938,7 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
onClick={() => selected && doDownload(selected)}
/>
</Tooltip>
<Tooltip title={t('ssh.file.rename')}>
<Tooltip title={tFor(language, 'ssh.file.rename')}>
<Button
type="text"
size="small"
@@ -935,7 +950,7 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
}}
/>
</Tooltip>
<Tooltip title={t('ssh.file.permOwner')}>
<Tooltip title={tFor(language, 'ssh.file.permOwner')}>
<Button
type="text"
size="small"
@@ -945,15 +960,15 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
/>
</Tooltip>
<Popconfirm
title={t('ssh.file.deleteTitle')}
okText={t('common.delete')}
cancelText={t('common.cancel')}
title={tFor(language, 'ssh.file.deleteTitle')}
okText={tFor(language, 'common.delete')}
cancelText={tFor(language, 'common.cancel')}
onConfirm={doDelete}
>
<Button type="text" size="small" icon={<DeleteOutlined />} disabled={!selected} danger />
</Popconfirm>
<span className="sftp-toolbar-gap" />
<Tooltip title={t('ssh.file.zoomOut')}>
<Tooltip title={tFor(language, 'ssh.file.zoomOut')}>
<Button
type="text"
size="small"
@@ -962,7 +977,7 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
onClick={() => adjustScale(-FONT_SCALE_STEP)}
/>
</Tooltip>
<Tooltip title={t('ssh.file.zoomIn')}>
<Tooltip title={tFor(language, 'ssh.file.zoomIn')}>
<Button
type="text"
size="small"
@@ -1024,16 +1039,16 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
>
<div className="sftp-head">
<span className="sftp-head-cell" />
{headCell('name', 'sftp-col-name', t('ssh.file.colName'))}
{headCell('size', 'sftp-col-size', t('ssh.file.colSize'))}
{headCell('mtime', 'sftp-col-time', t('ssh.file.colModified'))}
<span className="sftp-head-cell sftp-col-mode">{t('ssh.file.colPerm')}</span>
<span className="sftp-head-cell sftp-col-owner">{t('ssh.file.colOwner')}</span>
{headCell('name', 'sftp-col-name', tFor(language, 'ssh.file.colName'))}
{headCell('size', 'sftp-col-size', tFor(language, 'ssh.file.colSize'))}
{headCell('mtime', 'sftp-col-time', tFor(language, 'ssh.file.colModified'))}
<span className="sftp-head-cell sftp-col-mode">{tFor(language, 'ssh.file.colPerm')}</span>
<span className="sftp-head-cell sftp-col-owner">{tFor(language, 'ssh.file.colOwner')}</span>
</div>
{loading && entriesSorted.length === 0 && (
<div className="sftp-empty">{t('ssh.file.loading')}</div>
<div className="sftp-empty">{tFor(language, 'ssh.file.loading')}</div>
)}
{!loading && entriesSorted.length === 0 && <div className="sftp-empty">{t('ssh.file.empty')}</div>}
{!loading && entriesSorted.length === 0 && <div className="sftp-empty">{tFor(language, 'ssh.file.empty')}</div>}
{virtualized ? (
<div className="sftp-list-virtual" style={{ height: entriesSorted.length * rowHeight }}>
<div
@@ -1056,31 +1071,32 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
entry={selected}
onClose={() => setPermOpen(false)}
onSaved={() => void refresh(dir)}
language={language}
/>
<Modal
title={t('ssh.file.newFolder')}
title={tFor(language, 'ssh.file.newFolder')}
open={mkdirOpen}
onOk={doMkdir}
onCancel={() => setMkdirOpen(false)}
okText={t('ssh.file.create')}
cancelText={t('common.cancel')}
okText={tFor(language, 'ssh.file.create')}
cancelText={tFor(language, 'common.cancel')}
width={360}
>
<Input
autoFocus
placeholder={t('ssh.file.folderName')}
placeholder={tFor(language, 'ssh.file.folderName')}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onPressEnter={doMkdir}
/>
</Modal>
<Modal
title={t('ssh.file.renameTitle', { name: selected?.name ?? '' })}
title={tFor(language, 'ssh.file.renameTitle', { name: selected?.name ?? '' })}
open={renameOpen}
onOk={doRename}
onCancel={() => setRenameOpen(false)}
okText={t('common.ok')}
cancelText={t('common.cancel')}
okText={tFor(language, 'common.ok')}
cancelText={tFor(language, 'common.cancel')}
width={360}
>
<Input
+18 -9
View File
@@ -1,7 +1,8 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { CloseOutlined } from '@ant-design/icons'
import type { TransferKind, TransferProgressEvent, TransferState } from '@shared/sftp'
import { t } from '@shared/i18n'
import { DEFAULT_LANGUAGE, tFor } from '@shared/i18n'
import { useSettingsStore } from '@renderer/settings/store'
/** One aggregated transfer row in the overlay (the per-event state string is
* the shared `TransferState`). */
@@ -24,8 +25,16 @@ interface TransferRowProps {
* Memoized on the entry object: the progress channel replaces only the entry a
* transfer owns, so a busy multi-transfer overlay re-renders the advancing row
* and leaves the others alone.
*
* That memo is also why the row subscribes to the language itself: a language
* switch changes no prop here, so nothing else would ever re-render it, and
* `t()` would keep serving whatever the module-level language was. A row count
* is bounded by the concurrent transfers, so one subscription per row is
* cheaper than threading the language down as a prop (which would defeat the
* memo and re-render every row on each progress tick).
*/
const TransferRow = memo(function TransferRow({ id, entry, onDismiss }: TransferRowProps): React.JSX.Element {
const language = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE)
const pct =
entry.totalBytes > 0
? Math.min(100, Math.round((entry.bytes / entry.totalBytes) * 100))
@@ -37,18 +46,18 @@ const TransferRow = memo(function TransferRow({ id, entry, onDismiss }: Transfer
<div className="sftp-transfer-head">
<span className="sftp-transfer-title">
{entry.state === 'error'
? t('panels.transfer.failed')
? tFor(language, 'panels.transfer.failed')
: entry.state === 'cancelled'
? t('panels.transfer.cancelled')
? tFor(language, 'panels.transfer.cancelled')
: entry.state === 'done'
? t('panels.transfer.done')
? tFor(language, 'panels.transfer.done')
: entry.kind === 'upload'
? t('panels.transfer.uploading')
? tFor(language, 'panels.transfer.uploading')
: entry.kind === 'download'
? t('panels.transfer.downloading')
? tFor(language, 'panels.transfer.downloading')
: entry.kind === 'zmodem-upload'
? t('panels.transfer.zmodemUploading')
: t('panels.transfer.zmodemDownloading')}
? tFor(language, 'panels.transfer.zmodemUploading')
: tFor(language, 'panels.transfer.zmodemDownloading')}
</span>
{/* Only the SFTP engine's own transfers can be cancelled; zmodem
runs in a separate engine that ignores cancelTransfer. */}
@@ -58,7 +67,7 @@ const TransferRow = memo(function TransferRow({ id, entry, onDismiss }: Transfer
className="sftp-transfer-cancel"
onClick={() => window.api.cancelTransfer(id)}
>
{t('common.cancel')}
{tFor(language, 'common.cancel')}
</button>
)}
{/* Cancelled rows dismiss themselves after 3s, but one that also
+66 -34
View File
@@ -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 (
<div className="term-searchbar">
<input
className="term-searchbar-input"
type="text"
value={value}
placeholder={t('terminal.search.placeholder')}
placeholder={tFor(language, 'terminal.search.placeholder')}
spellCheck={false}
autoFocus
onChange={(e) => onChange(e.target.value)}
@@ -142,10 +160,10 @@ function SearchBar({ value, onChange, onPrev, onNext, onClose, enabled, result }
else if (e.key === 'Escape') onClose()
}}
/>
<button type="button" className="term-searchbar-btn" disabled={!enabled} onClick={onPrev}>{t('terminal.search.prev')}</button>
<button type="button" className="term-searchbar-btn" disabled={!enabled} onClick={onNext}>{t('terminal.search.next')}</button>
<span className="term-searchbar-count">{result}</span>
<button type="button" className="term-searchbar-btn" onClick={onClose}>{t('common.close')}</button>
<button type="button" className="term-searchbar-btn" disabled={!enabled} onClick={onPrev}>{tFor(language, 'terminal.search.prev')}</button>
<button type="button" className="term-searchbar-btn" disabled={!enabled} onClick={onNext}>{tFor(language, 'terminal.search.next')}</button>
<span className="term-searchbar-count">{searchResultText(result, language)}</span>
<button type="button" className="term-searchbar-btn" onClick={onClose}>{tFor(language, 'common.close')}</button>
</div>
)
}
@@ -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<SearchResult>(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())}
>
<CopyOutlined />
<span className="term-menu-label">{t('common.copy')}</span>
<span className="term-menu-label">{tFor(language, 'common.copy')}</span>
<kbd>Ctrl+Shift+C</kbd>
</button>
<button
@@ -1310,23 +1341,23 @@ export function TerminalView({
onClick={() => runMenuAction(() => void pasteFromClipboard())}
>
<SnippetsOutlined />
<span className="term-menu-label">{t('common.paste')}</span>
<span className="term-menu-label">{tFor(language, 'common.paste')}</span>
<kbd>Ctrl+Shift+V</kbd>
</button>
<div className="term-menu-sep" />
<button type="button" className="term-menu-item" onClick={() => runMenuAction(openSearch)}>
<SearchOutlined />
<span className="term-menu-label">{t('terminal.menu.find')}</span>
<span className="term-menu-label">{tFor(language, 'terminal.menu.find')}</span>
<kbd>Ctrl+F</kbd>
</button>
<button type="button" className="term-menu-item" onClick={() => runMenuAction(() => termRef.current?.selectAll())}>
<SelectOutlined />
<span className="term-menu-label">{t('terminal.menu.selectAll')}</span>
<span className="term-menu-label">{tFor(language, 'terminal.menu.selectAll')}</span>
<kbd />
</button>
<button type="button" className="term-menu-item" onClick={() => runMenuAction(() => termRef.current?.clear())}>
<ClearOutlined />
<span className="term-menu-label">{t('terminal.menu.clear')}</span>
<span className="term-menu-label">{tFor(language, 'terminal.menu.clear')}</span>
<kbd />
</button>
</div>
@@ -1340,7 +1371,8 @@ export function TerminalView({
onNext={() => search(1)}
onClose={closeSearch}
enabled={searchTerm.trim().length > 0}
result={resultInfo}
result={searchResult}
language={language}
/>
)}
<div className="term-recbar">
@@ -1348,8 +1380,8 @@ export function TerminalView({
<button
type="button"
className={`term-rec-btn${recording ? ' is-recording' : ''}`}
title={recording ? t('terminal.rec.stopLog') : t('terminal.rec.startLog')}
aria-label={recording ? t('terminal.rec.stop') : t('terminal.rec.start')}
title={recording ? tFor(language, 'terminal.rec.stopLog') : tFor(language, 'terminal.rec.startLog')}
aria-label={recording ? tFor(language, 'terminal.rec.stop') : tFor(language, 'terminal.rec.start')}
onClick={() => {
if (recording) void handleLogStop()
else void handleLogStart()
@@ -1362,8 +1394,8 @@ export function TerminalView({
<button
type="button"
className="term-rec-btn"
title={t('terminal.rec.openLogs')}
aria-label={t('terminal.rec.openLogs')}
title={tFor(language, 'terminal.rec.openLogs')}
aria-label={tFor(language, 'terminal.rec.openLogs')}
onClick={() => window.api.openLogsDir()}
>
<FolderOpenOutlined />
@@ -1373,8 +1405,8 @@ export function TerminalView({
<button
type="button"
className="term-rec-btn"
title={t('terminal.rec.openCwd')}
aria-label={t('terminal.rec.openCwd')}
title={tFor(language, 'terminal.rec.openCwd')}
aria-label={tFor(language, 'terminal.rec.openCwd')}
onClick={() => {
const dir = getSessionCwd(sessionId)
if (dir) void window.api.openDirectory(dir)
@@ -1386,29 +1418,29 @@ export function TerminalView({
</div>
<Modal
open={paste !== null}
title={t('terminal.paste.title')}
okText={t('common.paste')}
cancelText={t('common.cancel')}
title={tFor(language, 'terminal.paste.title')}
okText={tFor(language, 'common.paste')}
cancelText={tFor(language, 'common.cancel')}
width={420}
maskClosable={false}
onOk={acceptPaste}
onCancel={cancelPaste}
>
<div className="term-paste-confirm">
<p>{t('terminal.paste.message')}</p>
<p>{tFor(language, 'terminal.paste.message')}</p>
<Checkbox
checked={paste?.noPrompt ?? false}
onChange={(e) => setPaste((prev) => (prev ? { ...prev, noPrompt: e.target.checked } : prev))}
>
{t('terminal.paste.noPrompt')}
{tFor(language, 'terminal.paste.noPrompt')}
</Checkbox>
<Checkbox
checked={paste?.disableDetection ?? false}
onChange={(e) => setPaste((prev) => (prev ? { ...prev, disableDetection: e.target.checked } : prev))}
>
{t('terminal.paste.disableDetection')}
{tFor(language, 'terminal.paste.disableDetection')}
</Checkbox>
<p className="term-paste-confirm-hint">{t('terminal.paste.hint')}</p>
<p className="term-paste-confirm-hint">{tFor(language, 'terminal.paste.hint')}</p>
</div>
</Modal>
{/* Relative-positioned wrapper around the xterm surface: the suggestion
@@ -1436,7 +1468,7 @@ export function TerminalView({
</button>
))}
</div>
<div className="term-suggest-hint">{t('terminal.suggest.hint')}</div>
<div className="term-suggest-hint">{tFor(language, 'terminal.suggest.hint')}</div>
</div>
)}
<div className="terminal-view-dock" ref={hostRef}>
@@ -1459,8 +1491,8 @@ export function TerminalView({
)}
{dead && (
<div className="term-dead-mask">
<div>{t('terminal.dead.message', { code: exitCode })}</div>
<button type="button" className="term-dead-btn" onClick={() => onCloseRef.current()}>{t('common.close')}</button>
<div>{tFor(language, 'terminal.dead.message', { code: exitCode })}</div>
<button type="button" className="term-dead-btn" onClick={() => onCloseRef.current()}>{tFor(language, 'common.close')}</button>
</div>
)}
</div>
@@ -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<number>(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}
+8 -3
View File
@@ -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 (
<span
className="ssh-host-badge"
title={label}
aria-label={t('ssh.hostBadge.aria', { label })}
aria-label={tFor(language, 'ssh.hostBadge.aria', { label })}
>
{label}
</span>
+12 -5
View File
@@ -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 (
<div className="workspace-terminal-panel">
<PanelErrorBoundary label={t('workspace.error.panelTitle')}>
<PanelErrorBoundary label={tFor(language, 'workspace.error.panelTitle')}>
{isSsh && (
<Button
type="text"
size="small"
className="workspace-terminal-monitor-toggle"
icon={<AreaChartOutlined />}
aria-label={sideOpen ? t('workspace.panel.hideSidebar') : t('workspace.panel.showSidebar')}
title={sideOpen ? t('workspace.panel.hideSidebar') : t('workspace.panel.showSidebar')}
aria-label={sideOpen ? tFor(language, 'workspace.panel.hideSidebar') : tFor(language, 'workspace.panel.showSidebar')}
title={sideOpen ? tFor(language, 'workspace.panel.hideSidebar') : tFor(language, 'workspace.panel.showSidebar')}
onClick={() => setSideOpen((prev) => !prev)}
/>
)}
<div className="workspace-terminal-split">
{isSsh ? <SshBottomPanel sessionId={sessionId} terminal={terminal} /> : terminal}
{isSsh ? <SshBottomPanel sessionId={sessionId} terminal={terminal} language={language} /> : terminal}
{isSsh && sideOpen && (
<div className="workspace-terminal-side">
<MonitorPanel sessionId={sessionId} />
+17 -10
View File
@@ -6,7 +6,8 @@ import type { IDockviewPanelHeaderProps } from 'dockview-react'
import { useBroadcastStore } from './broadcastStore'
import { SshHostBadge } from './SshHostBadge'
import { t } from '@shared/i18n'
import { useSettingsStore } from '@renderer/settings/store'
import { DEFAULT_LANGUAGE, t, tFor } from '@shared/i18n'
import type { LayoutMeta } from '@shared/ipc'
export type TerminalTabProps = IDockviewPanelHeaderProps<{
@@ -30,13 +31,19 @@ export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Elemen
const [title, setTitle] = useState<string | undefined>(api.title)
const selfIndex = panel.panels.findIndex((p) => p.api === api)
// dockview keeps the tab component in its own render slot — a re-render of
// App/Workspace does not reach it — so the language has to be subscribed here
// and passed to `tFor` explicitly; `t()` would read the module-level language
// of whatever render last touched it.
const language = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE)
const isSsh = params?.sessionKind === 'ssh'
const hostLabel: string | undefined = isSsh ? (params?.hostLabel || title) : undefined
const tooltip = isSsh
? hostLabel && hostLabel !== title
? `${title} — ${hostLabel}`
: (hostLabel || title)
: t('workspace.tab.localTerminal')
: tFor(language, 'workspace.tab.localTerminal')
// M6 broadcast: show a badge on tabs that are current broadcast targets.
const isBroadcastTarget = useBroadcastStore((state) => state.targets.has(params?.sessionId ?? ''))
@@ -55,9 +62,9 @@ export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Elemen
const menu: MenuProps = {
items: [
{ key: 'close', label: t('common.close') },
{ key: 'closeOthers', label: t('workspace.tab.closeOthers'), disabled: panel.panels.length <= 1 },
{ key: 'closeRight', label: t('workspace.tab.closeRight'), disabled: selfIndex === -1 || selfIndex === panel.panels.length - 1 }
{ key: 'close', label: tFor(language, 'common.close') },
{ key: 'closeOthers', label: tFor(language, 'workspace.tab.closeOthers'), disabled: panel.panels.length <= 1 },
{ key: 'closeRight', label: tFor(language, 'workspace.tab.closeRight'), disabled: selfIndex === -1 || selfIndex === panel.panels.length - 1 }
],
onClick: ({ key }) => {
if (key === 'close') {
@@ -85,20 +92,20 @@ export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Elemen
>
{isSsh && <CloudServerOutlined className="workspace-terminal-tab-icon" />}
<span className="workspace-terminal-tab-title">{title}</span>
{isSsh && hostLabel && <SshHostBadge label={hostLabel} />}
{isSsh && hostLabel && <SshHostBadge label={hostLabel} language={language} />}
{isBroadcastTarget && (
<span
className="workspace-terminal-tab-bcast"
title={t('workspace.tab.broadcastTarget')}
aria-label={t('workspace.tab.broadcastTarget')}
title={tFor(language, 'workspace.tab.broadcastTarget')}
aria-label={tFor(language, 'workspace.tab.broadcastTarget')}
>
{t('workspace.tab.broadcastBadge')}
{tFor(language, 'workspace.tab.broadcastBadge')}
</span>
)}
<button
type="button"
className="workspace-terminal-tab-close"
aria-label={t('common.close')}
aria-label={tFor(language, 'common.close')}
onPointerDown={(event) => event.preventDefault()}
onClick={(event) => {
event.preventDefault()
+26 -18
View File
@@ -27,7 +27,7 @@ import type {
import 'dockview-react/dist/styles/dockview.css'
import { connectPromptFor, type HostKeyPromptEvent, type SshConnection, type SshSecretOverride } from '@shared/connections'
import { t } from '@shared/i18n'
import { DEFAULT_LANGUAGE, t, tFor, type Language } from '@shared/i18n'
import { nextTerminalTitle, retitleAutoTitles } from '@shared/terminalTitle'
import { ConnectionSidebar } from '../connections/ConnectionSidebar'
import type { ConnectionSidebarHandle } from '../connections/ConnectionSidebar'
@@ -248,12 +248,16 @@ function apiOfMode(mode: WorkspaceMode, terminalApi: DockviewApi | undefined, ss
* terminal dockview and hints from the server list in the ssh dockview). */
function createTabActions(mode: WorkspaceMode): (props: IDockviewHeaderActionsProps) => React.JSX.Element {
function TabActions({ containerApi, group }: IDockviewHeaderActionsProps): React.JSX.Element {
// dockview renders the header actions from its own slot, outside the
// App→Workspace re-render path, so the language is subscribed here (and
// passed down to the buttons) and every string goes through `tFor`.
const language = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE)
// The per-group "+" creates a local terminal — SSH sessions can only be
// opened from the server list, so in ssh mode the button just hints.
const { message } = App.useApp()
const handleAdd = (): void => {
if (mode === 'ssh') {
message.info(t('workspace.hint.sshFromList'))
message.info(tFor(language, 'workspace.hint.sshFromList'))
return
}
void (async (): Promise<void> => {
@@ -272,8 +276,8 @@ function createTabActions(mode: WorkspaceMode): (props: IDockviewHeaderActionsPr
return (
<div className="workspace-tabbar-actions">
<AddTerminalGroupButton onClick={handleAdd} />
<BroadcastToggleButton />
<AddTerminalGroupButton language={language} onClick={handleAdd} />
<BroadcastToggleButton language={language} />
</div>
)
}
@@ -1368,13 +1372,13 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
}
/** "+" renders the new-terminal button (per-group tab addition). */
function AddTerminalGroupButton({ onClick }: { onClick: () => void }): React.JSX.Element {
function AddTerminalGroupButton({ onClick, language }: { onClick: () => void; language: Language }): React.JSX.Element {
return (
<button
type="button"
className="workspace-tabbar-add"
title={t('workspace.tab.addInSplit')}
aria-label={t('workspace.tab.addInSplit')}
title={tFor(language, 'workspace.tab.addInSplit')}
aria-label={tFor(language, 'workspace.tab.addInSplit')}
onClick={onClick}
>
<PlusOutlined />
@@ -1383,7 +1387,7 @@ function AddTerminalGroupButton({ onClick }: { onClick: () => void }): React.JSX
}
/** 广播 target picker: toggle + Popover with per-session checkboxes. */
function BroadcastToggleButton(): React.JSX.Element {
function BroadcastToggleButton({ language }: { language: Language }): React.JSX.Element {
const enabled = useBroadcastStore((s) => s.enabled)
const targets = useBroadcastStore((s) => s.targets)
const sessions = useBroadcastStore((s) => s.sessions)
@@ -1409,14 +1413,15 @@ function BroadcastToggleButton(): React.JSX.Element {
sessions={sessions}
onToggleTarget={toggleTarget}
onSetEnabled={setEnabled}
language={language}
/>
}
>
<button
type="button"
className={cls}
title={enabled ? t('workspace.broadcast.activeTitle', { count: targets.size }) : t('workspace.broadcast.toggle')}
aria-label={t('workspace.broadcast.toggle')}
title={enabled ? tFor(language, 'workspace.broadcast.activeTitle', { count: targets.size }) : tFor(language, 'workspace.broadcast.toggle')}
aria-label={tFor(language, 'workspace.broadcast.toggle')}
aria-pressed={enabled}
>
<ShareAltOutlined />
@@ -1435,7 +1440,8 @@ function WorkspaceBroadcastPopover({
targets,
sessions,
onToggleTarget,
onSetEnabled
onSetEnabled,
language
}: {
mode: WorkspaceMode
enabled: boolean
@@ -1443,6 +1449,8 @@ function WorkspaceBroadcastPopover({
sessions: { id: string; title: string; isSsh: boolean }[]
onToggleTarget: (id: string) => void
onSetEnabled: (enabled: boolean) => void
/** from the dockview header-actions component — see TabActions */
language: Language
}): React.JSX.Element {
const isSsh = (s: { id: string; title: string; isSsh: boolean }): boolean => s.isSsh
const groupSessions = sessions.filter((s) => (mode === 'ssh' ? isSsh(s) : !isSsh(s)))
@@ -1451,26 +1459,26 @@ function WorkspaceBroadcastPopover({
return (
<div className="workspace-bcast-pop">
<div className="workspace-bcast-pop-mode">
{mode === 'ssh' ? t('workspace.broadcast.popoverSsh') : t('workspace.broadcast.popoverTerminal')}
{mode === 'ssh' ? tFor(language, 'workspace.broadcast.popoverSsh') : tFor(language, 'workspace.broadcast.popoverTerminal')}
</div>
<div className="workspace-bcast-pop-switch">
<span className="workspace-bcast-pop-label">{t('workspace.broadcast.switch')}</span>
<span className="workspace-bcast-pop-label">{tFor(language, 'workspace.broadcast.switch')}</span>
<Checkbox
checked={enabled}
disabled={targets.size < 2}
onChange={(e) => onSetEnabled(e.target.checked)}
>
{enabled
? t('workspace.broadcast.enabled', { count: targets.size })
? tFor(language, 'workspace.broadcast.enabled', { count: targets.size })
: targets.size < 2
? t('workspace.broadcast.needTwo')
: t('workspace.broadcast.disabled')}
? tFor(language, 'workspace.broadcast.needTwo')
: tFor(language, 'workspace.broadcast.disabled')}
</Checkbox>
</div>
<div className="workspace-bcast-pop-list">
{groupSessions.length === 0 ? (
<div className="workspace-bcast-pop-empty">
{mode === 'ssh' ? t('workspace.broadcast.emptySsh') : t('workspace.broadcast.emptyTerminal')}
{mode === 'ssh' ? tFor(language, 'workspace.broadcast.emptySsh') : tFor(language, 'workspace.broadcast.emptyTerminal')}
</div>
) : (
groupSessions.map((s) => (
@@ -1482,7 +1490,7 @@ function WorkspaceBroadcastPopover({
))
)}
</div>
<div className="workspace-bcast-pop-hint">{t('workspace.broadcast.hint')}</div>
<div className="workspace-bcast-pop-hint">{tFor(language, 'workspace.broadcast.hint')}</div>
</div>
)
}