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 { useEffect, useRef, useState } from 'react'
import type { SysinfoMeta, SysinfoSample } from '@shared/sysinfo' 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 { useWorkspaceModeStore } from '../workspace/workspaceModeStore'
import './monitor.css' import './monitor.css'
@@ -119,14 +120,14 @@ function formatSize(mb: number): string {
} }
/** human-readable uptime */ /** human-readable uptime */
function humanizeUptime(sec: number): string { function humanizeUptime(lang: Language, sec: number): string {
const s = Math.floor(sec) 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) 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) const hours = Math.floor(s / 3600)
if (s < 86400) return t('ssh.monitor.uptimeHours', { h: hours, m: Math.floor((s % 3600) / 60) }) if (s < 86400) return tFor(lang, 'ssh.monitor.uptimeHours', { h: hours, m: Math.floor((s % 3600) / 60) })
return t('ssh.monitor.uptimeDays', { n: Math.floor(s / 86400) }) return tFor(lang, 'ssh.monitor.uptimeDays', { n: Math.floor(s / 86400) })
} }
function Bar({ pct, color }: { pct: number; color: string }) { 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 { 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 [meta, setMeta] = useState<SysinfoMeta | null>(null)
const [sample, setSample] = useState<SysinfoSample | null>(null) const [sample, setSample] = useState<SysinfoSample | null>(null)
const sampleRef = useRef<SysinfoSample | null>(null) const sampleRef = useRef<SysinfoSample | null>(null)
@@ -188,14 +193,14 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
if (sample?.error) { if (sample?.error) {
return ( return (
<div className="mm-root mm-empty mm-failed"> <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> </div>
) )
} }
return ( return (
<div className="mm-root mm-empty"> <div className="mm-root mm-empty">
<span className="mm-dots"><span /> <span /> <span /></span> <span className="mm-dots"><span /> <span /> <span /></span>
{t('ssh.monitor.collecting')} {tFor(language, 'ssh.monitor.collecting')}
</div> </div>
) )
} }
@@ -210,7 +215,7 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
return ( return (
<div className={`mm-root${failed ? ' mm-failed' : ''}`}> <div className={`mm-root${failed ? ' mm-failed' : ''}`}>
{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"> <header className="mm-header">
@@ -222,19 +227,19 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
<div className="mm-block-row"> <div className="mm-block-row">
<span className="mm-label">CPU</span> <span className="mm-label">CPU</span>
<span className="mm-value" style={{ color: cpuColor(cpu.usage) }}> <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> </span>
</div> </div>
<Bar pct={cpu.usage} color={cpuColor(cpu.usage)} /> <Bar pct={cpu.usage} color={cpuColor(cpu.usage)} />
<div className="mm-sub mm-load"> <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> </div>
<HistoryChart data={cpuHist} lineColor="#3fb950" fillColor="rgba(63,185,80,0.15)" height={48} maxY={100} /> <HistoryChart data={cpuHist} lineColor="#3fb950" fillColor="rgba(63,185,80,0.15)" height={48} maxY={100} />
</section> </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"> <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"> <span className="mm-mem-size">
{formatSize(mem.usedMb)} / {formatSize(mem.totalMb)} {formatSize(mem.usedMb)} / {formatSize(mem.totalMb)}
</span> </span>
@@ -248,9 +253,9 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
</section> </section>
{disks.length > 0 && ( {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"> <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>
<div className="mm-disks"> <div className="mm-disks">
{disks.slice(0, 6).map((d) => { {disks.slice(0, 6).map((d) => {
@@ -273,9 +278,9 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
</section> </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"> <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"> <div className="mm-net">
<span className="mm-net-item mm-rx"> <span className="mm-net-item mm-rx">
<span className="mm-arrow mm-up">↓</span> <span className="mm-arrow mm-up">↓</span>
@@ -296,7 +301,7 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
</section> </section>
<footer className="mm-footer"> <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> </footer>
</div> </div>
) )
+87 -71
View File
@@ -19,7 +19,8 @@ import {
import { App, Button, Checkbox, Dropdown, Input, Modal, Popconfirm, Tooltip } from 'antd' import { App, Button, Checkbox, Dropdown, Input, Modal, Popconfirm, Tooltip } from 'antd'
import type { MenuProps } from 'antd' import type { MenuProps } from 'antd'
import type { SftpEntry, TransferProgressEvent } from '@shared/sftp' 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' import './sftp.css'
/** Remote file browser for one SSH session (M4). */ /** Remote file browser for one SSH session (M4). */
@@ -89,9 +90,9 @@ function bitsToOctal(bits: PermBits): string {
return special > 0 ? `${special}${perms}` : perms return special > 0 ? `${special}${perms}` : perms
} }
/** t() falls back to the key itself when a translation is missing. */ /** tFor() falls back to the key itself when a translation is missing. */
function tOr(key: string, fallback: string): string { function tOr(lang: Language, key: string, fallback: string): string {
const value = t(key) const value = tFor(lang, key)
return value === key ? fallback : value 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. */ /** Permission-grid labels are resolved per render so they follow the language. */
function permRowLabels(): string[] { function permRowLabels(lang: Language): string[] {
return [t('ssh.file.permRowOwner'), t('ssh.file.permRowGroup'), t('ssh.file.permRowOther')] return [
tFor(lang, 'ssh.file.permRowOwner'),
tFor(lang, 'ssh.file.permRowGroup'),
tFor(lang, 'ssh.file.permRowOther')
]
} }
function permColLabels(): string[] { function permColLabels(lang: Language): string[] {
return [t('ssh.file.permColRead'), t('ssh.file.permColWrite'), t('ssh.file.permColExec')] 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 entry: SftpEntry | null
onClose: () => void onClose: () => void
onSaved: () => 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 { message } = App.useApp()
const [bits, setBits] = useState<PermBits>(emptyBits) const [bits, setBits] = useState<PermBits>(emptyBits)
const [octal, setOctal] = useState('') 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) const hasUidGid = entry != null && (entry.uid != null || entry.gid != null)
/** lstat failed while listing → no mode to compare against or preserve. */ /** lstat failed while listing → no mode to compare against or preserve. */
const modeKnown = Boolean(entry?.mode) const modeKnown = Boolean(entry?.mode)
const rowLabels = permRowLabels() const rowLabels = permRowLabels(language)
const colLabels = permColLabels() const colLabels = permColLabels(language)
const modeStr = useMemo(() => { const modeStr = useMemo(() => {
if (!entry) return { type: '-', str: '---------' } 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) await window.api.chownRemote(sessionId, entry.path, u, g)
} }
if (modeChanged || ownerChanged) { if (modeChanged || ownerChanged) {
message.success(t('ssh.file.permApplied')) message.success(tFor(language, 'ssh.file.permApplied'))
} }
onClose() onClose()
onSaved() onSaved()
@@ -316,13 +327,13 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
return ( return (
<Modal <Modal
title={t('ssh.file.permTitle', { name: entry?.name ?? '' })} title={tFor(language, 'ssh.file.permTitle', { name: entry?.name ?? '' })}
open={open} open={open}
onOk={onSave} onOk={onSave}
onCancel={onClose} onCancel={onClose}
confirmLoading={saving} confirmLoading={saving}
okText={t('common.save')} okText={tFor(language, 'common.save')}
cancelText={t('common.cancel')} cancelText={tFor(language, 'common.cancel')}
okButtonProps={{ disabled: idInvalid(uid) || idInvalid(gid) }} okButtonProps={{ disabled: idInvalid(uid) || idInvalid(gid) }}
width={380} width={380}
> >
@@ -355,11 +366,11 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
</div> </div>
<div className="sftp-perm-octal-input"> <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 <Input
value={octal} value={octal}
onChange={(e) => applyOctal(e.target.value)} onChange={(e) => applyOctal(e.target.value)}
placeholder={t('ssh.file.octalPlaceholder')} placeholder={tFor(language, 'ssh.file.octalPlaceholder')}
addonAfter={bitsToModeStr(modeStr.type, bits)} addonAfter={bitsToModeStr(modeStr.type, bits)}
/> />
</div> </div>
@@ -370,7 +381,7 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
<Input <Input
value={uid} value={uid}
onChange={(e) => setUid(e.target.value)} onChange={(e) => setUid(e.target.value)}
placeholder={hasUidGid ? t('ssh.file.keepEmpty') : '0'} placeholder={hasUidGid ? tFor(language, 'ssh.file.keepEmpty') : '0'}
/> />
</div> </div>
<div className="sftp-perm-own-field"> <div className="sftp-perm-own-field">
@@ -378,14 +389,14 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
<Input <Input
value={gid} value={gid}
onChange={(e) => setGid(e.target.value)} onChange={(e) => setGid(e.target.value)}
placeholder={hasUidGid ? t('ssh.file.keepEmpty') : '0'} placeholder={hasUidGid ? tFor(language, 'ssh.file.keepEmpty') : '0'}
/> />
</div> </div>
</div> </div>
<div className="sftp-perm-hint">{t('ssh.file.ownerHint')}</div> <div className="sftp-perm-hint">{tFor(language, 'ssh.file.ownerHint')}</div>
{entry && !modeKnown && <div className="sftp-perm-hint">{t('ssh.file.modeUnavailable')}</div>} {entry && !modeKnown && <div className="sftp-perm-hint">{tFor(language, 'ssh.file.modeUnavailable')}</div>}
{(idInvalid(uid) || idInvalid(gid)) && ( {(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> </div>
</Modal> </Modal>
@@ -397,8 +408,15 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
* lifetime, so dragging the SSH divider (which re-renders SshBottomPanel on * lifetime, so dragging the SSH divider (which re-renders SshBottomPanel on
* every pointermove) no longer re-renders the whole file browser. Switching to * every pointermove) no longer re-renders the whole file browser. Switching to
* a different directory still works — it is internal state. * 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 { 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 { message, modal } = App.useApp()
const [dir, setDir] = useState('/') const [dir, setDir] = useState('/')
const [entries, setEntries] = useState<SftpEntry[]>([]) const [entries, setEntries] = useState<SftpEntry[]>([])
@@ -640,9 +658,9 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
startUpload(files) startUpload(files)
return return
} }
const desc = tOr('ssh.file.overwriteDesc', '') const desc = tOr(language, 'ssh.file.overwriteDesc', '')
modal.confirm({ modal.confirm({
title: tOr('ssh.file.overwriteTitle', t('ssh.file.uploadFile')), title: tOr(language, 'ssh.file.overwriteTitle', tFor(language, 'ssh.file.uploadFile')),
content: ( content: (
<div> <div>
{existing.map((name) => ( {existing.map((name) => (
@@ -651,9 +669,9 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
{desc && <div>{desc}</div>} {desc && <div>{desc}</div>}
</div> </div>
), ),
okText: t('common.ok'), okText: tFor(language, 'common.ok'),
okButtonProps: { danger: true }, okButtonProps: { danger: true },
cancelText: t('common.cancel'), cancelText: tFor(language, 'common.cancel'),
onOk: () => startUpload(files) onOk: () => startUpload(files)
}) })
} catch (err) { } catch (err) {
@@ -721,16 +739,13 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
void (async () => { void (async () => {
try { try {
await navigator.clipboard.writeText(entry.path) await navigator.clipboard.writeText(entry.path)
message.success(t('ssh.file.pathCopied')) message.success(tFor(language, 'ssh.file.pathCopied'))
} catch { } 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) */ /** opens the mkdir modal in create-a-file mode (no-op: new files unsupported) */
const menu: MenuProps = useMemo(() => { const menu: MenuProps = useMemo(() => {
const target = menuTarget const target = menuTarget
@@ -738,51 +753,51 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
const canAction = target != null const canAction = target != null
return { return {
items: [ items: [
{ key: 'refresh', label: t('ssh.file.refresh'), icon: <ReloadOutlined /> }, { key: 'refresh', label: tFor(language, 'ssh.file.refresh'), icon: <ReloadOutlined /> },
{ type: 'divider' }, { type: 'divider' },
{ {
key: 'open', 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 />, icon: isDir ? <FolderOpenOutlined /> : <DownloadOutlined />,
disabled: !canAction disabled: !canAction
}, },
{ {
key: 'download', key: 'download',
label: t('ssh.file.download'), label: tFor(language, 'ssh.file.download'),
icon: <DownloadOutlined />, icon: <DownloadOutlined />,
disabled: !canAction || Boolean(isDir) 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', key: 'rename',
label: t('ssh.file.rename'), label: tFor(language, 'ssh.file.rename'),
icon: <FormOutlined />, icon: <FormOutlined />,
disabled: !canAction disabled: !canAction
}, },
{ {
type: 'submenu', type: 'submenu',
key: 'new', key: 'new',
label: t('ssh.file.new'), label: tFor(language, 'ssh.file.new'),
icon: <FileAddOutlined />, icon: <FileAddOutlined />,
children: [ 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),不做 // 新建文件:无现成 SFTP 创建文件 API(main 仅 mkdir/rename/chmod/chown),不做
] ]
}, },
{ type: 'divider' }, { type: 'divider' },
{ {
key: 'copyPath', key: 'copyPath',
label: t('ssh.file.copyPath'), label: tFor(language, 'ssh.file.copyPath'),
icon: <CopyOutlined />, icon: <CopyOutlined />,
disabled: !canAction disabled: !canAction
}, },
{ {
key: 'permission', key: 'permission',
label: t('ssh.file.permission'), label: tFor(language, 'ssh.file.permission'),
icon: <SafetyOutlined />, icon: <SafetyOutlined />,
disabled: !canAction 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 }) => { onClick: ({ key }) => {
const entry = menuTarget const entry = menuTarget
@@ -808,11 +823,11 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
setPermOpen(true) setPermOpen(true)
} else if (key === 'delete') { } else if (key === 'delete') {
modal.confirm({ modal.confirm({
title: t('ssh.file.deleteTitle'), title: tFor(language, 'ssh.file.deleteTitle'),
content: entry ? `${entry.name}(${entry.path})` : undefined, content: entry ? `${entry.name}(${entry.path})` : undefined,
okText: t('common.delete'), okText: tFor(language, 'common.delete'),
okButtonProps: { danger: true }, okButtonProps: { danger: true },
cancelText: t('common.cancel'), cancelText: tFor(language, 'common.cancel'),
onOk: () => onOk: () =>
new Promise<void>((resolve, reject) => { new Promise<void>((resolve, reject) => {
if (!entry) { if (!entry) {
@@ -864,7 +879,7 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
{e.isDir ? '' : fmtSize(e.size)} {e.isDir ? '' : fmtSize(e.size)}
</span> </span>
<span className="sftp-time">{fmtTime(e.mtime)}</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 ?? '----------'} {e.mode ?? '----------'}
</span> </span>
<span className="sftp-owner" title={`UID ${e.uid ?? '—'} / GID ${e.gid ?? '—'}`}> <span className="sftp-owner" title={`UID ${e.uid ?? '—'} / GID ${e.gid ?? '—'}`}>
@@ -888,7 +903,7 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
return ( return (
<div className="sftp-panel" style={scaleVars}> <div className="sftp-panel" style={scaleVars}>
<div className="sftp-toolbar"> <div className="sftp-toolbar">
<Tooltip title={t('ssh.file.up')}> <Tooltip title={tFor(language, 'ssh.file.up')}>
<Button <Button
type="text" type="text"
size="small" size="small"
@@ -897,10 +912,10 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
onClick={() => void refresh(parent)} onClick={() => void refresh(parent)}
/> />
</Tooltip> </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)} /> <Button type="text" size="small" icon={<ReloadOutlined />} onClick={() => void refresh(dir)} />
</Tooltip> </Tooltip>
<Tooltip title={t('ssh.file.newFolder')}> <Tooltip title={tFor(language, 'ssh.file.newFolder')}>
<Button <Button
type="text" type="text"
size="small" size="small"
@@ -911,10 +926,10 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
}} }}
/> />
</Tooltip> </Tooltip>
<Tooltip title={t('ssh.file.uploadFile')}> <Tooltip title={tFor(language, 'ssh.file.uploadFile')}>
<Button type="text" size="small" icon={<UploadOutlined />} onClick={doUpload} /> <Button type="text" size="small" icon={<UploadOutlined />} onClick={doUpload} />
</Tooltip> </Tooltip>
<Tooltip title={t('ssh.file.downloadSelected')}> <Tooltip title={tFor(language, 'ssh.file.downloadSelected')}>
<Button <Button
type="text" type="text"
size="small" size="small"
@@ -923,7 +938,7 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
onClick={() => selected && doDownload(selected)} onClick={() => selected && doDownload(selected)}
/> />
</Tooltip> </Tooltip>
<Tooltip title={t('ssh.file.rename')}> <Tooltip title={tFor(language, 'ssh.file.rename')}>
<Button <Button
type="text" type="text"
size="small" size="small"
@@ -935,7 +950,7 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
}} }}
/> />
</Tooltip> </Tooltip>
<Tooltip title={t('ssh.file.permOwner')}> <Tooltip title={tFor(language, 'ssh.file.permOwner')}>
<Button <Button
type="text" type="text"
size="small" size="small"
@@ -945,15 +960,15 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
/> />
</Tooltip> </Tooltip>
<Popconfirm <Popconfirm
title={t('ssh.file.deleteTitle')} title={tFor(language, 'ssh.file.deleteTitle')}
okText={t('common.delete')} okText={tFor(language, 'common.delete')}
cancelText={t('common.cancel')} cancelText={tFor(language, 'common.cancel')}
onConfirm={doDelete} onConfirm={doDelete}
> >
<Button type="text" size="small" icon={<DeleteOutlined />} disabled={!selected} danger /> <Button type="text" size="small" icon={<DeleteOutlined />} disabled={!selected} danger />
</Popconfirm> </Popconfirm>
<span className="sftp-toolbar-gap" /> <span className="sftp-toolbar-gap" />
<Tooltip title={t('ssh.file.zoomOut')}> <Tooltip title={tFor(language, 'ssh.file.zoomOut')}>
<Button <Button
type="text" type="text"
size="small" size="small"
@@ -962,7 +977,7 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
onClick={() => adjustScale(-FONT_SCALE_STEP)} onClick={() => adjustScale(-FONT_SCALE_STEP)}
/> />
</Tooltip> </Tooltip>
<Tooltip title={t('ssh.file.zoomIn')}> <Tooltip title={tFor(language, 'ssh.file.zoomIn')}>
<Button <Button
type="text" type="text"
size="small" size="small"
@@ -1024,16 +1039,16 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
> >
<div className="sftp-head"> <div className="sftp-head">
<span className="sftp-head-cell" /> <span className="sftp-head-cell" />
{headCell('name', 'sftp-col-name', t('ssh.file.colName'))} {headCell('name', 'sftp-col-name', tFor(language, 'ssh.file.colName'))}
{headCell('size', 'sftp-col-size', t('ssh.file.colSize'))} {headCell('size', 'sftp-col-size', tFor(language, 'ssh.file.colSize'))}
{headCell('mtime', 'sftp-col-time', t('ssh.file.colModified'))} {headCell('mtime', 'sftp-col-time', tFor(language, 'ssh.file.colModified'))}
<span className="sftp-head-cell sftp-col-mode">{t('ssh.file.colPerm')}</span> <span className="sftp-head-cell sftp-col-mode">{tFor(language, 'ssh.file.colPerm')}</span>
<span className="sftp-head-cell sftp-col-owner">{t('ssh.file.colOwner')}</span> <span className="sftp-head-cell sftp-col-owner">{tFor(language, 'ssh.file.colOwner')}</span>
</div> </div>
{loading && entriesSorted.length === 0 && ( {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 ? ( {virtualized ? (
<div className="sftp-list-virtual" style={{ height: entriesSorted.length * rowHeight }}> <div className="sftp-list-virtual" style={{ height: entriesSorted.length * rowHeight }}>
<div <div
@@ -1056,31 +1071,32 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
entry={selected} entry={selected}
onClose={() => setPermOpen(false)} onClose={() => setPermOpen(false)}
onSaved={() => void refresh(dir)} onSaved={() => void refresh(dir)}
language={language}
/> />
<Modal <Modal
title={t('ssh.file.newFolder')} title={tFor(language, 'ssh.file.newFolder')}
open={mkdirOpen} open={mkdirOpen}
onOk={doMkdir} onOk={doMkdir}
onCancel={() => setMkdirOpen(false)} onCancel={() => setMkdirOpen(false)}
okText={t('ssh.file.create')} okText={tFor(language, 'ssh.file.create')}
cancelText={t('common.cancel')} cancelText={tFor(language, 'common.cancel')}
width={360} width={360}
> >
<Input <Input
autoFocus autoFocus
placeholder={t('ssh.file.folderName')} placeholder={tFor(language, 'ssh.file.folderName')}
value={inputValue} value={inputValue}
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => setInputValue(e.target.value)}
onPressEnter={doMkdir} onPressEnter={doMkdir}
/> />
</Modal> </Modal>
<Modal <Modal
title={t('ssh.file.renameTitle', { name: selected?.name ?? '' })} title={tFor(language, 'ssh.file.renameTitle', { name: selected?.name ?? '' })}
open={renameOpen} open={renameOpen}
onOk={doRename} onOk={doRename}
onCancel={() => setRenameOpen(false)} onCancel={() => setRenameOpen(false)}
okText={t('common.ok')} okText={tFor(language, 'common.ok')}
cancelText={t('common.cancel')} cancelText={tFor(language, 'common.cancel')}
width={360} width={360}
> >
<Input <Input
+18 -9
View File
@@ -1,7 +1,8 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react' import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { CloseOutlined } from '@ant-design/icons' import { CloseOutlined } from '@ant-design/icons'
import type { TransferKind, TransferProgressEvent, TransferState } from '@shared/sftp' 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 /** One aggregated transfer row in the overlay (the per-event state string is
* the shared `TransferState`). */ * the shared `TransferState`). */
@@ -24,8 +25,16 @@ interface TransferRowProps {
* Memoized on the entry object: the progress channel replaces only the entry a * 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 * transfer owns, so a busy multi-transfer overlay re-renders the advancing row
* and leaves the others alone. * 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 TransferRow = memo(function TransferRow({ id, entry, onDismiss }: TransferRowProps): React.JSX.Element {
const language = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE)
const pct = const pct =
entry.totalBytes > 0 entry.totalBytes > 0
? Math.min(100, Math.round((entry.bytes / entry.totalBytes) * 100)) ? 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"> <div className="sftp-transfer-head">
<span className="sftp-transfer-title"> <span className="sftp-transfer-title">
{entry.state === 'error' {entry.state === 'error'
? t('panels.transfer.failed') ? tFor(language, 'panels.transfer.failed')
: entry.state === 'cancelled' : entry.state === 'cancelled'
? t('panels.transfer.cancelled') ? tFor(language, 'panels.transfer.cancelled')
: entry.state === 'done' : entry.state === 'done'
? t('panels.transfer.done') ? tFor(language, 'panels.transfer.done')
: entry.kind === 'upload' : entry.kind === 'upload'
? t('panels.transfer.uploading') ? tFor(language, 'panels.transfer.uploading')
: entry.kind === 'download' : entry.kind === 'download'
? t('panels.transfer.downloading') ? tFor(language, 'panels.transfer.downloading')
: entry.kind === 'zmodem-upload' : entry.kind === 'zmodem-upload'
? t('panels.transfer.zmodemUploading') ? tFor(language, 'panels.transfer.zmodemUploading')
: t('panels.transfer.zmodemDownloading')} : tFor(language, 'panels.transfer.zmodemDownloading')}
</span> </span>
{/* Only the SFTP engine's own transfers can be cancelled; zmodem {/* Only the SFTP engine's own transfers can be cancelled; zmodem
runs in a separate engine that ignores cancelTransfer. */} 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" className="sftp-transfer-cancel"
onClick={() => window.api.cancelTransfer(id)} onClick={() => window.api.cancelTransfer(id)}
> >
{t('common.cancel')} {tFor(language, 'common.cancel')}
</button> </button>
)} )}
{/* Cancelled rows dismiss themselves after 3s, but one that also {/* 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 '@xterm/xterm/css/xterm.css'
import { ClearOutlined, CopyOutlined, ExportOutlined, FolderOpenOutlined, PauseOutlined, SearchOutlined, SelectOutlined, SnippetsOutlined, SoundOutlined } from '@ant-design/icons' import { ClearOutlined, CopyOutlined, ExportOutlined, FolderOpenOutlined, PauseOutlined, SearchOutlined, SelectOutlined, SnippetsOutlined, SoundOutlined } from '@ant-design/icons'
import { Checkbox, Modal } from 'antd' 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 { getThemeById } from '@shared/theme'
import type { CommandItem } from '@shared/commands' import type { CommandItem } from '@shared/commands'
import { highlightModeOf, type TerminalSettings } from '@shared/settings' import { highlightModeOf, type TerminalSettings } from '@shared/settings'
@@ -113,7 +113,25 @@ interface SearchBarProps {
onNext: () => void onNext: () => void
onClose: () => void onClose: () => void
enabled: boolean 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. */ /** 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)) 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 ( return (
<div className="term-searchbar"> <div className="term-searchbar">
<input <input
className="term-searchbar-input" className="term-searchbar-input"
type="text" type="text"
value={value} value={value}
placeholder={t('terminal.search.placeholder')} placeholder={tFor(language, 'terminal.search.placeholder')}
spellCheck={false} spellCheck={false}
autoFocus autoFocus
onChange={(e) => onChange(e.target.value)} 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() 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={onPrev}>{tFor(language, 'terminal.search.prev')}</button>
<button type="button" className="term-searchbar-btn" disabled={!enabled} onClick={onNext}>{t('terminal.search.next')}</button> <button type="button" className="term-searchbar-btn" disabled={!enabled} onClick={onNext}>{tFor(language, 'terminal.search.next')}</button>
<span className="term-searchbar-count">{result}</span> <span className="term-searchbar-count">{searchResultText(result, language)}</span>
<button type="button" className="term-searchbar-btn" onClick={onClose}>{t('common.close')}</button> <button type="button" className="term-searchbar-btn" onClick={onClose}>{tFor(language, 'common.close')}</button>
</div> </div>
) )
} }
@@ -299,6 +317,12 @@ export function TerminalView({
})) }))
) )
const rendererMode = useSettingsStore((s) => s.settings.terminal.rendererMode) 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( const highlightOpts = useSettingsStore(
useShallow((s) => ({ useShallow((s) => ({
mode: highlightModeOf(s.settings.terminal.highlightMode), mode: highlightModeOf(s.settings.terminal.highlightMode),
@@ -441,7 +465,7 @@ export function TerminalView({
const [searchOpen, setSearchOpen] = useState(false) const [searchOpen, setSearchOpen] = useState(false)
const [searchTerm, setSearchTerm] = useState('') 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 // Right-click context menu: position + whether a selection existed at open
// time (decides if 复制 is enabled). // time (decides if 复制 is enabled).
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null) const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null)
@@ -573,7 +597,7 @@ export function TerminalView({
searchOpenRef.current = false searchOpenRef.current = false
searchRef.current?.clearDecorations() searchRef.current?.clearDecorations()
setSearchOpen(false) setSearchOpen(false)
setResultInfo('') setSearchResult(null)
termRef.current?.focus() termRef.current?.focus()
}, []) }, [])
@@ -585,7 +609,9 @@ export function TerminalView({
dir === 1 dir === 1
? addon.findNext(q, { decorations: SEARCH_DECORATIONS }) ? addon.findNext(q, { decorations: SEARCH_DECORATIONS })
: addon.findPrevious(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. */ /** Record a submitted command line; cd-style lines also update cwd memory. */
@@ -837,7 +863,12 @@ export function TerminalView({
const searchAddon = new SearchAddon() const searchAddon = new SearchAddon()
searchAddon.onDidChangeResults((ev) => { searchAddon.onDidChangeResults((ev) => {
if (searchOpenRef.current && searchTermRef.current.trim()) { 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) term.loadAddon(searchAddon)
@@ -1300,7 +1331,7 @@ export function TerminalView({
onClick={() => runMenuAction(() => void copySelection())} onClick={() => runMenuAction(() => void copySelection())}
> >
<CopyOutlined /> <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> <kbd>Ctrl+Shift+C</kbd>
</button> </button>
<button <button
@@ -1310,23 +1341,23 @@ export function TerminalView({
onClick={() => runMenuAction(() => void pasteFromClipboard())} onClick={() => runMenuAction(() => void pasteFromClipboard())}
> >
<SnippetsOutlined /> <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> <kbd>Ctrl+Shift+V</kbd>
</button> </button>
<div className="term-menu-sep" /> <div className="term-menu-sep" />
<button type="button" className="term-menu-item" onClick={() => runMenuAction(openSearch)}> <button type="button" className="term-menu-item" onClick={() => runMenuAction(openSearch)}>
<SearchOutlined /> <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> <kbd>Ctrl+F</kbd>
</button> </button>
<button type="button" className="term-menu-item" onClick={() => runMenuAction(() => termRef.current?.selectAll())}> <button type="button" className="term-menu-item" onClick={() => runMenuAction(() => termRef.current?.selectAll())}>
<SelectOutlined /> <SelectOutlined />
<span className="term-menu-label">{t('terminal.menu.selectAll')}</span> <span className="term-menu-label">{tFor(language, 'terminal.menu.selectAll')}</span>
<kbd /> <kbd />
</button> </button>
<button type="button" className="term-menu-item" onClick={() => runMenuAction(() => termRef.current?.clear())}> <button type="button" className="term-menu-item" onClick={() => runMenuAction(() => termRef.current?.clear())}>
<ClearOutlined /> <ClearOutlined />
<span className="term-menu-label">{t('terminal.menu.clear')}</span> <span className="term-menu-label">{tFor(language, 'terminal.menu.clear')}</span>
<kbd /> <kbd />
</button> </button>
</div> </div>
@@ -1340,7 +1371,8 @@ export function TerminalView({
onNext={() => search(1)} onNext={() => search(1)}
onClose={closeSearch} onClose={closeSearch}
enabled={searchTerm.trim().length > 0} enabled={searchTerm.trim().length > 0}
result={resultInfo} result={searchResult}
language={language}
/> />
)} )}
<div className="term-recbar"> <div className="term-recbar">
@@ -1348,8 +1380,8 @@ export function TerminalView({
<button <button
type="button" type="button"
className={`term-rec-btn${recording ? ' is-recording' : ''}`} className={`term-rec-btn${recording ? ' is-recording' : ''}`}
title={recording ? t('terminal.rec.stopLog') : t('terminal.rec.startLog')} title={recording ? tFor(language, 'terminal.rec.stopLog') : tFor(language, 'terminal.rec.startLog')}
aria-label={recording ? t('terminal.rec.stop') : t('terminal.rec.start')} aria-label={recording ? tFor(language, 'terminal.rec.stop') : tFor(language, 'terminal.rec.start')}
onClick={() => { onClick={() => {
if (recording) void handleLogStop() if (recording) void handleLogStop()
else void handleLogStart() else void handleLogStart()
@@ -1362,8 +1394,8 @@ export function TerminalView({
<button <button
type="button" type="button"
className="term-rec-btn" className="term-rec-btn"
title={t('terminal.rec.openLogs')} title={tFor(language, 'terminal.rec.openLogs')}
aria-label={t('terminal.rec.openLogs')} aria-label={tFor(language, 'terminal.rec.openLogs')}
onClick={() => window.api.openLogsDir()} onClick={() => window.api.openLogsDir()}
> >
<FolderOpenOutlined /> <FolderOpenOutlined />
@@ -1373,8 +1405,8 @@ export function TerminalView({
<button <button
type="button" type="button"
className="term-rec-btn" className="term-rec-btn"
title={t('terminal.rec.openCwd')} title={tFor(language, 'terminal.rec.openCwd')}
aria-label={t('terminal.rec.openCwd')} aria-label={tFor(language, 'terminal.rec.openCwd')}
onClick={() => { onClick={() => {
const dir = getSessionCwd(sessionId) const dir = getSessionCwd(sessionId)
if (dir) void window.api.openDirectory(dir) if (dir) void window.api.openDirectory(dir)
@@ -1386,29 +1418,29 @@ export function TerminalView({
</div> </div>
<Modal <Modal
open={paste !== null} open={paste !== null}
title={t('terminal.paste.title')} title={tFor(language, 'terminal.paste.title')}
okText={t('common.paste')} okText={tFor(language, 'common.paste')}
cancelText={t('common.cancel')} cancelText={tFor(language, 'common.cancel')}
width={420} width={420}
maskClosable={false} maskClosable={false}
onOk={acceptPaste} onOk={acceptPaste}
onCancel={cancelPaste} onCancel={cancelPaste}
> >
<div className="term-paste-confirm"> <div className="term-paste-confirm">
<p>{t('terminal.paste.message')}</p> <p>{tFor(language, 'terminal.paste.message')}</p>
<Checkbox <Checkbox
checked={paste?.noPrompt ?? false} checked={paste?.noPrompt ?? false}
onChange={(e) => setPaste((prev) => (prev ? { ...prev, noPrompt: e.target.checked } : prev))} onChange={(e) => setPaste((prev) => (prev ? { ...prev, noPrompt: e.target.checked } : prev))}
> >
{t('terminal.paste.noPrompt')} {tFor(language, 'terminal.paste.noPrompt')}
</Checkbox> </Checkbox>
<Checkbox <Checkbox
checked={paste?.disableDetection ?? false} checked={paste?.disableDetection ?? false}
onChange={(e) => setPaste((prev) => (prev ? { ...prev, disableDetection: e.target.checked } : prev))} onChange={(e) => setPaste((prev) => (prev ? { ...prev, disableDetection: e.target.checked } : prev))}
> >
{t('terminal.paste.disableDetection')} {tFor(language, 'terminal.paste.disableDetection')}
</Checkbox> </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> </div>
</Modal> </Modal>
{/* Relative-positioned wrapper around the xterm surface: the suggestion {/* Relative-positioned wrapper around the xterm surface: the suggestion
@@ -1436,7 +1468,7 @@ export function TerminalView({
</button> </button>
))} ))}
</div> </div>
<div className="term-suggest-hint">{t('terminal.suggest.hint')}</div> <div className="term-suggest-hint">{tFor(language, 'terminal.suggest.hint')}</div>
</div> </div>
)} )}
<div className="terminal-view-dock" ref={hostRef}> <div className="terminal-view-dock" ref={hostRef}>
@@ -1459,8 +1491,8 @@ export function TerminalView({
)} )}
{dead && ( {dead && (
<div className="term-dead-mask"> <div className="term-dead-mask">
<div>{t('terminal.dead.message', { code: exitCode })}</div> <div>{tFor(language, 'terminal.dead.message', { code: exitCode })}</div>
<button type="button" className="term-dead-btn" onClick={() => onCloseRef.current()}>{t('common.close')}</button> <button type="button" className="term-dead-btn" onClick={() => onCloseRef.current()}>{tFor(language, 'common.close')}</button>
</div> </div>
)} )}
</div> </div>
@@ -1,18 +1,21 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react' 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' import { FilePanel } from '../sftp/FilePanel'
export interface SshBottomPanelProps { export interface SshBottomPanelProps {
sessionId: string sessionId: string
/** The terminal surface rendered in the flexible top region. */ /** The terminal surface rendered in the flexible top region. */
terminal: ReactNode 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]. */ /** File browser height in px — clamped to [120, 60% of container height]. */
const MIN_HEIGHT = 120 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. */ /** Current file-browser height in px. */
const [filesHeight, setFilesHeight] = useState<number>(220) const [filesHeight, setFilesHeight] = useState<number>(220)
/** Divider drag in flight — drives the accent highlight. */ /** 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' : '')} className={'ssh-bottom-divider' + (resizing ? ' is-resizing' : '')}
role="separator" role="separator"
aria-orientation="horizontal" aria-orientation="horizontal"
aria-label={t('ssh.bottomPanel.resize')} aria-label={tFor(language, 'ssh.bottomPanel.resize')}
onPointerDown={onDividerPointerDown} onPointerDown={onDividerPointerDown}
onPointerMove={onDividerPointerMove} onPointerMove={onDividerPointerMove}
onPointerUp={endDrag} 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`) * 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 * 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 * truncated with an ellipsis rather than wrapped; the full label is always
* available via the native `title` tooltip and the tab-level tooltip. * 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 ( return (
<span <span
className="ssh-host-badge" className="ssh-host-badge"
title={label} title={label}
aria-label={t('ssh.hostBadge.aria', { label })} aria-label={tFor(language, 'ssh.hostBadge.aria', { label })}
> >
{label} {label}
</span> </span>
+12 -5
View File
@@ -3,7 +3,8 @@ import type { IDockviewPanelProps } from 'dockview-react'
import { Button } from 'antd' import { Button } from 'antd'
import { AreaChartOutlined } from '@ant-design/icons' 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 { PanelErrorBoundary } from '../ErrorBoundary'
import { TerminalView } from '../terminal/TerminalView' import { TerminalView } from '../terminal/TerminalView'
import { MonitorPanel } from '../monitor/MonitorPanel' 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. */ /** Right sidebar (monitor) visibility for ssh sessions — expanded by default. */
const [sideOpen, setSideOpen] = useState(true) 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 // 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 // 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 // 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. // is remounted on retry; the shell (tab, broadcast registration) survives.
return ( return (
<div className="workspace-terminal-panel"> <div className="workspace-terminal-panel">
<PanelErrorBoundary label={t('workspace.error.panelTitle')}> <PanelErrorBoundary label={tFor(language, 'workspace.error.panelTitle')}>
{isSsh && ( {isSsh && (
<Button <Button
type="text" type="text"
size="small" size="small"
className="workspace-terminal-monitor-toggle" className="workspace-terminal-monitor-toggle"
icon={<AreaChartOutlined />} icon={<AreaChartOutlined />}
aria-label={sideOpen ? t('workspace.panel.hideSidebar') : t('workspace.panel.showSidebar')} aria-label={sideOpen ? tFor(language, 'workspace.panel.hideSidebar') : tFor(language, 'workspace.panel.showSidebar')}
title={sideOpen ? t('workspace.panel.hideSidebar') : t('workspace.panel.showSidebar')} title={sideOpen ? tFor(language, 'workspace.panel.hideSidebar') : tFor(language, 'workspace.panel.showSidebar')}
onClick={() => setSideOpen((prev) => !prev)} onClick={() => setSideOpen((prev) => !prev)}
/> />
)} )}
<div className="workspace-terminal-split"> <div className="workspace-terminal-split">
{isSsh ? <SshBottomPanel sessionId={sessionId} terminal={terminal} /> : terminal} {isSsh ? <SshBottomPanel sessionId={sessionId} terminal={terminal} language={language} /> : terminal}
{isSsh && sideOpen && ( {isSsh && sideOpen && (
<div className="workspace-terminal-side"> <div className="workspace-terminal-side">
<MonitorPanel sessionId={sessionId} /> <MonitorPanel sessionId={sessionId} />
+17 -10
View File
@@ -6,7 +6,8 @@ import type { IDockviewPanelHeaderProps } from 'dockview-react'
import { useBroadcastStore } from './broadcastStore' import { useBroadcastStore } from './broadcastStore'
import { SshHostBadge } from './SshHostBadge' 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' import type { LayoutMeta } from '@shared/ipc'
export type TerminalTabProps = IDockviewPanelHeaderProps<{ 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 [title, setTitle] = useState<string | undefined>(api.title)
const selfIndex = panel.panels.findIndex((p) => p.api === api) 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 isSsh = params?.sessionKind === 'ssh'
const hostLabel: string | undefined = isSsh ? (params?.hostLabel || title) : undefined const hostLabel: string | undefined = isSsh ? (params?.hostLabel || title) : undefined
const tooltip = isSsh const tooltip = isSsh
? hostLabel && hostLabel !== title ? hostLabel && hostLabel !== title
? `${title} — ${hostLabel}` ? `${title} — ${hostLabel}`
: (hostLabel || title) : (hostLabel || title)
: t('workspace.tab.localTerminal') : tFor(language, 'workspace.tab.localTerminal')
// M6 broadcast: show a badge on tabs that are current broadcast targets. // M6 broadcast: show a badge on tabs that are current broadcast targets.
const isBroadcastTarget = useBroadcastStore((state) => state.targets.has(params?.sessionId ?? '')) 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 = { const menu: MenuProps = {
items: [ items: [
{ key: 'close', label: t('common.close') }, { key: 'close', label: tFor(language, 'common.close') },
{ key: 'closeOthers', label: t('workspace.tab.closeOthers'), disabled: panel.panels.length <= 1 }, { key: 'closeOthers', label: tFor(language, 'workspace.tab.closeOthers'), disabled: panel.panels.length <= 1 },
{ key: 'closeRight', label: t('workspace.tab.closeRight'), disabled: selfIndex === -1 || selfIndex === panel.panels.length - 1 } { key: 'closeRight', label: tFor(language, 'workspace.tab.closeRight'), disabled: selfIndex === -1 || selfIndex === panel.panels.length - 1 }
], ],
onClick: ({ key }) => { onClick: ({ key }) => {
if (key === 'close') { if (key === 'close') {
@@ -85,20 +92,20 @@ export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Elemen
> >
{isSsh && <CloudServerOutlined className="workspace-terminal-tab-icon" />} {isSsh && <CloudServerOutlined className="workspace-terminal-tab-icon" />}
<span className="workspace-terminal-tab-title">{title}</span> <span className="workspace-terminal-tab-title">{title}</span>
{isSsh && hostLabel && <SshHostBadge label={hostLabel} />} {isSsh && hostLabel && <SshHostBadge label={hostLabel} language={language} />}
{isBroadcastTarget && ( {isBroadcastTarget && (
<span <span
className="workspace-terminal-tab-bcast" className="workspace-terminal-tab-bcast"
title={t('workspace.tab.broadcastTarget')} title={tFor(language, 'workspace.tab.broadcastTarget')}
aria-label={t('workspace.tab.broadcastTarget')} aria-label={tFor(language, 'workspace.tab.broadcastTarget')}
> >
{t('workspace.tab.broadcastBadge')} {tFor(language, 'workspace.tab.broadcastBadge')}
</span> </span>
)} )}
<button <button
type="button" type="button"
className="workspace-terminal-tab-close" className="workspace-terminal-tab-close"
aria-label={t('common.close')} aria-label={tFor(language, 'common.close')}
onPointerDown={(event) => event.preventDefault()} onPointerDown={(event) => event.preventDefault()}
onClick={(event) => { onClick={(event) => {
event.preventDefault() event.preventDefault()
+26 -18
View File
@@ -27,7 +27,7 @@ import type {
import 'dockview-react/dist/styles/dockview.css' import 'dockview-react/dist/styles/dockview.css'
import { connectPromptFor, type HostKeyPromptEvent, type SshConnection, type SshSecretOverride } from '@shared/connections' 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 { nextTerminalTitle, retitleAutoTitles } from '@shared/terminalTitle'
import { ConnectionSidebar } from '../connections/ConnectionSidebar' import { ConnectionSidebar } from '../connections/ConnectionSidebar'
import type { ConnectionSidebarHandle } 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). */ * terminal dockview and hints from the server list in the ssh dockview). */
function createTabActions(mode: WorkspaceMode): (props: IDockviewHeaderActionsProps) => React.JSX.Element { function createTabActions(mode: WorkspaceMode): (props: IDockviewHeaderActionsProps) => React.JSX.Element {
function TabActions({ containerApi, group }: 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 // 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. // opened from the server list, so in ssh mode the button just hints.
const { message } = App.useApp() const { message } = App.useApp()
const handleAdd = (): void => { const handleAdd = (): void => {
if (mode === 'ssh') { if (mode === 'ssh') {
message.info(t('workspace.hint.sshFromList')) message.info(tFor(language, 'workspace.hint.sshFromList'))
return return
} }
void (async (): Promise<void> => { void (async (): Promise<void> => {
@@ -272,8 +276,8 @@ function createTabActions(mode: WorkspaceMode): (props: IDockviewHeaderActionsPr
return ( return (
<div className="workspace-tabbar-actions"> <div className="workspace-tabbar-actions">
<AddTerminalGroupButton onClick={handleAdd} /> <AddTerminalGroupButton language={language} onClick={handleAdd} />
<BroadcastToggleButton /> <BroadcastToggleButton language={language} />
</div> </div>
) )
} }
@@ -1368,13 +1372,13 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
} }
/** "+" renders the new-terminal button (per-group tab addition). */ /** "+" 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 ( return (
<button <button
type="button" type="button"
className="workspace-tabbar-add" className="workspace-tabbar-add"
title={t('workspace.tab.addInSplit')} title={tFor(language, 'workspace.tab.addInSplit')}
aria-label={t('workspace.tab.addInSplit')} aria-label={tFor(language, 'workspace.tab.addInSplit')}
onClick={onClick} onClick={onClick}
> >
<PlusOutlined /> <PlusOutlined />
@@ -1383,7 +1387,7 @@ function AddTerminalGroupButton({ onClick }: { onClick: () => void }): React.JSX
} }
/** 广播 target picker: toggle + Popover with per-session checkboxes. */ /** 广播 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 enabled = useBroadcastStore((s) => s.enabled)
const targets = useBroadcastStore((s) => s.targets) const targets = useBroadcastStore((s) => s.targets)
const sessions = useBroadcastStore((s) => s.sessions) const sessions = useBroadcastStore((s) => s.sessions)
@@ -1409,14 +1413,15 @@ function BroadcastToggleButton(): React.JSX.Element {
sessions={sessions} sessions={sessions}
onToggleTarget={toggleTarget} onToggleTarget={toggleTarget}
onSetEnabled={setEnabled} onSetEnabled={setEnabled}
language={language}
/> />
} }
> >
<button <button
type="button" type="button"
className={cls} className={cls}
title={enabled ? t('workspace.broadcast.activeTitle', { count: targets.size }) : t('workspace.broadcast.toggle')} title={enabled ? tFor(language, 'workspace.broadcast.activeTitle', { count: targets.size }) : tFor(language, 'workspace.broadcast.toggle')}
aria-label={t('workspace.broadcast.toggle')} aria-label={tFor(language, 'workspace.broadcast.toggle')}
aria-pressed={enabled} aria-pressed={enabled}
> >
<ShareAltOutlined /> <ShareAltOutlined />
@@ -1435,7 +1440,8 @@ function WorkspaceBroadcastPopover({
targets, targets,
sessions, sessions,
onToggleTarget, onToggleTarget,
onSetEnabled onSetEnabled,
language
}: { }: {
mode: WorkspaceMode mode: WorkspaceMode
enabled: boolean enabled: boolean
@@ -1443,6 +1449,8 @@ function WorkspaceBroadcastPopover({
sessions: { id: string; title: string; isSsh: boolean }[] sessions: { id: string; title: string; isSsh: boolean }[]
onToggleTarget: (id: string) => void onToggleTarget: (id: string) => void
onSetEnabled: (enabled: boolean) => void onSetEnabled: (enabled: boolean) => void
/** from the dockview header-actions component — see TabActions */
language: Language
}): React.JSX.Element { }): React.JSX.Element {
const isSsh = (s: { id: string; title: string; isSsh: boolean }): boolean => s.isSsh const isSsh = (s: { id: string; title: string; isSsh: boolean }): boolean => s.isSsh
const groupSessions = sessions.filter((s) => (mode === 'ssh' ? isSsh(s) : !isSsh(s))) const groupSessions = sessions.filter((s) => (mode === 'ssh' ? isSsh(s) : !isSsh(s)))
@@ -1451,26 +1459,26 @@ function WorkspaceBroadcastPopover({
return ( return (
<div className="workspace-bcast-pop"> <div className="workspace-bcast-pop">
<div className="workspace-bcast-pop-mode"> <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>
<div className="workspace-bcast-pop-switch"> <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 <Checkbox
checked={enabled} checked={enabled}
disabled={targets.size < 2} disabled={targets.size < 2}
onChange={(e) => onSetEnabled(e.target.checked)} onChange={(e) => onSetEnabled(e.target.checked)}
> >
{enabled {enabled
? t('workspace.broadcast.enabled', { count: targets.size }) ? tFor(language, 'workspace.broadcast.enabled', { count: targets.size })
: targets.size < 2 : targets.size < 2
? t('workspace.broadcast.needTwo') ? tFor(language, 'workspace.broadcast.needTwo')
: t('workspace.broadcast.disabled')} : tFor(language, 'workspace.broadcast.disabled')}
</Checkbox> </Checkbox>
</div> </div>
<div className="workspace-bcast-pop-list"> <div className="workspace-bcast-pop-list">
{groupSessions.length === 0 ? ( {groupSessions.length === 0 ? (
<div className="workspace-bcast-pop-empty"> <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> </div>
) : ( ) : (
groupSessions.map((s) => ( groupSessions.map((s) => (
@@ -1482,7 +1490,7 @@ function WorkspaceBroadcastPopover({
)) ))
)} )}
</div> </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> </div>
) )
} }