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:
1 parent
c94e3358ea
commit
402bf368fc
9 files changed
+263
-171
No files matched your search
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user