feat: multi-language interface (zh-CN/zh-TW/en/ja), multilingual offline changelog, settings robustness
i18n - shared/i18n: dependency-free t() with flat per-namespace dictionaries (common/settings/workspace/terminal/ssh/panels/main), zh-CN fallback - language picker in Settings -> System; antd ConfigProvider locale follows it - main process tracks the language too: tray menu, close prompt, ssh/sftp/ zmodem errors and the log TUI marker are translated; tray rebuilds on change changelog - CHANGELOG.md (zh-CN canonical) + .zh-TW/.en/.ja, bundled via ?raw and read per interface language with per-version fallback to zh-CN (no network) - sync-changelog.cjs merges RELEASE_NOTES[.<lang>].md per release; release.cjs refuses to publish without a zh-CN entry for the version settings robustness - closeAction 'ask' survives the sanitizer (was silently coerced to 'tray', which made the 'ask every time' option dead) - highlight rules are repaired instead of dropped: string priority, 0/1 enabled, missing fg colour; unknown fields preserved - load-time warnings are written to settings-warnings.log (deduped, capped) terminal/UI - configurable terminal toolbar: open working directory (default on), session log recording (off), open logs folder (off) - global shortcut field records key combos (modifier or F-key required) - input suggestions + command history default to off, with a one-time reset migration for existing installs - settings dialog scrolling fixed (antd v6 renamed the tabs container), theme gallery nested scrollbar removed, joined segmented pickers with readable selected-state text tests: settings-store.mjs (15 checks) added; commands-store.mjs updated for the new off-by-default history setting
This commit is contained in:
1 parent
34094d607b
commit
4c6a29ef28
82 files changed
+3342
-608
No files matched your search
@@ -1,12 +1,26 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ConfigProvider } from 'antd'
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
import zhTW from 'antd/locale/zh_TW'
|
||||
import enUS from 'antd/locale/en_US'
|
||||
import jaJP from 'antd/locale/ja_JP'
|
||||
import Workspace from '@renderer/workspace/Workspace'
|
||||
import { SettingsDialog } from '@renderer/settings/SettingsDialog'
|
||||
import { TransferPanel } from '@renderer/sftp/TransferPanel'
|
||||
import { useSettingsStore } from '@renderer/settings/store'
|
||||
import { getThemeById } from '@shared/theme'
|
||||
import { DEFAULT_LANGUAGE, setLanguage, type Language } from '@shared/i18n'
|
||||
import { applyChromeTheme, applyTabAccent } from '@renderer/theme/chrome'
|
||||
import appIconUrl from '../../../build/icon.png'
|
||||
|
||||
/** antd's own component texts (empty states, pagination, date pickers). */
|
||||
const ANTD_LOCALES: Record<Language, typeof zhCN> = {
|
||||
'zh-CN': zhCN,
|
||||
'zh-TW': zhTW,
|
||||
en: enUS,
|
||||
ja: jaJP
|
||||
}
|
||||
|
||||
/** Custom window title bar: draggable strip themed with the active terminal
|
||||
* theme; native min/max/close buttons come from BrowserWindow titleBarOverlay. */
|
||||
function TitleBar(): React.JSX.Element {
|
||||
@@ -28,12 +42,18 @@ export default function App(): React.JSX.Element {
|
||||
const themeId = useSettingsStore((s) => s.settings.terminal.themeId)
|
||||
const customThemes = useSettingsStore((s) => s.settings.customThemes)
|
||||
const tabAccentColor = useSettingsStore((s) => s.settings.terminal.tabAccentColor)
|
||||
const language = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
void hydrate()
|
||||
}, [hydrate])
|
||||
|
||||
// Interface language: drives t() and antd's built-in component texts.
|
||||
useEffect(() => {
|
||||
setLanguage(language)
|
||||
}, [language])
|
||||
|
||||
// Sidebar / tab bars / dividers follow the active terminal theme.
|
||||
useEffect(() => {
|
||||
applyChromeTheme(getThemeById(themeId, customThemes).colors)
|
||||
@@ -45,11 +65,13 @@ export default function App(): React.JSX.Element {
|
||||
}, [tabAccentColor])
|
||||
|
||||
return (
|
||||
<div className="app-root">
|
||||
<TitleBar />
|
||||
<Workspace onOpenSettings={() => setSettingsOpen(true)} />
|
||||
<SettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
<TransferPanel />
|
||||
</div>
|
||||
<ConfigProvider locale={ANTD_LOCALES[language]}>
|
||||
<div className="app-root">
|
||||
<TitleBar />
|
||||
<Workspace onOpenSettings={() => setSettingsOpen(true)} />
|
||||
<SettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
<TransferPanel />
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component } from 'react'
|
||||
import type { ErrorInfo, ReactNode } from 'react'
|
||||
import { t } from '@shared/i18n'
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode
|
||||
@@ -38,7 +39,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
||||
color: '#cccccc'
|
||||
}}
|
||||
>
|
||||
<div>界面出现异常</div>
|
||||
<div>{t('workspace.error.title')}</div>
|
||||
<pre
|
||||
style={{
|
||||
maxWidth: 720,
|
||||
@@ -56,7 +57,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
||||
onClick={() => window.location.reload()}
|
||||
style={{ padding: '6px 18px', cursor: 'pointer' }}
|
||||
>
|
||||
重新加载
|
||||
{t('workspace.error.reload')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import { Button, Modal, Popconfirm, Tabs } from 'antd'
|
||||
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import type { CommandItem } from '@shared/commands'
|
||||
import { t } from '@shared/i18n'
|
||||
import { useWorkspaceModeStore } from '../workspace/workspaceModeStore'
|
||||
import './commands.css'
|
||||
|
||||
@@ -88,8 +89,10 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
|
||||
return (
|
||||
<div className="commands-panel">
|
||||
<div className={`commands-mode-badge commands-mode-badge--${workspaceMode}`}>
|
||||
<span className="commands-mode-badge-text">发送目标</span>
|
||||
<span className="commands-mode-badge-tag">{workspaceMode === 'ssh' ? 'SSH 服务器' : '终端'}</span>
|
||||
<span className="commands-mode-badge-text">{t('panels.commands.target')}</span>
|
||||
<span className="commands-mode-badge-tag">
|
||||
{workspaceMode === 'ssh' ? t('panels.commands.targetSsh') : t('panels.commands.targetTerminal')}
|
||||
</span>
|
||||
</div>
|
||||
<Tabs
|
||||
size="small"
|
||||
@@ -97,27 +100,29 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
|
||||
items={[
|
||||
{
|
||||
key: 'history',
|
||||
label: '历史',
|
||||
label: t('panels.commands.tabHistory'),
|
||||
children: (
|
||||
<div className="commands-section">
|
||||
<div className="commands-section-head">
|
||||
<span className="commands-section-title">历史命令</span>
|
||||
<span className="commands-section-title">{t('panels.commands.historyTitle')}</span>
|
||||
{history.length > 0 && (
|
||||
<Popconfirm
|
||||
title="清空全部命令历史?"
|
||||
okText="清空"
|
||||
cancelText="取消"
|
||||
title={t('panels.commands.clearTitle')}
|
||||
okText={t('panels.commands.clear')}
|
||||
cancelText={t('common.cancel')}
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => void handleClearHistory()}
|
||||
>
|
||||
<Button type="text" size="small" danger className="commands-clear-btn">
|
||||
清空
|
||||
{t('panels.commands.clear')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</div>
|
||||
{history.length === 0 ? (
|
||||
<div className="commands-empty">{loaded ? '暂无历史命令' : '加载中…'}</div>
|
||||
<div className="commands-empty">
|
||||
{loaded ? t('panels.commands.noHistory') : t('panels.commands.loading')}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="commands-list">
|
||||
{history.map((item) => (
|
||||
@@ -130,22 +135,24 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
|
||||
},
|
||||
{
|
||||
key: 'library',
|
||||
label: '命令库',
|
||||
label: t('panels.commands.tabLibrary'),
|
||||
children: (
|
||||
<div className="commands-section">
|
||||
<div className="commands-section-head">
|
||||
<span className="commands-section-title">命令库</span>
|
||||
<span className="commands-section-title">{t('panels.commands.libraryTitle')}</span>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAdd}
|
||||
aria-label="新增命令"
|
||||
title="新增命令"
|
||||
aria-label={t('panels.commands.add')}
|
||||
title={t('panels.commands.add')}
|
||||
/>
|
||||
</div>
|
||||
{library.length === 0 ? (
|
||||
<div className="commands-empty">{loaded ? '命令库为空' : '加载中…'}</div>
|
||||
<div className="commands-empty">
|
||||
{loaded ? t('panels.commands.libraryEmpty') : t('panels.commands.loading')}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="commands-list">
|
||||
{library.map((item) => (
|
||||
@@ -166,9 +173,9 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
|
||||
|
||||
<Modal
|
||||
open={addOpen}
|
||||
title="新增命令"
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
title={t('panels.commands.add')}
|
||||
okText={t('common.save')}
|
||||
cancelText={t('common.cancel')}
|
||||
okButtonProps={{ disabled: command.trim().length === 0 }}
|
||||
onOk={() => void handleSave()}
|
||||
onCancel={() => setAddOpen(false)}
|
||||
@@ -176,23 +183,23 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
|
||||
>
|
||||
<div className="commands-form">
|
||||
<label className="commands-field">
|
||||
<span className="commands-field-label">名称</span>
|
||||
<span className="commands-field-label">{t('panels.commands.name')}</span>
|
||||
<input
|
||||
className="commands-input"
|
||||
type="text"
|
||||
value={name}
|
||||
placeholder="(可选)"
|
||||
placeholder={t('panels.commands.optional')}
|
||||
spellCheck={false}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="commands-field">
|
||||
<span className="commands-field-label">命令</span>
|
||||
<span className="commands-field-label">{t('panels.commands.command')}</span>
|
||||
<input
|
||||
className="commands-input"
|
||||
type="text"
|
||||
value={command}
|
||||
placeholder="如 ls -la"
|
||||
placeholder={t('panels.commands.commandPlaceholder')}
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
@@ -203,12 +210,12 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
|
||||
/>
|
||||
</label>
|
||||
<label className="commands-field">
|
||||
<span className="commands-field-label">备注</span>
|
||||
<span className="commands-field-label">{t('panels.commands.note')}</span>
|
||||
<input
|
||||
className="commands-input"
|
||||
type="text"
|
||||
value={note}
|
||||
placeholder="(可选)"
|
||||
placeholder={t('panels.commands.optional')}
|
||||
spellCheck={false}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
@@ -240,9 +247,9 @@ function Row({
|
||||
</div>
|
||||
{item.name && onDelete && (
|
||||
<Popconfirm
|
||||
title="删除该命令?"
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
title={t('panels.commands.deleteTitle')}
|
||||
okText={t('common.delete')}
|
||||
cancelText={t('common.cancel')}
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={onDelete}
|
||||
>
|
||||
@@ -252,7 +259,7 @@ function Row({
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
className="commands-row-del"
|
||||
aria-label="删除命令"
|
||||
aria-label={t('panels.commands.deleteAria')}
|
||||
/>
|
||||
</Popconfirm>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SshAuthMethod, SshConnection, SshConnectionInput, SshSavedAuthFlags } from '@shared/connections'
|
||||
import { t } from '@shared/i18n'
|
||||
|
||||
/**
|
||||
* Shared helpers for the connection manager UI (connections/**, renderer).
|
||||
@@ -22,7 +23,11 @@ export function hasSavedKind(kind: SecretKind, savedAuth: SshSavedAuthFlags | un
|
||||
}
|
||||
|
||||
export function authLabel(kind: SshAuthMethod): string {
|
||||
return kind === 'privateKey' ? '私钥' : kind === 'agent' ? 'Agent' : '密码'
|
||||
return kind === 'privateKey'
|
||||
? t('ssh.auth.privateKey')
|
||||
: kind === 'agent'
|
||||
? t('ssh.auth.agent')
|
||||
: t('ssh.auth.password')
|
||||
}
|
||||
|
||||
export function AuthTags({ auth }: { auth: SshAuthMethod }): React.JSX.Element {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Alert, Button, Form, Input, InputNumber, Modal, Radio, Select } from 'antd'
|
||||
import type { SshAuthMethod, SshConnection, SshConnectionInput } from '@shared/connections'
|
||||
import { getLanguage, t } from '@shared/i18n'
|
||||
import { AuthFieldsRenderer } from './Fields'
|
||||
import { buildInput, num, hasSavedKind, type SecretKind } from './Common'
|
||||
|
||||
@@ -87,7 +88,7 @@ export function ConnectionEditDialog({
|
||||
onSaved()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setErrorMsg(err instanceof Error ? err.message : '保存失败,请检查输入后重试')
|
||||
setErrorMsg(err instanceof Error ? err.message : t('ssh.dialog.saveFailed'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -97,21 +98,24 @@ export function ConnectionEditDialog({
|
||||
if (!saving) onClose()
|
||||
}
|
||||
|
||||
/** Active language; a dep of the memo below so translated labels refresh. */
|
||||
const language = getLanguage()
|
||||
|
||||
const savedNotes = useMemo(() => {
|
||||
if (!editing) return []
|
||||
const kinds: Array<{ kind: SecretKind; label: string }> = [
|
||||
{ kind: 'password', label: '密码' },
|
||||
{ kind: 'keyContent', label: '私钥' },
|
||||
{ kind: 'passphrase', label: '口令' }
|
||||
{ kind: 'password', label: t('ssh.auth.password') },
|
||||
{ kind: 'keyContent', label: t('ssh.auth.privateKey') },
|
||||
{ kind: 'passphrase', label: t('ssh.secret.passphrase') }
|
||||
]
|
||||
const active = auth === 'password' ? ['password'] : auth === 'privateKey' ? ['keyContent', 'passphrase'] : []
|
||||
return kinds.filter((k) => active.includes(k.kind) && hasSavedKind(k.kind, editing.savedAuth))
|
||||
}, [editing, auth])
|
||||
}, [editing, auth, language])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={editing ? '编辑连接' : '新建连接'}
|
||||
title={editing ? t('ssh.dialog.titleEdit') : t('ssh.dialog.titleNew')}
|
||||
width={520}
|
||||
onCancel={handleCancel}
|
||||
destroyOnHidden
|
||||
@@ -119,10 +123,10 @@ export function ConnectionEditDialog({
|
||||
<div className="connections-dialog-footer">
|
||||
<span className="connections-dialog-error">{errorMsg}</span>
|
||||
<Button onClick={onClose} disabled={saving} style={{ marginRight: 8 }}>
|
||||
取消
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="primary" loading={saving} onClick={() => void handleSave()}>
|
||||
保存
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
@@ -136,17 +140,17 @@ export function ConnectionEditDialog({
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="名称"
|
||||
rules={[{ required: true, whitespace: true, message: '请输入连接名称' }]}
|
||||
label={t('ssh.dialog.name')}
|
||||
rules={[{ required: true, whitespace: true, message: t('ssh.dialog.nameRequired') }]}
|
||||
>
|
||||
<Input placeholder="例如:生产服务器" autoComplete="off" />
|
||||
<Input placeholder={t('ssh.dialog.namePlaceholder')} autoComplete="off" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="group" label="分组">
|
||||
<Form.Item name="group" label={t('ssh.dialog.group')}>
|
||||
<Select
|
||||
allowClear
|
||||
mode="tags"
|
||||
placeholder="选择或输入新分组,留空归入“默认”"
|
||||
placeholder={t('ssh.dialog.groupPlaceholder')}
|
||||
options={groupOptions.map((g) => ({ value: g, label: g }))}
|
||||
maxCount={1}
|
||||
/>
|
||||
@@ -154,33 +158,33 @@ export function ConnectionEditDialog({
|
||||
|
||||
<Form.Item
|
||||
name="host"
|
||||
label="主机"
|
||||
rules={[{ required: true, whitespace: true, message: '请输入主机地址' }]}
|
||||
label={t('ssh.dialog.host')}
|
||||
rules={[{ required: true, whitespace: true, message: t('ssh.dialog.hostRequired') }]}
|
||||
>
|
||||
<Input placeholder="192.168.1.10 或 example.com" autoComplete="off" />
|
||||
<Input placeholder={t('ssh.dialog.hostPlaceholder')} autoComplete="off" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="port"
|
||||
label="端口"
|
||||
rules={[{ type: 'number', min: 1, max: 65535, message: '端口需在 1–65535 之间' }]}
|
||||
label={t('ssh.dialog.port')}
|
||||
rules={[{ type: 'number', min: 1, max: 65535, message: t('ssh.dialog.portRange') }]}
|
||||
>
|
||||
<InputNumber className="connections-port-input" min={1} max={65535} placeholder="22" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="用户名"
|
||||
rules={[{ required: true, whitespace: true, message: '请输入用户名' }]}
|
||||
label={t('ssh.dialog.username')}
|
||||
rules={[{ required: true, whitespace: true, message: t('ssh.dialog.usernameRequired') }]}
|
||||
>
|
||||
<Input placeholder="root" autoComplete="off" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="auth" label="认证方式">
|
||||
<Form.Item name="auth" label={t('ssh.dialog.authMethod')}>
|
||||
<Radio.Group onChange={(e) => setAuth(e.target.value as SshAuthMethod)}>
|
||||
<Radio value="password">密码</Radio>
|
||||
<Radio value="privateKey">私钥</Radio>
|
||||
<Radio value="agent">Agent</Radio>
|
||||
<Radio value="password">{t('ssh.auth.password')}</Radio>
|
||||
<Radio value="privateKey">{t('ssh.auth.privateKey')}</Radio>
|
||||
<Radio value="agent">{t('ssh.auth.agent')}</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
@@ -191,7 +195,7 @@ export function ConnectionEditDialog({
|
||||
type="info"
|
||||
showIcon
|
||||
className="connections-secret-note"
|
||||
message={`已保存${n.label},留空将保留原值`}
|
||||
message={t('ssh.dialog.savedSecret', { kind: n.label })}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -199,9 +203,9 @@ export function ConnectionEditDialog({
|
||||
|
||||
<Form.Item
|
||||
name="keepaliveIntervalSec"
|
||||
label="Keepalive 间隔(秒)"
|
||||
tooltip="0 表示关闭 keepalive"
|
||||
rules={[{ type: 'number', min: 0, message: '不能为负数' }]}
|
||||
label={t('ssh.dialog.keepalive')}
|
||||
tooltip={t('ssh.dialog.keepaliveTooltip')}
|
||||
rules={[{ type: 'number', min: 0, message: t('ssh.dialog.keepaliveMin') }]}
|
||||
>
|
||||
<InputNumber className="connections-keepalive-input" min={0} placeholder="30" />
|
||||
</Form.Item>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'r
|
||||
import { Button, Collapse, Popconfirm, Tooltip } from 'antd'
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import type { SshConnection } from '@shared/connections'
|
||||
import { t } from '@shared/i18n'
|
||||
import { AuthTags } from './Common'
|
||||
import { ConnectionEditDialog } from './ConnectionEditDialog'
|
||||
import { CommandsPanel } from '../commands/CommandsPanel'
|
||||
@@ -28,18 +29,16 @@ export interface ConnectionSidebarHandle {
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
const DEFAULT_GROUP = '默认'
|
||||
|
||||
/** Short human "time ago" for the recent-session rows. */
|
||||
function formatRelativeTime(ts: number): string {
|
||||
const diffSec = Math.max(0, Math.floor((Date.now() - ts) / 1000))
|
||||
if (diffSec < 60) return '刚刚'
|
||||
if (diffSec < 60) return t('ssh.sidebar.timeJustNow')
|
||||
const mins = Math.floor(diffSec / 60)
|
||||
if (mins < 60) return `${mins} 分钟前`
|
||||
if (mins < 60) return t('ssh.sidebar.timeMinutesAgo', { n: mins })
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 24) return `${hours} 小时前`
|
||||
if (hours < 24) return t('ssh.sidebar.timeHoursAgo', { n: hours })
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 30) return `${days} 天前`
|
||||
if (days < 30) return t('ssh.sidebar.timeDaysAgo', { n: days })
|
||||
return new Date(ts).toLocaleDateString()
|
||||
}
|
||||
|
||||
@@ -87,27 +86,28 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
|
||||
}, [])
|
||||
|
||||
// Fold every group open whenever the dataset changes.
|
||||
const defaultGroup = t('ssh.sidebar.defaultGroup')
|
||||
const activeGroups = useMemo(() => {
|
||||
const groups = new Set<string>()
|
||||
for (const c of connections) groups.add(c.group ?? DEFAULT_GROUP)
|
||||
for (const c of connections) groups.add(c.group ?? defaultGroup)
|
||||
return [...groups]
|
||||
}, [connections])
|
||||
}, [connections, defaultGroup])
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, SshConnection[]>()
|
||||
for (const c of connections) {
|
||||
const key = c.group ?? DEFAULT_GROUP
|
||||
const key = c.group ?? defaultGroup
|
||||
const list = map.get(key)
|
||||
if (list) list.push(c)
|
||||
else map.set(key, [c])
|
||||
}
|
||||
const entries = [...map.entries()].sort((a, b) => {
|
||||
if (a[0] === DEFAULT_GROUP) return -1
|
||||
if (b[0] === DEFAULT_GROUP) return 1
|
||||
if (a[0] === defaultGroup) return -1
|
||||
if (b[0] === defaultGroup) return 1
|
||||
return a[0].localeCompare(b[0])
|
||||
})
|
||||
return entries.map(([group, list]) => ({ group, list }))
|
||||
}, [connections])
|
||||
}, [connections, defaultGroup])
|
||||
|
||||
const openCreate = (): void => {
|
||||
setEditing(null)
|
||||
@@ -163,16 +163,16 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
|
||||
return (
|
||||
<aside className={className ?? 'connections-sidebar'}>
|
||||
<div className="connections-mode-title">
|
||||
{isTerminal ? '终端工作区' : 'SSH 工作区'}
|
||||
{isTerminal ? t('ssh.sidebar.modeTerminal') : t('ssh.sidebar.modeSsh')}
|
||||
</div>
|
||||
<div className="connections-header">
|
||||
<span className="connections-title">连接</span>
|
||||
<span className="connections-title">{t('ssh.sidebar.title')}</span>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={openCreate}
|
||||
aria-label="新建连接"
|
||||
aria-label={t('ssh.sidebar.newConnection')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -180,11 +180,11 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
|
||||
{isTerminal && (
|
||||
<section className="connections-section">
|
||||
<div className="connections-section-head">
|
||||
<span className="connections-section-title">终端</span>
|
||||
<span className="connections-section-title">{t('ssh.sidebar.sectionTerminals')}</span>
|
||||
<span className="connections-section-count">{localPanels.length}</span>
|
||||
</div>
|
||||
{localPanels.length === 0 ? (
|
||||
<div className="connections-section-empty">无打开的本地终端</div>
|
||||
<div className="connections-section-empty">{t('ssh.sidebar.noLocalTerminals')}</div>
|
||||
) : (
|
||||
<ul className="connections-local-list">
|
||||
{localPanels.map((panel) => (
|
||||
@@ -195,7 +195,7 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
|
||||
title={panel.title}
|
||||
>
|
||||
<span className="connections-local-name">{panel.title}</span>
|
||||
<span className="connections-local-tag">本地</span>
|
||||
<span className="connections-local-tag">{t('ssh.sidebar.local')}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -206,16 +206,16 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
|
||||
{!isTerminal && (
|
||||
<section className="connections-section">
|
||||
<div className="connections-section-head">
|
||||
<span className="connections-section-title">SSH 服务器</span>
|
||||
<span className="connections-section-title">{t('ssh.sidebar.sectionServers')}</span>
|
||||
</div>
|
||||
{connections.length === 0 ? (
|
||||
<div className="connections-empty">
|
||||
{loaded ? (
|
||||
<button type="button" className="connections-empty-create" onClick={openCreate}>
|
||||
暂无连接,点击+新建
|
||||
{t('ssh.sidebar.empty')}
|
||||
</button>
|
||||
) : (
|
||||
<span>加载中…</span>
|
||||
<span>{t('ssh.sidebar.loading')}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -231,7 +231,7 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
|
||||
{recent.length > 0 && (
|
||||
<div className="connections-recent">
|
||||
<div className="connections-recent-head">
|
||||
<span className="connections-recent-title">最近会话</span>
|
||||
<span className="connections-recent-title">{t('ssh.sidebar.recent')}</span>
|
||||
</div>
|
||||
{recent.map((conn) => (
|
||||
<button
|
||||
@@ -255,7 +255,7 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
|
||||
{isTerminal && (
|
||||
<section className="connections-section commands-sidebar-section">
|
||||
<div className="connections-section-head">
|
||||
<span className="connections-section-title">命令</span>
|
||||
<span className="connections-section-title">{t('panels.commands.sectionTitle')}</span>
|
||||
</div>
|
||||
<CommandsPanel onRun={onRunCommand ?? ((): void => undefined)} />
|
||||
</section>
|
||||
@@ -290,7 +290,7 @@ function Row({
|
||||
<div className="connections-row-main">
|
||||
<div className="connections-row-name">
|
||||
<span className="connections-row-label">{conn.name}</span>
|
||||
<Tooltip title={conn.host === conn.host ? `双击连接 ${conn.host}` : undefined}>
|
||||
<Tooltip title={conn.host === conn.host ? t('ssh.sidebar.dblClickConnect', { host: conn.host }) : undefined}>
|
||||
<AuthTags auth={conn.auth} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -300,19 +300,19 @@ function Row({
|
||||
</div>
|
||||
</div>
|
||||
<div className="connections-row-actions">
|
||||
<Button type="text" size="small" icon={<EditOutlined />} onClick={onEdit} aria-label="编辑" />
|
||||
<Button type="text" size="small" icon={<EditOutlined />} onClick={onEdit} aria-label={t('common.edit')} />
|
||||
<Popconfirm
|
||||
title="删除该连接?"
|
||||
description="连接配置删除后不可恢复。已保存的密钥数据会同步删除。"
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
title={t('ssh.sidebar.deleteTitle')}
|
||||
description={t('ssh.sidebar.deleteDesc')}
|
||||
okText={t('common.delete')}
|
||||
cancelText={t('common.cancel')}
|
||||
okButtonProps={{ danger: true }}
|
||||
open={confirming}
|
||||
onConfirm={onDelete}
|
||||
onOpenChange={(visible) => setConfirming(visible)}
|
||||
onCancel={() => setConfirming(false)}
|
||||
>
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined />} aria-label="删除" />
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined />} aria-label={t('common.delete')} />
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Form, Input, Switch } from 'antd'
|
||||
import useFormInstance from 'antd/es/form/hooks/useFormInstance'
|
||||
import { useWatch } from 'antd/es/form/Form'
|
||||
import type { SshAuthMethod } from '@shared/connections'
|
||||
import { t } from '@shared/i18n'
|
||||
|
||||
/**
|
||||
* Auth-method dependent fields rendered inside the edit dialog's <Form>.
|
||||
@@ -16,7 +17,7 @@ export function AuthFieldsRenderer({ auth }: { auth: SshAuthMethod }): React.JSX
|
||||
const askPassphraseAtConnect = useWatch('askPassphraseAtConnect', form) ?? false
|
||||
|
||||
if (auth === 'agent') {
|
||||
return <div className="connections-auth-desc">使用本机 SSH Agent 中已加载的密钥进行认证,无需在本应用内保存任何密钥。</div>
|
||||
return <div className="connections-auth-desc">{t('ssh.fields.agentDesc')}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -25,36 +26,36 @@ export function AuthFieldsRenderer({ auth }: { auth: SshAuthMethod }): React.JSX
|
||||
<>
|
||||
<SwitchRow
|
||||
name="askPasswordAtConnect"
|
||||
label="连接时询问密码"
|
||||
hint="开启后每次连接弹出对话框输入密码,不再使用已保存的密码"
|
||||
label={t('ssh.fields.askPasswordLabel')}
|
||||
hint={t('ssh.fields.askPasswordHint')}
|
||||
/>
|
||||
{!askPasswordAtConnect && (
|
||||
<Form.Item name="password" label="密码" className="connections-secret-item">
|
||||
<Input.Password placeholder="编辑时留空表示保留已保存的密码" autoComplete="off" />
|
||||
<Form.Item name="password" label={t('ssh.auth.password')} className="connections-secret-item">
|
||||
<Input.Password placeholder={t('ssh.fields.passwordPlaceholder')} autoComplete="off" />
|
||||
</Form.Item>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Form.Item name="keyPath" label="私钥路径(服务器上的本地私钥路径,可留空)">
|
||||
<Form.Item name="keyPath" label={t('ssh.fields.keyPath')}>
|
||||
<Input placeholder="/home/me/.ssh/id_rsa" autoComplete="off" />
|
||||
</Form.Item>
|
||||
<Form.Item name="keyContent" label="私钥内容">
|
||||
<Form.Item name="keyContent" label={t('ssh.fields.keyContent')}>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
placeholder="粘贴私钥内容;留空则使用上面的路径(编辑时留空表示保留已保存的私钥)"
|
||||
placeholder={t('ssh.fields.keyContentPlaceholder')}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Form.Item>
|
||||
<SwitchRow
|
||||
name="askPassphraseAtConnect"
|
||||
label="连接时询问口令"
|
||||
hint="开启后每次连接提示输入私钥口令"
|
||||
label={t('ssh.fields.askPassphraseLabel')}
|
||||
hint={t('ssh.fields.askPassphraseHint')}
|
||||
/>
|
||||
{!askPassphraseAtConnect && (
|
||||
<Form.Item name="passphrase" label="私钥口令" className="connections-secret-item">
|
||||
<Input.Password placeholder="编辑时留空表示保留已保存的口令" autoComplete="off" />
|
||||
<Form.Item name="passphrase" label={t('ssh.fields.passphrase')} className="connections-secret-item">
|
||||
<Input.Password placeholder={t('ssh.fields.passphrasePlaceholder')} autoComplete="off" />
|
||||
</Form.Item>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { SysinfoMeta, SysinfoSample } from '@shared/sysinfo'
|
||||
import { t } from '@shared/i18n'
|
||||
import './monitor.css'
|
||||
|
||||
export interface MonitorPanelProps {
|
||||
@@ -106,12 +107,12 @@ function formatSize(mb: number): string {
|
||||
/** human-readable uptime */
|
||||
function humanizeUptime(sec: number): string {
|
||||
const s = Math.floor(sec)
|
||||
if (s < 60) return `${s}秒`
|
||||
if (s < 60) return t('ssh.monitor.uptimeSeconds', { n: s })
|
||||
const mins = Math.floor(s / 60)
|
||||
if (s < 3600) return `${mins}分钟`
|
||||
if (s < 3600) return t('ssh.monitor.uptimeMinutes', { n: mins })
|
||||
const hours = Math.floor(s / 3600)
|
||||
if (s < 86400) return `${hours}小时${Math.floor((s % 3600) / 60)}分`
|
||||
return `${Math.floor(s / 86400)}天`
|
||||
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) })
|
||||
}
|
||||
|
||||
function Bar({ pct, color }: { pct: number; color: string }) {
|
||||
@@ -166,7 +167,7 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
|
||||
return (
|
||||
<div className="mm-root mm-empty">
|
||||
<span className="mm-dots"><span /> <span /> <span /></span>
|
||||
采集中…
|
||||
{t('ssh.monitor.collecting')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -180,7 +181,9 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
|
||||
|
||||
return (
|
||||
<div className={`mm-root${failed ? ' mm-failed' : ''}`}>
|
||||
{failed && <div className="mm-error">采集中断:{sample.error}</div>}
|
||||
{failed && (
|
||||
<div className="mm-error">{t('ssh.monitor.interrupted', { error: sample.error ?? '' })}</div>
|
||||
)}
|
||||
|
||||
<header className="mm-header">
|
||||
<span className="mm-host">{meta.hostname || 'unknown'}</span>
|
||||
@@ -191,19 +194,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"> · {cpu.cores} 核</span>
|
||||
{cpu.usage.toFixed(0)}%<span className="mm-value-sub"> · {t('ssh.monitor.cores', { n: cpu.cores })}</span>
|
||||
</span>
|
||||
</div>
|
||||
<Bar pct={cpu.usage} color={cpuColor(cpu.usage)} />
|
||||
<div className="mm-sub mm-load">
|
||||
负载 {cpu.loadavg.map((v) => v.toFixed(1)).join(' / ')}
|
||||
{t('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="内存">
|
||||
<section className="mm-block" aria-label={t('ssh.monitor.mem')}>
|
||||
<div className="mm-block-row">
|
||||
<span className="mm-label">内存</span>
|
||||
<span className="mm-label">{t('ssh.monitor.mem')}</span>
|
||||
<span className="mm-mem-size">
|
||||
{formatSize(mem.usedMb)} / {formatSize(mem.totalMb)}
|
||||
</span>
|
||||
@@ -217,9 +220,9 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
|
||||
</section>
|
||||
|
||||
{disks.length > 0 && (
|
||||
<section className="mm-block" aria-label="磁盘">
|
||||
<section className="mm-block" aria-label={t('ssh.monitor.disks')}>
|
||||
<div className="mm-block-row">
|
||||
<span className="mm-label">磁盘</span>
|
||||
<span className="mm-label">{t('ssh.monitor.disks')}</span>
|
||||
</div>
|
||||
<div className="mm-disks">
|
||||
{disks.slice(0, 6).map((d) => {
|
||||
@@ -242,9 +245,9 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="mm-block" aria-label="网络">
|
||||
<section className="mm-block" aria-label={t('ssh.monitor.net')}>
|
||||
<div className="mm-block-row">
|
||||
<span className="mm-label">网络</span>
|
||||
<span className="mm-label">{t('ssh.monitor.net')}</span>
|
||||
<div className="mm-net">
|
||||
<span className="mm-net-item mm-rx">
|
||||
<span className="mm-arrow mm-up">↓</span>
|
||||
@@ -265,7 +268,7 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
|
||||
</section>
|
||||
|
||||
<footer className="mm-footer">
|
||||
运行时间 <span className="mm-uptime">{humanizeUptime(sample.uptimeSec)}</span>
|
||||
{t('ssh.monitor.uptime')} <span className="mm-uptime">{humanizeUptime(sample.uptimeSec)}</span>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,55 +1,84 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Progress, Select, Switch, Tag } from 'antd'
|
||||
import { DEFAULT_LANGUAGE, t, type Language } from '@shared/i18n'
|
||||
import type { AppInfo, ReleaseNote, UpdateState, UpdateStatus } from '@shared/ipc'
|
||||
import { useSettingsStore } from './store'
|
||||
import appIconUrl from '../../../../build/icon.png'
|
||||
import changelogMd from '../../../../CHANGELOG.md?raw'
|
||||
import changelogZhCN from '../../../../CHANGELOG.md?raw'
|
||||
import changelogZhTW from '../../../../CHANGELOG.zh-TW.md?raw'
|
||||
import changelogEn from '../../../../CHANGELOG.en.md?raw'
|
||||
import changelogJa from '../../../../CHANGELOG.ja.md?raw'
|
||||
|
||||
const STATUS_TEXT: Record<UpdateStatus, string> = {
|
||||
idle: '点击检查更新',
|
||||
checking: '正在检查更新…',
|
||||
available: '发现新版本',
|
||||
latest: '已经是最新版本',
|
||||
downloading: '正在下载…',
|
||||
downloaded: '下载完成,可重启安装',
|
||||
error: '检查失败',
|
||||
dev: '开发模式不支持更新检查'
|
||||
/** Bundled changelog per language; zh-CN is the canonical source. */
|
||||
const CHANGELOGS: Record<Language, string> = {
|
||||
'zh-CN': changelogZhCN,
|
||||
'zh-TW': changelogZhTW,
|
||||
en: changelogEn,
|
||||
ja: changelogJa
|
||||
}
|
||||
|
||||
/** Parse the bundled CHANGELOG.md into release notes. Sections look like
|
||||
* `## v1.0.12 - 2026-09-20`; the body below the heading is kept verbatim. */
|
||||
function parseChangelog(md: string): ReleaseNote[] {
|
||||
const notes: ReleaseNote[] = []
|
||||
const STATUS_TEXT: Record<UpdateStatus, string> = {
|
||||
idle: 'settings.about.statusIdle',
|
||||
checking: 'settings.about.statusChecking',
|
||||
available: 'settings.about.statusAvailable',
|
||||
latest: 'settings.about.statusLatest',
|
||||
downloading: 'settings.about.statusDownloading',
|
||||
downloaded: 'settings.about.statusDownloaded',
|
||||
error: 'settings.about.statusError',
|
||||
dev: 'settings.about.statusDev'
|
||||
}
|
||||
|
||||
/** Parse a bundled changelog into release notes, keyed by version. Sections
|
||||
* look like `## v1.0.12 - 2026-09-20`; the body below is kept verbatim. */
|
||||
function parseChangelog(md: string): Map<string, ReleaseNote> {
|
||||
const notes = new Map<string, ReleaseNote>()
|
||||
for (const section of md.split(/^## /m).slice(1)) {
|
||||
const lineEnd = section.indexOf('\n')
|
||||
if (lineEnd < 0) continue
|
||||
const head = section.slice(0, lineEnd).trim()
|
||||
const m = /^(v[\d.]+)\s*-\s*(\d{4}-\d{2}-\d{2})/.exec(head)
|
||||
const body = section.slice(lineEnd + 1).trim()
|
||||
if (m && body) notes.push({ version: m[1], date: m[2], body })
|
||||
if (m && body) notes.set(m[1], { version: m[1], date: m[2], body })
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
/** 关于页:版本信息 + 更新检查/下载/安装 + 更新日志(内置 CHANGELOG 优先,网络兜底)。 */
|
||||
/** Notes for `language`, newest first, falling back to zh-CN per version so a
|
||||
* missing translation shows the canonical text instead of a gap. */
|
||||
function notesFor(language: Language): ReleaseNote[] {
|
||||
const primary = parseChangelog(CHANGELOGS[language])
|
||||
const fallback = language === DEFAULT_LANGUAGE ? primary : parseChangelog(CHANGELOGS[DEFAULT_LANGUAGE])
|
||||
const versions = new Set([...primary.keys(), ...fallback.keys()])
|
||||
return [...versions]
|
||||
.map((v) => primary.get(v) ?? fallback.get(v))
|
||||
.filter((n): n is ReleaseNote => n !== undefined)
|
||||
.sort((a, b) => b.version.localeCompare(a.version, undefined, { numeric: true }))
|
||||
}
|
||||
|
||||
/** 关于页:版本信息 + 更新检查/下载/安装 + 更新日志(内置多语言 CHANGELOG 优先,网络兜底)。 */
|
||||
export function AboutTab(): React.JSX.Element {
|
||||
const [version, setVersion] = useState('')
|
||||
const [state, setState] = useState<UpdateState | null>(null)
|
||||
const [notes, setNotes] = useState<ReleaseNote[]>([])
|
||||
const updateSystem = useSettingsStore((s) => s.updateSystem)
|
||||
const autoCheck = useSettingsStore((s) => s.settings.system.autoCheckUpdate !== false)
|
||||
const language = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE)
|
||||
|
||||
useEffect(() => {
|
||||
// Embedded changelog first (works offline, follows the UI language);
|
||||
// network sources as fallback.
|
||||
const local = notesFor(language)
|
||||
if (local.length > 0) setNotes(local)
|
||||
else void window.api.updateChangelog().then(setNotes).catch(() => undefined)
|
||||
}, [language])
|
||||
|
||||
useEffect(() => {
|
||||
void window.api.appInfo().then((i: AppInfo) => setVersion(i.appVersion))
|
||||
// Embedded changelog first (works offline); network sources as fallback.
|
||||
const local = parseChangelog(changelogMd)
|
||||
if (local.length > 0) setNotes(local)
|
||||
else void window.api.updateChangelog().then(setNotes).catch(() => undefined)
|
||||
return window.api.onUpdateState(setState)
|
||||
}, [])
|
||||
|
||||
const status = state?.status ?? 'idle'
|
||||
const statusText = STATUS_TEXT[status] + (state?.version ? ` v${state.version}` : '')
|
||||
const statusText = t(STATUS_TEXT[status]) + (state?.version ? ` v${state.version}` : '')
|
||||
|
||||
return (
|
||||
<div className="about-tab">
|
||||
@@ -58,7 +87,7 @@ export function AboutTab(): React.JSX.Element {
|
||||
<div>
|
||||
<div className="about-name">OpenTerminal</div>
|
||||
<div className="about-version">
|
||||
当前版本: {version || '…'}{' '}
|
||||
{t('settings.about.currentVersion')}: {version || '…'}{' '}
|
||||
<span className={'about-status' + (status === 'available' ? ' is-new' : '')}>
|
||||
{statusText}
|
||||
</span>
|
||||
@@ -70,7 +99,7 @@ export function AboutTab(): React.JSX.Element {
|
||||
<div className="about-controls">
|
||||
<Select
|
||||
value="stable"
|
||||
options={[{ value: 'stable', label: '稳定更新通道' }]}
|
||||
options={[{ value: 'stable', label: t('settings.about.channelStable') }]}
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
<label className="about-autocheck">
|
||||
@@ -79,26 +108,27 @@ export function AboutTab(): React.JSX.Element {
|
||||
checked={autoCheck}
|
||||
onChange={(checked) => void updateSystem({ autoCheckUpdate: checked })}
|
||||
/>
|
||||
启动时自动检查
|
||||
{t('settings.about.autoCheck')}
|
||||
</label>
|
||||
{status === 'available' && (
|
||||
<Button type="primary" onClick={() => void window.api.updateDownload()}>
|
||||
下载更新{state?.feed === 'github' ? '(GitHub 源)' : ''}
|
||||
{t('settings.about.download')}
|
||||
{state?.feed === 'github' ? t('settings.about.downloadGithub') : ''}
|
||||
</Button>
|
||||
)}
|
||||
{status === 'downloaded' && (
|
||||
<Button type="primary" onClick={() => void window.api.updateInstall()}>
|
||||
重启安装
|
||||
{t('settings.about.install')}
|
||||
</Button>
|
||||
)}
|
||||
{(status === 'idle' || status === 'latest' || status === 'error') && (
|
||||
<Button type="primary" onClick={() => void window.api.updateCheck()}>
|
||||
检查更新
|
||||
{t('settings.about.check')}
|
||||
</Button>
|
||||
)}
|
||||
{status === 'checking' && (
|
||||
<Button type="primary" loading disabled>
|
||||
检查中
|
||||
{t('settings.about.checkingButton')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -108,8 +138,10 @@ export function AboutTab(): React.JSX.Element {
|
||||
)}
|
||||
|
||||
<div className="about-changelog">
|
||||
<div className="about-changelog-title">更新日志</div>
|
||||
{notes.length === 0 && <div className="about-changelog-empty">暂无更新日志</div>}
|
||||
<div className="about-changelog-title">{t('settings.about.changelog')}</div>
|
||||
{notes.length === 0 && (
|
||||
<div className="about-changelog-empty">{t('settings.about.changelogEmpty')}</div>
|
||||
)}
|
||||
{notes.map((n) => (
|
||||
<div key={n.version} className="about-release">
|
||||
<div className="about-release-head">
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Tooltip
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { t } from '@shared/i18n'
|
||||
import { DEFAULT_HIGHLIGHT_RULES } from '@shared/settings'
|
||||
import type { HighlightRule } from '@shared/settings'
|
||||
import { useSettingsStore } from './store'
|
||||
@@ -31,7 +32,7 @@ function tryCompile(pattern: string): { ok: boolean; message?: string } {
|
||||
new RegExp(pattern)
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
return { ok: false, message: err instanceof Error ? err.message : '正则表达式无效' }
|
||||
return { ok: false, message: err instanceof Error ? err.message : t('settings.highlight.invalidRegex') }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +84,7 @@ export function HighlightTab(): React.JSX.Element {
|
||||
|
||||
const columns: ColumnsType<HighlightRule> = [
|
||||
{
|
||||
title: '启用',
|
||||
title: t('settings.highlight.enabled'),
|
||||
dataIndex: 'enabled',
|
||||
width: 64,
|
||||
align: 'center',
|
||||
@@ -92,13 +93,13 @@ export function HighlightTab(): React.JSX.Element {
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '正则',
|
||||
title: t('settings.highlight.pattern'),
|
||||
dataIndex: 'pattern',
|
||||
ellipsis: true,
|
||||
render: (pattern: string) => <PatternCell pattern={pattern} />
|
||||
},
|
||||
{
|
||||
title: '优先级',
|
||||
title: t('settings.highlight.priority'),
|
||||
dataIndex: 'priority',
|
||||
width: 76,
|
||||
align: 'center',
|
||||
@@ -107,7 +108,7 @@ export function HighlightTab(): React.JSX.Element {
|
||||
render: (priority: number) => <span className="hl-cell-priority">{priority}</span>
|
||||
},
|
||||
{
|
||||
title: '预览',
|
||||
title: t('settings.preview'),
|
||||
dataIndex: 'color',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
@@ -124,32 +125,32 @@ export function HighlightTab(): React.JSX.Element {
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
title: t('settings.highlight.note'),
|
||||
dataIndex: 'note',
|
||||
ellipsis: true,
|
||||
render: (note: string | undefined) =>
|
||||
note ? <span className="hl-cell-note">{note}</span> : <span className="hl-cell-empty">—</span>
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
title: t('settings.highlight.actions'),
|
||||
key: 'action',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
render: (_, record) => (
|
||||
<div className="hl-actions">
|
||||
<Button size="small" onClick={() => openEdit(record)}>
|
||||
编辑
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="删除高亮规则"
|
||||
description="删除后无法恢复,确定继续?"
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
title={t('settings.highlight.deleteTitle')}
|
||||
description={t('settings.deleteConfirmDesc')}
|
||||
okText={t('common.delete')}
|
||||
cancelText={t('common.cancel')}
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => void handleDelete(record.id)}
|
||||
>
|
||||
<Button size="small" danger>
|
||||
删除
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
@@ -163,20 +164,20 @@ export function HighlightTab(): React.JSX.Element {
|
||||
type="info"
|
||||
showIcon
|
||||
closable
|
||||
message="自定义高亮可增强显示效果,但会略微增加性能消耗。规则按优先级顺序应用。PS: 高亮在写入终端时注入颜色,对已有回滚内容不生效"
|
||||
message={t('settings.highlight.alert')}
|
||||
/>
|
||||
<div className="hl-toolbar">
|
||||
<Popconfirm
|
||||
title="恢复默认高亮规则"
|
||||
description="将替换当前全部规则为内置预设,不可恢复。"
|
||||
okText="恢复"
|
||||
cancelText="取消"
|
||||
title={t('settings.highlight.resetTitle')}
|
||||
description={t('settings.highlight.resetDesc')}
|
||||
okText={t('settings.highlight.resetOk')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={() => void handleReset()}
|
||||
>
|
||||
<Button>恢复默认</Button>
|
||||
<Button>{t('common.reset')}</Button>
|
||||
</Popconfirm>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
+ 新增规则
|
||||
{t('settings.highlight.addRule')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="hl-table">
|
||||
@@ -186,7 +187,7 @@ export function HighlightTab(): React.JSX.Element {
|
||||
columns={columns}
|
||||
dataSource={sorted}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '暂无高亮规则,点击「+ 新增规则」创建' }}
|
||||
locale={{ emptyText: t('settings.highlight.empty') }}
|
||||
/>
|
||||
</div>
|
||||
<HighlightEditor
|
||||
@@ -307,22 +308,22 @@ function HighlightEditor({
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
onOk={() => void handleSave()}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
okText={t('common.save')}
|
||||
cancelText={t('common.cancel')}
|
||||
confirmLoading={saving}
|
||||
destroyOnHidden
|
||||
width={560}
|
||||
title={isCreate ? '新建高亮规则' : '编辑高亮规则'}
|
||||
title={isCreate ? t('settings.highlight.createTitle') : t('settings.highlight.editTitle')}
|
||||
okButtonProps={{ disabled: !valid }}
|
||||
>
|
||||
<div className="hl-editor">
|
||||
<div className="hl-editor-row">
|
||||
<span className="hl-editor-label">正则</span>
|
||||
<span className="hl-editor-label">{t('settings.highlight.pattern')}</span>
|
||||
<div className="hl-editor-control">
|
||||
<Input.TextArea
|
||||
value={draft.pattern}
|
||||
onChange={(e) => patch({ pattern: e.target.value })}
|
||||
placeholder="例如 \b(ERROR|FAILED)\b"
|
||||
placeholder={t('settings.highlight.patternPlaceholder')}
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
className={compile.ok ? undefined : 'hl-editor-input-bad'}
|
||||
/>
|
||||
@@ -333,7 +334,7 @@ function HighlightEditor({
|
||||
</div>
|
||||
|
||||
<div className="hl-editor-row">
|
||||
<span className="hl-editor-label">优先级</span>
|
||||
<span className="hl-editor-label">{t('settings.highlight.priority')}</span>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={100}
|
||||
@@ -342,19 +343,19 @@ function HighlightEditor({
|
||||
if (v !== null) patch({ priority: v })
|
||||
}}
|
||||
/>
|
||||
<span className="hl-editor-hint">值越小越先应用</span>
|
||||
<span className="hl-editor-hint">{t('settings.highlight.priorityHint')}</span>
|
||||
</div>
|
||||
|
||||
<div className="hl-editor-row">
|
||||
<span className="hl-editor-label">颜色</span>
|
||||
<span className="hl-editor-label">{t('settings.highlight.color')}</span>
|
||||
<div className="hl-editor-control">
|
||||
<ColorField
|
||||
label="前景"
|
||||
label={t('settings.color.foreground')}
|
||||
value={draft.fg}
|
||||
onChange={(v) => patch({ fg: v })}
|
||||
/>
|
||||
<div className="hl-editor-inline">
|
||||
<span className="hl-editor-sub-label">背景</span>
|
||||
<span className="hl-editor-sub-label">{t('settings.color.background')}</span>
|
||||
{showBg ? (
|
||||
<>
|
||||
<ColorField
|
||||
@@ -362,12 +363,12 @@ function HighlightEditor({
|
||||
onChange={(v) => patch({ bg: v })}
|
||||
/>
|
||||
<Button size="small" onClick={() => patch({ bg: undefined })}>
|
||||
清空
|
||||
{t('settings.highlight.clearBg')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button size="small" onClick={() => setShowBg(true)}>
|
||||
添加
|
||||
{t('settings.highlight.addBg')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -375,9 +376,9 @@ function HighlightEditor({
|
||||
</div>
|
||||
|
||||
<div className="hl-editor-row">
|
||||
<span className="hl-editor-label">色板</span>
|
||||
<span className="hl-editor-label">{t('settings.highlight.palette')}</span>
|
||||
<div className="hl-palette">
|
||||
{showBg && <span className="hl-palette-tag">背景</span>}
|
||||
{showBg && <span className="hl-palette-tag">{t('settings.color.background')}</span>}
|
||||
{PRESET_COLORS.map((c) => (
|
||||
<PaletteSwatch
|
||||
key={c}
|
||||
@@ -390,17 +391,17 @@ function HighlightEditor({
|
||||
</div>
|
||||
|
||||
<div className="hl-editor-row">
|
||||
<span className="hl-editor-label">备注</span>
|
||||
<span className="hl-editor-label">{t('settings.highlight.note')}</span>
|
||||
<Input
|
||||
value={draft.note ?? ''}
|
||||
onChange={(e) => patch({ note: e.target.value })}
|
||||
placeholder="规则说明(可选)"
|
||||
placeholder={t('settings.highlight.notePlaceholder')}
|
||||
maxLength={60}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="hl-editor-row">
|
||||
<span className="hl-editor-label">启用</span>
|
||||
<span className="hl-editor-label">{t('settings.highlight.enabled')}</span>
|
||||
<Switch checked={draft.enabled} onChange={(c) => patch({ enabled: c })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Button, Modal, Tabs } from 'antd'
|
||||
import type { TabsProps } from 'antd'
|
||||
import { t } from '@shared/i18n'
|
||||
import { DEFAULT_SETTINGS } from '@shared/settings'
|
||||
import { useSettingsStore } from './store'
|
||||
import { CursorSettingsTab, FontSettingsTab, RenderSettingsTab, SystemSettingsTab } from './SettingsTabs'
|
||||
@@ -69,13 +70,13 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS
|
||||
}
|
||||
|
||||
const tabItems: TabsProps['items'] = [
|
||||
{ key: 'font', label: '字体', children: <FontSettingsTab /> },
|
||||
{ key: 'cursor', label: '光标与滚动', children: <CursorSettingsTab /> },
|
||||
{ key: 'render', label: '渲染与行为', children: <RenderSettingsTab /> },
|
||||
{ key: 'highlight', label: '高亮', children: <HighlightTab /> },
|
||||
{ key: 'font', label: t('settings.tabs.font'), children: <FontSettingsTab /> },
|
||||
{ key: 'cursor', label: t('settings.tabs.cursor'), children: <CursorSettingsTab /> },
|
||||
{ key: 'render', label: t('settings.tabs.render'), children: <RenderSettingsTab /> },
|
||||
{ key: 'highlight', label: t('settings.tabs.highlight'), children: <HighlightTab /> },
|
||||
{
|
||||
key: 'theme',
|
||||
label: '主题',
|
||||
label: t('settings.tabs.theme'),
|
||||
children: (
|
||||
<ThemeSettingsTab
|
||||
onEditTheme={(themeId) => setEditorState({ open: true, themeId })}
|
||||
@@ -83,8 +84,8 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS
|
||||
/>
|
||||
)
|
||||
},
|
||||
{ key: 'system', label: '系统', children: <SystemSettingsTab /> },
|
||||
{ key: 'about', label: '关于', children: <AboutTab /> }
|
||||
{ key: 'system', label: t('settings.tabs.system'), children: <SystemSettingsTab /> },
|
||||
{ key: 'about', label: t('settings.tabs.about'), children: <AboutTab /> }
|
||||
]
|
||||
|
||||
const closeEditor = (): void => setEditorState((prev) => (prev.open ? { ...prev, open: false } : prev))
|
||||
@@ -99,12 +100,12 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS
|
||||
onCancel={onClose}
|
||||
centered
|
||||
width={dialogSize.w}
|
||||
title="设置"
|
||||
title={t('settings.title')}
|
||||
footer={
|
||||
<div className="settings-footer">
|
||||
<Button onClick={handleResetDefaults}>恢复默认</Button>
|
||||
<Button onClick={handleResetDefaults}>{t('common.reset')}</Button>
|
||||
<Button type="primary" onClick={onClose}>
|
||||
关闭
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
@@ -115,8 +116,8 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS
|
||||
{/* Drag corner: resizes the dialog until it is closed. */}
|
||||
<span
|
||||
className="settings-resize"
|
||||
title="拖拽调整大小"
|
||||
aria-label="拖拽调整大小"
|
||||
title={t('settings.resizeHandle')}
|
||||
aria-label={t('settings.resizeHandle')}
|
||||
onMouseDown={startResize}
|
||||
/>
|
||||
<ThemeEditor
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Input, InputNumber, Radio, Select, Switch } from 'antd'
|
||||
import type { ThemeColors } from '@shared/theme'
|
||||
import { DEFAULT_LANGUAGE, LANGUAGES, t, type Language } from '@shared/i18n'
|
||||
import { useSettingsStore, useResolvedTheme } from './store'
|
||||
import {
|
||||
DEFAULT_FONT_STACK,
|
||||
@@ -57,17 +58,20 @@ export function FontSettingsTab(): React.JSX.Element {
|
||||
return (
|
||||
<div className="settings-pane">
|
||||
<SettingRow
|
||||
label="字体"
|
||||
desc="内置默认字体或系统等宽字体"
|
||||
label={t('settings.font.label')}
|
||||
desc={t('settings.font.desc')}
|
||||
control={
|
||||
<Select
|
||||
className="settings-select"
|
||||
showSearch
|
||||
placeholder="选择字体"
|
||||
placeholder={t('settings.font.placeholder')}
|
||||
value={selectedFont()}
|
||||
onChange={handleFontChange}
|
||||
options={[
|
||||
{ value: 'default', label: <span style={{ fontFamily: DEFAULT_FONT_STACK }}>默认</span> },
|
||||
{
|
||||
value: 'default',
|
||||
label: <span style={{ fontFamily: DEFAULT_FONT_STACK }}>{t('settings.font.default')}</span>
|
||||
},
|
||||
...fontOptions
|
||||
]}
|
||||
filterOption={(input, option) =>
|
||||
@@ -78,8 +82,8 @@ export function FontSettingsTab(): React.JSX.Element {
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="字号"
|
||||
desc="终端文字的像素大小"
|
||||
label={t('settings.font.size')}
|
||||
desc={t('settings.font.sizeDesc')}
|
||||
control={
|
||||
<InputNumber
|
||||
className="settings-input-number"
|
||||
@@ -93,8 +97,8 @@ export function FontSettingsTab(): React.JSX.Element {
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="字重"
|
||||
desc="400 为常规,700 为粗体"
|
||||
label={t('settings.font.weight')}
|
||||
desc={t('settings.font.weightDesc')}
|
||||
control={
|
||||
<Select
|
||||
className="settings-select"
|
||||
@@ -107,8 +111,8 @@ export function FontSettingsTab(): React.JSX.Element {
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="粗体字重"
|
||||
desc="粗体文字所用的字重"
|
||||
label={t('settings.font.boldWeight')}
|
||||
desc={t('settings.font.boldWeightDesc')}
|
||||
control={
|
||||
<Select
|
||||
className="settings-select"
|
||||
@@ -121,8 +125,8 @@ export function FontSettingsTab(): React.JSX.Element {
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="字间距"
|
||||
desc="字符之间的水平间距"
|
||||
label={t('settings.font.letterSpacing')}
|
||||
desc={t('settings.font.letterSpacingDesc')}
|
||||
control={
|
||||
<InputNumber
|
||||
className="settings-input-number"
|
||||
@@ -137,8 +141,8 @@ export function FontSettingsTab(): React.JSX.Element {
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="行高"
|
||||
desc="相邻两行之间的垂直距离"
|
||||
label={t('settings.font.lineHeight')}
|
||||
desc={t('settings.font.lineHeightDesc')}
|
||||
control={
|
||||
<InputNumber
|
||||
className="settings-input-number"
|
||||
@@ -171,8 +175,8 @@ export function CursorSettingsTab(): React.JSX.Element {
|
||||
return (
|
||||
<div className="settings-pane">
|
||||
<SettingRow
|
||||
label="光标闪烁"
|
||||
desc="光标是否周期性闪烁"
|
||||
label={t('settings.cursor.blink')}
|
||||
desc={t('settings.cursor.blinkDesc')}
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.terminal.cursorBlink}
|
||||
@@ -181,8 +185,8 @@ export function CursorSettingsTab(): React.JSX.Element {
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="光标样式"
|
||||
desc="光标的显示形状"
|
||||
label={t('settings.cursor.style')}
|
||||
desc={t('settings.cursor.styleDesc')}
|
||||
control={
|
||||
<Select
|
||||
className="settings-select"
|
||||
@@ -190,13 +194,13 @@ export function CursorSettingsTab(): React.JSX.Element {
|
||||
onChange={(v) => {
|
||||
if (v !== null) void updateTerminal({ cursorStyle: v })
|
||||
}}
|
||||
options={cursorStyleOptions}
|
||||
options={cursorStyleOptions()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="非活动光标样式"
|
||||
desc="窗口失焦时光标样式"
|
||||
label={t('settings.cursor.inactiveStyle')}
|
||||
desc={t('settings.cursor.inactiveStyleDesc')}
|
||||
control={
|
||||
<Select
|
||||
className="settings-select"
|
||||
@@ -204,13 +208,13 @@ export function CursorSettingsTab(): React.JSX.Element {
|
||||
onChange={(v) => {
|
||||
if (v !== null) void updateTerminal({ cursorInactiveStyle: v })
|
||||
}}
|
||||
options={inactiveCursorStyleOptions}
|
||||
options={inactiveCursorStyleOptions()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="滚动缓冲区"
|
||||
desc="屏幕上可回滚的行数"
|
||||
label={t('settings.cursor.scrollback')}
|
||||
desc={t('settings.cursor.scrollbackDesc')}
|
||||
control={
|
||||
<InputNumber
|
||||
className="settings-input-number"
|
||||
@@ -231,26 +235,27 @@ export function CursorSettingsTab(): React.JSX.Element {
|
||||
export function RenderSettingsTab(): React.JSX.Element {
|
||||
const settings = useSettingsStore((s) => s.settings)
|
||||
const updateTerminal = useSettingsStore((s) => s.updateTerminal)
|
||||
const rendererModes = rendererModeOptions()
|
||||
return (
|
||||
<div className="settings-pane">
|
||||
<div className="settings-block-label">
|
||||
渲染模式
|
||||
<span className="settings-block-hint">终端单元渲染加速方式,改动将应用到所有终端</span>
|
||||
{t('settings.render.modeLabel')}
|
||||
<span className="settings-block-hint">{t('settings.render.modeHint')}</span>
|
||||
</div>
|
||||
<Radio.Group
|
||||
className="settings-radio-group"
|
||||
value={settings.terminal.rendererMode}
|
||||
onChange={(e) => void updateTerminal({ rendererMode: e.target.value })}
|
||||
options={rendererModeOptions}
|
||||
options={rendererModes}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
/>
|
||||
<div className="settings-block-hint settings-render-hint">
|
||||
{rendererModeOptions.find((o) => o.value === settings.terminal.rendererMode)?.hint}
|
||||
{rendererModes.find((o) => o.value === settings.terminal.rendererMode)?.hint}
|
||||
</div>
|
||||
<SettingRow
|
||||
label="自动换行"
|
||||
desc="超出宽度时自动折行"
|
||||
label={t('settings.render.autoWrap')}
|
||||
desc={t('settings.render.autoWrapDesc')}
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.terminal.autoWrap}
|
||||
@@ -259,8 +264,8 @@ export function RenderSettingsTab(): React.JSX.Element {
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="选中即复制"
|
||||
desc="鼠标选中文字时自动复制"
|
||||
label={t('settings.render.copyOnSelect')}
|
||||
desc={t('settings.render.copyOnSelectDesc')}
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.terminal.copyOnSelect}
|
||||
@@ -269,8 +274,8 @@ export function RenderSettingsTab(): React.JSX.Element {
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="粘贴风险确认"
|
||||
desc="多行文本(或超长单行)粘贴前弹出确认;单行直接粘贴"
|
||||
label={t('settings.render.pasteRiskConfirm')}
|
||||
desc={t('settings.render.pasteRiskConfirmDesc')}
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.terminal.pasteRiskConfirm}
|
||||
@@ -279,8 +284,8 @@ export function RenderSettingsTab(): React.JSX.Element {
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="输入建议"
|
||||
desc="输入时弹出命令建议;Tab 接受,回车始终直接执行"
|
||||
label={t('settings.render.suggest')}
|
||||
desc={t('settings.render.suggestDesc')}
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.terminal.suggestEnabled}
|
||||
@@ -289,8 +294,8 @@ export function RenderSettingsTab(): React.JSX.Element {
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="记录命令历史"
|
||||
desc="关闭后不再记录新命令;已保存的历史仍然保留"
|
||||
label={t('settings.render.history')}
|
||||
desc={t('settings.render.historyDesc')}
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.terminal.historyEnabled}
|
||||
@@ -299,8 +304,8 @@ export function RenderSettingsTab(): React.JSX.Element {
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="历史条数上限"
|
||||
desc="命令历史最多保留多少条,重复命令只记一次"
|
||||
label={t('settings.render.historyLimit')}
|
||||
desc={t('settings.render.historyLimitDesc')}
|
||||
control={
|
||||
<InputNumber
|
||||
min={1}
|
||||
@@ -316,8 +321,8 @@ export function RenderSettingsTab(): React.JSX.Element {
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="终端工具栏 · 录制"
|
||||
desc="右上角显示「记录会话日志」按钮"
|
||||
label={t('settings.render.recButton')}
|
||||
desc={t('settings.render.recButtonDesc')}
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.terminal.showRecButton}
|
||||
@@ -326,8 +331,8 @@ export function RenderSettingsTab(): React.JSX.Element {
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="终端工具栏 · 打开日志目录"
|
||||
desc="右上角显示「打开日志目录」按钮"
|
||||
label={t('settings.render.openLogsButton')}
|
||||
desc={t('settings.render.openLogsButtonDesc')}
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.terminal.showOpenLogsButton}
|
||||
@@ -336,8 +341,8 @@ export function RenderSettingsTab(): React.JSX.Element {
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="终端工具栏 · 打开工作区目录"
|
||||
desc="右上角显示「打开当前目录」按钮(SSH 会话不显示;目录随 cd 实时跟踪,需开启 shell 集成或使用 cd 命令)"
|
||||
label={t('settings.render.openCwdButton')}
|
||||
desc={t('settings.render.openCwdButtonDesc')}
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.terminal.showOpenCwdButton}
|
||||
@@ -354,9 +359,20 @@ export function SystemSettingsTab(): React.JSX.Element {
|
||||
const updateSystem = useSettingsStore((s) => s.updateSystem)
|
||||
return (
|
||||
<div className="settings-pane">
|
||||
{/* Interface language — first row: the whole dialog re-renders on change. */}
|
||||
<div className="settings-block-label">{t('common.language')}</div>
|
||||
<Radio.Group
|
||||
className="settings-language-group"
|
||||
value={settings.system.language ?? DEFAULT_LANGUAGE}
|
||||
onChange={(e) => void updateSystem({ language: e.target.value as Language })}
|
||||
options={LANGUAGES.map((l) => ({ value: l.value, label: l.label }))}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
/>
|
||||
<div className="settings-block-hint settings-language-hint">{t('common.languageDesc')}</div>
|
||||
<SettingRow
|
||||
label="开机时自启动"
|
||||
desc="开机时自动启动软件(开发模式下注册的是开发版程序)"
|
||||
label={t('settings.system.launchAtLogin')}
|
||||
desc={t('settings.system.launchAtLoginDesc')}
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.system.launchAtLogin}
|
||||
@@ -365,8 +381,8 @@ export function SystemSettingsTab(): React.JSX.Element {
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="阻止系统休眠"
|
||||
desc="开启后 OpenTerminal 将阻止系统自动休眠;关闭则允许系统按系统设置休眠"
|
||||
label={t('settings.system.preventSleep')}
|
||||
desc={t('settings.system.preventSleepDesc')}
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.system.preventSleep}
|
||||
@@ -375,8 +391,8 @@ export function SystemSettingsTab(): React.JSX.Element {
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="启动时恢复上次会话"
|
||||
desc="下次启动恢复分屏布局,并让每个终端回到它上次所在的目录;关闭则每次都以单个终端启动"
|
||||
label={t('settings.system.restoreSession')}
|
||||
desc={t('settings.system.restoreSessionDesc')}
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.system.restoreSession !== false}
|
||||
@@ -385,8 +401,8 @@ export function SystemSettingsTab(): React.JSX.Element {
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="Shell 集成"
|
||||
desc="让 shell 主动上报工作目录(OSC 7),路径记忆更精确(含脚本内的 cd);会包装你的命令提示符,遇到提示符主题异常时关掉即可"
|
||||
label={t('settings.system.shellIntegration')}
|
||||
desc={t('settings.system.shellIntegrationDesc')}
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.system.shellIntegration === true}
|
||||
@@ -395,24 +411,24 @@ export function SystemSettingsTab(): React.JSX.Element {
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="关闭按钮行为"
|
||||
desc="点击窗口关闭按钮时的动作;最小化到托盘后可在托盘图标右键菜单中退出"
|
||||
label={t('settings.system.closeAction')}
|
||||
desc={t('settings.system.closeActionDesc')}
|
||||
control={
|
||||
<Select
|
||||
className="settings-select"
|
||||
value={settings.system.closeAction ?? 'tray'}
|
||||
onChange={(value) => void updateSystem({ closeAction: value })}
|
||||
options={[
|
||||
{ value: 'tray', label: '最小化到托盘' },
|
||||
{ value: 'exit', label: '直接退出' },
|
||||
{ value: 'ask', label: '每次询问' }
|
||||
{ value: 'tray', label: t('settings.system.closeActionTray') },
|
||||
{ value: 'exit', label: t('settings.system.closeActionExit') },
|
||||
{ value: 'ask', label: t('settings.system.closeActionAsk') }
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="全局唤起快捷键"
|
||||
desc="点击输入框后直接按下组合键;Esc 取消,退格清空禁用"
|
||||
label={t('settings.system.globalShortcut')}
|
||||
desc={t('settings.system.globalShortcutDesc')}
|
||||
control={
|
||||
<ShortcutInput
|
||||
value={settings.system.globalShowHide ?? ''}
|
||||
@@ -421,7 +437,7 @@ export function SystemSettingsTab(): React.JSX.Element {
|
||||
}
|
||||
/>
|
||||
<div className="settings-block-hint settings-shortcut-hint">
|
||||
配合全局键可在任何界面唤起/隐藏窗口;若注册失败(与其他软件冲突)则不生效,应用不会报错。
|
||||
{t('settings.system.globalShortcutHint')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -481,8 +497,8 @@ function ShortcutInput({ value, onChange }: { value: string; onChange: (v: strin
|
||||
<Input
|
||||
className={`settings-select shortcut-input${recording ? ' is-recording' : ''}`}
|
||||
readOnly
|
||||
value={recording ? '按下组合键…' : value}
|
||||
placeholder="点击后按下快捷键,留空禁用"
|
||||
value={recording ? t('settings.system.shortcutRecording') : value}
|
||||
placeholder={t('settings.system.shortcutPlaceholder')}
|
||||
onFocus={() => setRecording(true)}
|
||||
onBlur={() => setRecording(false)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -557,7 +573,7 @@ function TerminalPreview({
|
||||
}
|
||||
return (
|
||||
<div className="settings-preview" style={{ background: colors.background }}>
|
||||
<span className="settings-preview-title">预览</span>
|
||||
<span className="settings-preview-title">{t('settings.preview')}</span>
|
||||
<div className="settings-preview-grid" style={gridStyle}>
|
||||
{lines.map((l, i) => (
|
||||
<div key={i} className="settings-preview-line">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Button, ColorPicker, Popconfirm } from 'antd'
|
||||
import { t } from '@shared/i18n'
|
||||
import type { TerminalSettings } from '@shared/settings'
|
||||
import { DEFAULT_SETTINGS } from '@shared/settings'
|
||||
import type { TerminalTheme, ThemeColors } from '@shared/theme'
|
||||
@@ -49,8 +50,8 @@ export function ThemeSettingsTab({
|
||||
<div className="theme-pane">
|
||||
<div className="theme-accent-row">
|
||||
<div>
|
||||
<div className="settings-block-label">标签强调色</div>
|
||||
<div className="settings-block-hint">活动标签边框、侧栏指示条与 SSH 徽章的强调颜色</div>
|
||||
<div className="settings-block-label">{t('settings.theme.accent')}</div>
|
||||
<div className="settings-block-hint">{t('settings.theme.accentDesc')}</div>
|
||||
</div>
|
||||
<div className="theme-accent-controls">
|
||||
<ColorPicker
|
||||
@@ -61,33 +62,33 @@ export function ThemeSettingsTab({
|
||||
size="small"
|
||||
onClick={() => void updateTerminal({ tabAccentColor: DEFAULT_SETTINGS.terminal.tabAccentColor })}
|
||||
>
|
||||
恢复默认
|
||||
{t('common.reset')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="theme-gallery">
|
||||
{themeCards.map((t) => {
|
||||
const selected = t.id === activeTheme.id
|
||||
const isCustom = t.builtin === false
|
||||
{themeCards.map((theme) => {
|
||||
const selected = theme.id === activeTheme.id
|
||||
const isCustom = theme.builtin === false
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
key={theme.id}
|
||||
className={selected ? 'theme-card theme-card-selected' : 'theme-card'}
|
||||
onClick={() => void updateTerminal({ themeId: t.id })}
|
||||
title={t.name}
|
||||
onClick={() => void updateTerminal({ themeId: theme.id })}
|
||||
title={theme.name}
|
||||
>
|
||||
<div className="theme-card-bg" style={{ background: t.colors.background }} />
|
||||
<div className="theme-card-bg" style={{ background: theme.colors.background }} />
|
||||
<div className="theme-card-body">
|
||||
<div className="theme-card-name-row">
|
||||
<span className="theme-card-name">{t.name}</span>
|
||||
{isCustom && <span className="theme-card-badge">自定义</span>}
|
||||
<span className="theme-card-name">{theme.name}</span>
|
||||
{isCustom && <span className="theme-card-badge">{t('settings.theme.customBadge')}</span>}
|
||||
</div>
|
||||
<div className="theme-card-dots">
|
||||
{ANSI_KEYS.map((k) => (
|
||||
<span
|
||||
key={k}
|
||||
className="theme-card-dot"
|
||||
style={{ background: t.colors[k] }}
|
||||
style={{ background: theme.colors[k] }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -98,29 +99,29 @@ export function ThemeSettingsTab({
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEditTheme(t.id)
|
||||
onEditTheme(theme.id)
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="删除自定义主题"
|
||||
description="删除后无法恢复,确定继续?"
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
title={t('settings.theme.deleteTitle')}
|
||||
description={t('settings.deleteConfirmDesc')}
|
||||
okText={t('common.delete')}
|
||||
cancelText={t('common.cancel')}
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation()
|
||||
void handleDelete(t.id)
|
||||
void handleDelete(theme.id)
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="删除"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
删除
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</>
|
||||
@@ -133,7 +134,7 @@ export function ThemeSettingsTab({
|
||||
|
||||
<div className="theme-card theme-card-new" onClick={onCreateTheme}>
|
||||
<div className="theme-card-new-icon">+</div>
|
||||
<div className="theme-card-new-text">新建主题</div>
|
||||
<div className="theme-card-new-text">{t('settings.theme.create')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { t } from '@shared/i18n'
|
||||
import type { TerminalSettings } from '@shared/settings'
|
||||
|
||||
/** font stack shown as the persistent "默认" option */
|
||||
@@ -8,31 +9,35 @@ export const WEIGHT_OPTIONS = [100, 200, 300, 400, 500, 600, 700, 800, 900]
|
||||
|
||||
export const weightOptions = WEIGHT_OPTIONS.map((w) => ({ value: w, label: String(w) }))
|
||||
|
||||
export const cursorStyleOptions: Array<{ value: TerminalSettings['cursorStyle']; label: string }> = [
|
||||
{ value: 'bar', label: '竖线 (bar)' },
|
||||
{ value: 'block', label: '方块 (block)' },
|
||||
{ value: 'underline', label: '下划线 (underline)' }
|
||||
export const cursorStyleOptions = (): Array<{ value: TerminalSettings['cursorStyle']; label: string }> => [
|
||||
{ value: 'bar', label: t('settings.cursorStyle.bar') },
|
||||
{ value: 'block', label: t('settings.cursorStyle.block') },
|
||||
{ value: 'underline', label: t('settings.cursorStyle.underline') }
|
||||
]
|
||||
|
||||
export const inactiveCursorStyleOptions: Array<{
|
||||
export const inactiveCursorStyleOptions = (): Array<{
|
||||
value: TerminalSettings['cursorInactiveStyle']
|
||||
label: string
|
||||
}> = [
|
||||
{ value: 'outline', label: '轮廓 (outline)' },
|
||||
{ value: 'block', label: '方块 (block)' },
|
||||
{ value: 'bar', label: '竖线 (bar)' },
|
||||
{ value: 'underline', label: '下划线 (underline)' },
|
||||
{ value: 'none', label: '无 (none)' }
|
||||
}> => [
|
||||
{ value: 'outline', label: t('settings.cursorStyle.outline') },
|
||||
{ value: 'block', label: t('settings.cursorStyle.block') },
|
||||
{ value: 'bar', label: t('settings.cursorStyle.bar') },
|
||||
{ value: 'underline', label: t('settings.cursorStyle.underline') },
|
||||
{ value: 'none', label: t('settings.cursorStyle.none') }
|
||||
]
|
||||
|
||||
export const rendererModeOptions: Array<{
|
||||
export const rendererModeOptions = (): Array<{
|
||||
value: TerminalSettings['rendererMode']
|
||||
label: string
|
||||
hint: string
|
||||
}> = [
|
||||
{ value: 'auto', label: '自动', hint: '优先使用 WebGL 加速,失败时自动回退到 DOM 渲染' },
|
||||
{ value: 'webgl', label: '高性能 WebGL', hint: '强制使用 GPU 加速渲染,适合高刷新率与大行数滚动' },
|
||||
{ value: 'dom', label: '兼容 DOM', hint: '纯 CPU 渲染,兼容性最好,适合无 GPU 或远程桌面环境' }
|
||||
}> => [
|
||||
{ value: 'auto', label: t('settings.rendererMode.auto'), hint: t('settings.rendererMode.autoHint') },
|
||||
{
|
||||
value: 'webgl',
|
||||
label: t('settings.rendererMode.webgl'),
|
||||
hint: t('settings.rendererMode.webglHint')
|
||||
},
|
||||
{ value: 'dom', label: t('settings.rendererMode.dom'), hint: t('settings.rendererMode.domHint') }
|
||||
]
|
||||
|
||||
/** one label + control row inside a settings pane */
|
||||
|
||||
@@ -163,10 +163,10 @@
|
||||
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 45%, transparent);
|
||||
}
|
||||
|
||||
/* Button-style radio groups render as one joined strip (no gaps, shared
|
||||
borders) — matches the language picker and the reference UI. */
|
||||
.settings-radio-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
display: inline-flex;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@@ -175,6 +175,36 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Language picker: one joined strip (no gaps, shared borders) instead of the
|
||||
spread-out radio buttons used elsewhere. */
|
||||
.settings-language-group {
|
||||
display: inline-flex;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.settings-language-group .ant-radio-button-wrapper {
|
||||
min-width: 104px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Selected segment: the filled (primary) background needs a light label —
|
||||
antd's own checked colour loses to our text overrides, so pin it here. */
|
||||
.settings-radio-group .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled),
|
||||
.settings-language-group .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled) {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.settings-radio-group .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled):hover,
|
||||
.settings-language-group .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled):hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Own positive spacing: the shared `.settings-render-hint` uses a negative
|
||||
top margin that lands the text on top of the button strip here. */
|
||||
.settings-language-hint {
|
||||
margin: 6px 0 18px;
|
||||
}
|
||||
|
||||
.settings-render-hint {
|
||||
margin: -8px 0 16px;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ 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 './sftp.css'
|
||||
|
||||
/** Remote file browser for one SSH session (M4). */
|
||||
@@ -70,8 +71,14 @@ function fmtTime(ms: number): string {
|
||||
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const PERM_ROW_LABELS = ['所有者', '组', '其他'] as const
|
||||
const PERM_COL_LABELS = ['读', '写', '执行'] as const
|
||||
/** 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 permColLabels(): string[] {
|
||||
return [t('ssh.file.permColRead'), t('ssh.file.permColWrite'), t('ssh.file.permColExec')]
|
||||
}
|
||||
|
||||
interface PermissionModalProps {
|
||||
open: boolean
|
||||
@@ -89,6 +96,8 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
|
||||
const [gid, setGid] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const hasUidGid = entry != null && (entry.uid != null || entry.gid != null)
|
||||
const rowLabels = permRowLabels()
|
||||
const colLabels = permColLabels()
|
||||
|
||||
const modeStr = useMemo(() => {
|
||||
if (!entry) return { type: '-', str: '---------' }
|
||||
@@ -164,7 +173,7 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
|
||||
await window.api.chownRemote(sessionId, entry.path, u, g)
|
||||
}
|
||||
if (octalStr !== curOctal || ownerChanged) {
|
||||
message.success('已应用权限')
|
||||
message.success(t('ssh.file.permApplied'))
|
||||
}
|
||||
onClose()
|
||||
onSaved()
|
||||
@@ -178,13 +187,13 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`权限 · ${entry?.name ?? ''}`}
|
||||
title={t('ssh.file.permTitle', { name: entry?.name ?? '' })}
|
||||
open={open}
|
||||
onOk={onSave}
|
||||
onCancel={onClose}
|
||||
confirmLoading={saving}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
okText={t('common.save')}
|
||||
cancelText={t('common.cancel')}
|
||||
width={380}
|
||||
>
|
||||
<div className="sftp-perm">
|
||||
@@ -198,14 +207,14 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
|
||||
<div className="sftp-perm-grid">
|
||||
<div className="sftp-perm-grid-head">
|
||||
<span />
|
||||
{PERM_COL_LABELS.map((l) => (
|
||||
{colLabels.map((l) => (
|
||||
<span key={l}>{l}</span>
|
||||
))}
|
||||
</div>
|
||||
{PERM_ROW_LABELS.map((row, r) => (
|
||||
{rowLabels.map((row, r) => (
|
||||
<div className="sftp-perm-grid-row" key={row}>
|
||||
<span className="sftp-perm-grid-label">{row}</span>
|
||||
{PERM_COL_LABELS.map((_, c) => {
|
||||
{colLabels.map((_, c) => {
|
||||
const idx = r * 3 + c
|
||||
return (
|
||||
<Checkbox key={idx} checked={bits[idx]} onChange={() => toggleBit(idx)} />
|
||||
@@ -216,11 +225,11 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
|
||||
</div>
|
||||
|
||||
<div className="sftp-perm-octal-input">
|
||||
<span className="sftp-perm-label">八进制</span>
|
||||
<span className="sftp-perm-label">{t('ssh.file.octal')}</span>
|
||||
<Input
|
||||
value={octal}
|
||||
onChange={(e) => applyOctal(e.target.value)}
|
||||
placeholder="如 755"
|
||||
placeholder={t('ssh.file.octalPlaceholder')}
|
||||
addonAfter={bitsToModeStr(modeStr.type, bits)}
|
||||
/>
|
||||
</div>
|
||||
@@ -228,14 +237,22 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
|
||||
<div className="sftp-perm-own">
|
||||
<div className="sftp-perm-own-field">
|
||||
<span className="sftp-perm-label">UID</span>
|
||||
<Input value={uid} onChange={(e) => setUid(e.target.value)} placeholder={hasUidGid ? '不改则留空' : '0'} />
|
||||
<Input
|
||||
value={uid}
|
||||
onChange={(e) => setUid(e.target.value)}
|
||||
placeholder={hasUidGid ? t('ssh.file.keepEmpty') : '0'}
|
||||
/>
|
||||
</div>
|
||||
<div className="sftp-perm-own-field">
|
||||
<span className="sftp-perm-label">GID</span>
|
||||
<Input value={gid} onChange={(e) => setGid(e.target.value)} placeholder={hasUidGid ? '不改则留空' : '0'} />
|
||||
<Input
|
||||
value={gid}
|
||||
onChange={(e) => setGid(e.target.value)}
|
||||
placeholder={hasUidGid ? t('ssh.file.keepEmpty') : '0'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sftp-perm-hint">UID/GID 留空表示不修改属主</div>
|
||||
<div className="sftp-perm-hint">{t('ssh.file.ownerHint')}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
@@ -363,13 +380,16 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
|
||||
void (async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(entry.path)
|
||||
message.success('已复制路径')
|
||||
message.success(t('ssh.file.pathCopied'))
|
||||
} catch {
|
||||
message.error('复制路径失败')
|
||||
message.error(t('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
|
||||
@@ -377,51 +397,51 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
|
||||
const canAction = target != null
|
||||
return {
|
||||
items: [
|
||||
{ key: 'refresh', label: '刷新', icon: <ReloadOutlined /> },
|
||||
{ key: 'refresh', label: t('ssh.file.refresh'), icon: <ReloadOutlined /> },
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'open',
|
||||
label: isDir ? '打开' : '下载',
|
||||
label: isDir ? t('ssh.file.open') : t('ssh.file.download'),
|
||||
icon: isDir ? <FolderOpenOutlined /> : <DownloadOutlined />,
|
||||
disabled: !canAction
|
||||
},
|
||||
{
|
||||
key: 'download',
|
||||
label: '下载',
|
||||
label: t('ssh.file.download'),
|
||||
icon: <DownloadOutlined />,
|
||||
disabled: !canAction || Boolean(isDir)
|
||||
},
|
||||
{ key: 'upload', label: '上传…', icon: <UploadOutlined /> },
|
||||
{ key: 'upload', label: t('ssh.file.upload'), icon: <UploadOutlined /> },
|
||||
{
|
||||
key: 'rename',
|
||||
label: '重命名',
|
||||
label: t('ssh.file.rename'),
|
||||
icon: <FormOutlined />,
|
||||
disabled: !canAction
|
||||
},
|
||||
{
|
||||
type: 'submenu',
|
||||
key: 'new',
|
||||
label: '新建',
|
||||
label: t('ssh.file.new'),
|
||||
icon: <FileAddOutlined />,
|
||||
children: [
|
||||
{ key: 'mkdir', label: '新建文件夹', icon: <FolderAddOutlined /> }
|
||||
{ key: 'mkdir', label: t('ssh.file.newFolder'), icon: <FolderAddOutlined /> }
|
||||
// 新建文件:无现成 SFTP 创建文件 API(main 仅 mkdir/rename/chmod/chown),不做
|
||||
]
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'copyPath',
|
||||
label: '复制路径',
|
||||
label: t('ssh.file.copyPath'),
|
||||
icon: <CopyOutlined />,
|
||||
disabled: !canAction
|
||||
},
|
||||
{
|
||||
key: 'permission',
|
||||
label: '文件权限…',
|
||||
label: t('ssh.file.permission'),
|
||||
icon: <SafetyOutlined />,
|
||||
disabled: !canAction
|
||||
},
|
||||
{ key: 'delete', label: '删除', icon: <DeleteOutlined />, danger: true, disabled: !canAction }
|
||||
{ key: 'delete', label: t('common.delete'), icon: <DeleteOutlined />, danger: true, disabled: !canAction }
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
const entry = menuTarget
|
||||
@@ -447,11 +467,11 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
|
||||
setPermOpen(true)
|
||||
} else if (key === 'delete') {
|
||||
modal.confirm({
|
||||
title: '确认删除?',
|
||||
title: t('ssh.file.deleteTitle'),
|
||||
content: entry ? `${entry.name}(${entry.path})` : undefined,
|
||||
okText: '删除',
|
||||
okText: t('common.delete'),
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
cancelText: t('common.cancel'),
|
||||
onOk: () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
if (!entry) {
|
||||
@@ -471,12 +491,12 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [menuTarget, dir, refresh])
|
||||
}, [menuTarget, dir, refresh, language])
|
||||
|
||||
return (
|
||||
<div className="sftp-panel">
|
||||
<div className="sftp-toolbar">
|
||||
<Tooltip title="上一级">
|
||||
<Tooltip title={t('ssh.file.up')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
@@ -485,10 +505,10 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
|
||||
onClick={() => void refresh(parent)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="刷新">
|
||||
<Tooltip title={t('ssh.file.refresh')}>
|
||||
<Button type="text" size="small" icon={<ReloadOutlined />} onClick={() => void refresh(dir)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="新建文件夹">
|
||||
<Tooltip title={t('ssh.file.newFolder')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
@@ -499,10 +519,10 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="上传文件">
|
||||
<Tooltip title={t('ssh.file.uploadFile')}>
|
||||
<Button type="text" size="small" icon={<UploadOutlined />} onClick={doUpload} />
|
||||
</Tooltip>
|
||||
<Tooltip title="下载选中项">
|
||||
<Tooltip title={t('ssh.file.downloadSelected')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
@@ -511,7 +531,7 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
|
||||
onClick={() => selected && doDownload(selected)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="重命名">
|
||||
<Tooltip title={t('ssh.file.rename')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
@@ -523,7 +543,7 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="权限 / 属主">
|
||||
<Tooltip title={t('ssh.file.permOwner')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
@@ -532,7 +552,12 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
|
||||
onClick={() => setPermOpen(true)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm title="确认删除?" okText="删除" cancelText="取消" onConfirm={doDelete}>
|
||||
<Popconfirm
|
||||
title={t('ssh.file.deleteTitle')}
|
||||
okText={t('common.delete')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={doDelete}
|
||||
>
|
||||
<Button type="text" size="small" icon={<DeleteOutlined />} disabled={!selected} danger />
|
||||
</Popconfirm>
|
||||
</div>
|
||||
@@ -552,8 +577,8 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
|
||||
<div className="sftp-error">{error}</div>
|
||||
) : (
|
||||
<div className="sftp-list">
|
||||
{loading && entries.length === 0 && <div className="sftp-empty">加载中…</div>}
|
||||
{!loading && entries.length === 0 && <div className="sftp-empty">空目录</div>}
|
||||
{loading && entries.length === 0 && <div className="sftp-empty">{t('ssh.file.loading')}</div>}
|
||||
{!loading && entries.length === 0 && <div className="sftp-empty">{t('ssh.file.empty')}</div>}
|
||||
{entries.map((e) => (
|
||||
<Dropdown
|
||||
key={e.path}
|
||||
@@ -577,7 +602,7 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
|
||||
<span className="sftp-name" title={e.name}>
|
||||
{e.name}
|
||||
</span>
|
||||
<span className="sftp-mode" title={e.mode ?? '权限不可用'}>
|
||||
<span className="sftp-mode" title={e.mode ?? t('ssh.file.modeUnavailable')}>
|
||||
{e.mode ?? '----------'}
|
||||
</span>
|
||||
<span className="sftp-owner" title={`UID ${e.uid ?? '—'} / GID ${e.gid ?? '—'}`}>
|
||||
@@ -601,29 +626,29 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
|
||||
onSaved={() => void refresh(dir)}
|
||||
/>
|
||||
<Modal
|
||||
title="新建文件夹"
|
||||
title={t('ssh.file.newFolder')}
|
||||
open={mkdirOpen}
|
||||
onOk={doMkdir}
|
||||
onCancel={() => setMkdirOpen(false)}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
okText={t('ssh.file.create')}
|
||||
cancelText={t('common.cancel')}
|
||||
width={360}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="文件夹名称"
|
||||
placeholder={t('ssh.file.folderName')}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onPressEnter={doMkdir}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={`重命名 ${selected?.name ?? ''}`}
|
||||
title={t('ssh.file.renameTitle', { name: selected?.name ?? '' })}
|
||||
open={renameOpen}
|
||||
onOk={doRename}
|
||||
onCancel={() => setRenameOpen(false)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
okText={t('common.ok')}
|
||||
cancelText={t('common.cancel')}
|
||||
width={360}
|
||||
>
|
||||
<Input
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { CloseOutlined } from '@ant-design/icons'
|
||||
import type { TransferKind, TransferProgressEvent } from '@shared/sftp'
|
||||
import { t } from '@shared/i18n'
|
||||
|
||||
interface TransferState {
|
||||
kind: TransferKind
|
||||
@@ -75,27 +76,27 @@ export function TransferPanel(): React.JSX.Element | null {
|
||||
|
||||
return (
|
||||
<div className="sftp-transfer-panel">
|
||||
{list.map(([id, t]) => {
|
||||
const pct = t.totalBytes > 0 ? Math.min(100, Math.round((t.bytes / t.totalBytes) * 100)) : t.state === 'done' ? 100 : 0
|
||||
{list.map(([id, tr]) => {
|
||||
const pct = tr.totalBytes > 0 ? Math.min(100, Math.round((tr.bytes / tr.totalBytes) * 100)) : tr.state === 'done' ? 100 : 0
|
||||
return (
|
||||
<div key={id} className={`sftp-transfer sftp-transfer-${t.state}`}>
|
||||
<div key={id} className={`sftp-transfer sftp-transfer-${tr.state}`}>
|
||||
<div className="sftp-transfer-head">
|
||||
<span className="sftp-transfer-title">
|
||||
{t.state === 'error'
|
||||
? '传输失败'
|
||||
: t.state === 'cancelled'
|
||||
? '已取消'
|
||||
: t.state === 'done'
|
||||
? '传输完成'
|
||||
: t.kind === 'upload'
|
||||
? '上传中'
|
||||
: t.kind === 'download'
|
||||
? '下载中'
|
||||
: t.kind === 'zmodem-upload'
|
||||
? 'ZMODEM 上传中'
|
||||
: 'ZMODEM 下载中'}
|
||||
{tr.state === 'error'
|
||||
? t('panels.transfer.failed')
|
||||
: tr.state === 'cancelled'
|
||||
? t('panels.transfer.cancelled')
|
||||
: tr.state === 'done'
|
||||
? t('panels.transfer.done')
|
||||
: tr.kind === 'upload'
|
||||
? t('panels.transfer.uploading')
|
||||
: tr.kind === 'download'
|
||||
? t('panels.transfer.downloading')
|
||||
: tr.kind === 'zmodem-upload'
|
||||
? t('panels.transfer.zmodemUploading')
|
||||
: t('panels.transfer.zmodemDownloading')}
|
||||
</span>
|
||||
{(t.state === 'error' || t.state === 'done') && (
|
||||
{(tr.state === 'error' || tr.state === 'done') && (
|
||||
<button
|
||||
type="button"
|
||||
className="sftp-transfer-close"
|
||||
@@ -111,13 +112,13 @@ export function TransferPanel(): React.JSX.Element | null {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="sftp-transfer-file">{t.file || t.error || ''}</div>
|
||||
{t.state === 'running' && t.totalBytes > 0 && (
|
||||
<div className="sftp-transfer-file">{tr.file || tr.error || ''}</div>
|
||||
{tr.state === 'running' && tr.totalBytes > 0 && (
|
||||
<div className="sftp-transfer-bar">
|
||||
<div className="sftp-transfer-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
{t.error && <div className="sftp-transfer-err">{t.error}</div>}
|
||||
{tr.error && <div className="sftp-transfer-err">{tr.error}</div>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -8,6 +8,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 { getThemeById } from '@shared/theme'
|
||||
import type { CommandItem } from '@shared/commands'
|
||||
import type { TerminalSettings } from '@shared/settings'
|
||||
@@ -113,7 +114,7 @@ function SearchBar({ value, onChange, onPrev, onNext, onClose, enabled, result }
|
||||
className="term-searchbar-input"
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder="搜索"
|
||||
placeholder={t('terminal.search.placeholder')}
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
@@ -123,10 +124,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}>上一个</button>
|
||||
<button type="button" className="term-searchbar-btn" disabled={!enabled} onClick={onNext}>下一个</button>
|
||||
<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}>关闭</button>
|
||||
<button type="button" className="term-searchbar-btn" onClick={onClose}>{t('common.close')}</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -454,7 +455,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
dir === 1
|
||||
? addon.findNext(q, { decorations: SEARCH_DECORATIONS })
|
||||
: addon.findPrevious(q, { decorations: SEARCH_DECORATIONS })
|
||||
setResultInfo(found ? '已找到' : '未找到')
|
||||
setResultInfo(found ? t('terminal.search.found') : t('terminal.search.notFound'))
|
||||
}, [])
|
||||
|
||||
/** Record a submitted command line; cd-style lines also update cwd memory. */
|
||||
@@ -1122,7 +1123,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
onClick={() => runMenuAction(() => void copySelection())}
|
||||
>
|
||||
<CopyOutlined />
|
||||
<span className="term-menu-label">复制</span>
|
||||
<span className="term-menu-label">{t('common.copy')}</span>
|
||||
<kbd>Ctrl+Shift+C</kbd>
|
||||
</button>
|
||||
<button
|
||||
@@ -1132,23 +1133,23 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
onClick={() => runMenuAction(() => void pasteFromClipboard())}
|
||||
>
|
||||
<SnippetsOutlined />
|
||||
<span className="term-menu-label">粘贴</span>
|
||||
<span className="term-menu-label">{t('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">查找</span>
|
||||
<span className="term-menu-label">{t('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">全选</span>
|
||||
<span className="term-menu-label">{t('terminal.menu.selectAll')}</span>
|
||||
<kbd />
|
||||
</button>
|
||||
<button type="button" className="term-menu-item" onClick={() => runMenuAction(() => termRef.current?.clear())}>
|
||||
<ClearOutlined />
|
||||
<span className="term-menu-label">清屏</span>
|
||||
<span className="term-menu-label">{t('terminal.menu.clear')}</span>
|
||||
<kbd />
|
||||
</button>
|
||||
</div>
|
||||
@@ -1170,8 +1171,8 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
<button
|
||||
type="button"
|
||||
className={`term-rec-btn${recording ? ' is-recording' : ''}`}
|
||||
title={recording ? '停止记录会话日志' : '开始记录会话日志'}
|
||||
aria-label={recording ? '停止记录' : '开始记录'}
|
||||
title={recording ? t('terminal.rec.stopLog') : t('terminal.rec.startLog')}
|
||||
aria-label={recording ? t('terminal.rec.stop') : t('terminal.rec.start')}
|
||||
onClick={() => {
|
||||
if (recording) void handleLogStop()
|
||||
else void handleLogStart()
|
||||
@@ -1184,8 +1185,8 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
<button
|
||||
type="button"
|
||||
className="term-rec-btn"
|
||||
title="打开日志目录"
|
||||
aria-label="打开日志目录"
|
||||
title={t('terminal.rec.openLogs')}
|
||||
aria-label={t('terminal.rec.openLogs')}
|
||||
onClick={() => window.api.openLogsDir()}
|
||||
>
|
||||
<FolderOpenOutlined />
|
||||
@@ -1195,8 +1196,8 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
<button
|
||||
type="button"
|
||||
className="term-rec-btn"
|
||||
title="打开工作区目录"
|
||||
aria-label="打开工作区目录"
|
||||
title={t('terminal.rec.openCwd')}
|
||||
aria-label={t('terminal.rec.openCwd')}
|
||||
onClick={() => {
|
||||
const dir = getSessionCwd(sessionId)
|
||||
if (dir) void window.api.openDirectory(dir)
|
||||
@@ -1208,29 +1209,29 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
</div>
|
||||
<Modal
|
||||
open={paste !== null}
|
||||
title="确认粘贴"
|
||||
okText="粘贴"
|
||||
cancelText="取消"
|
||||
title={t('terminal.paste.title')}
|
||||
okText={t('common.paste')}
|
||||
cancelText={t('common.cancel')}
|
||||
width={420}
|
||||
maskClosable={false}
|
||||
onOk={acceptPaste}
|
||||
onCancel={cancelPaste}
|
||||
>
|
||||
<div className="term-paste-confirm">
|
||||
<p>检测到多行或潜在危险命令,确定要粘贴到终端吗?</p>
|
||||
<p>{t('terminal.paste.message')}</p>
|
||||
<Checkbox
|
||||
checked={paste?.noPrompt ?? false}
|
||||
onChange={(e) => setPaste((prev) => (prev ? { ...prev, noPrompt: e.target.checked } : prev))}
|
||||
>
|
||||
本次会话不再提示
|
||||
{t('terminal.paste.noPrompt')}
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
checked={paste?.disableDetection ?? false}
|
||||
onChange={(e) => setPaste((prev) => (prev ? { ...prev, disableDetection: e.target.checked } : prev))}
|
||||
>
|
||||
关闭后续粘贴检测
|
||||
{t('terminal.paste.disableDetection')}
|
||||
</Checkbox>
|
||||
<p className="term-paste-confirm-hint">可在通用终端设置中重新开启或切换检测策略。</p>
|
||||
<p className="term-paste-confirm-hint">{t('terminal.paste.hint')}</p>
|
||||
</div>
|
||||
</Modal>
|
||||
{/* Relative-positioned wrapper around the xterm surface: the suggestion
|
||||
@@ -1258,14 +1259,14 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="term-suggest-hint">↑/↓ 选择 · Tab 接受 · Esc 关闭</div>
|
||||
<div className="term-suggest-hint">{t('terminal.suggest.hint')}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="terminal-view-dock" ref={hostRef}>
|
||||
{dead && (
|
||||
<div className="term-dead-mask">
|
||||
<div>进程已退出 (代码 {exitCode})</div>
|
||||
<button type="button" className="term-dead-btn" onClick={() => onCloseRef.current()}>关闭</button>
|
||||
<div>{t('terminal.dead.message', { code: exitCode })}</div>
|
||||
<button type="button" className="term-dead-btn" onClick={() => onCloseRef.current()}>{t('common.close')}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Input, Modal } from 'antd'
|
||||
import { t } from '@shared/i18n'
|
||||
import type { ThemeColors, TerminalTheme } from '@shared/theme'
|
||||
import { DEFAULT_DARK, getThemeById } from '@shared/theme'
|
||||
import { useSettingsStore } from '../../settings/store'
|
||||
@@ -22,11 +23,11 @@ interface FieldSpec {
|
||||
label: string
|
||||
}
|
||||
|
||||
const HEADER_KEYS: FieldSpec[] = [
|
||||
{ key: 'foreground', label: '前景' },
|
||||
{ key: 'background', label: '背景' },
|
||||
{ key: 'cursor', label: '光标' },
|
||||
{ key: 'selectionBackground', label: '选区' }
|
||||
const headerKeys = (): FieldSpec[] => [
|
||||
{ key: 'foreground', label: t('settings.color.foreground') },
|
||||
{ key: 'background', label: t('settings.color.background') },
|
||||
{ key: 'cursor', label: t('settings.color.cursor') },
|
||||
{ key: 'selectionBackground', label: t('settings.color.selection') }
|
||||
]
|
||||
|
||||
const ANSI_KEYS: FieldSpec[] = [
|
||||
@@ -78,7 +79,7 @@ export function ThemeEditor({ open, onClose, themeId }: ThemeEditorProps): React
|
||||
} else {
|
||||
/** create mode: seed a fresh copy based on the currently active theme */
|
||||
const active = getThemeById(settings.terminal.themeId, settings.customThemes)
|
||||
setName(active.name + '(副本)')
|
||||
setName(active.name + t('settings.themeEditor.copySuffix'))
|
||||
setColors({ ...active.colors })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -122,27 +123,27 @@ export function ThemeEditor({ open, onClose, themeId }: ThemeEditorProps): React
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
onOk={handleSave}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
okText={t('common.save')}
|
||||
cancelText={t('common.cancel')}
|
||||
confirmLoading={saving}
|
||||
destroyOnHidden
|
||||
width={560}
|
||||
title={isCreate ? '新建主题' : '编辑主题'}
|
||||
title={isCreate ? t('settings.theme.create') : t('settings.theme.editTitle')}
|
||||
okButtonProps={{ disabled: name.trim() === '' }}
|
||||
>
|
||||
<div className="theme-editor">
|
||||
<div className="theme-editor-row">
|
||||
<span className="theme-editor-label">名称</span>
|
||||
<span className="theme-editor-label">{t('settings.themeEditor.name')}</span>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="主题名称"
|
||||
placeholder={t('settings.themeEditor.namePlaceholder')}
|
||||
maxLength={60}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="theme-editor-colors">
|
||||
{HEADER_KEYS.map((spec) => (
|
||||
{headerKeys().map((spec) => (
|
||||
<ColorField key={spec.key} spec={spec} value={colors[spec.key]} onChange={setColor} />
|
||||
))}
|
||||
<div className="theme-editor-divider" />
|
||||
@@ -152,7 +153,7 @@ export function ThemeEditor({ open, onClose, themeId }: ThemeEditorProps): React
|
||||
</div>
|
||||
|
||||
<div className="theme-editor-preview">
|
||||
<span className="theme-editor-preview-label">预览</span>
|
||||
<span className="theme-editor-preview-label">{t('settings.preview')}</span>
|
||||
<ThemePreviewSnippet colors={colors} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { Button, Input, Modal } from 'antd'
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import type { SshConnection, SshSecretOverride } from '@shared/connections'
|
||||
import { t } from '@shared/i18n'
|
||||
|
||||
/**
|
||||
* Connect-time gateways for one SSH connection attempt.
|
||||
@@ -54,9 +55,9 @@ export function ConnectFlow({ conn, phase, onConfirmed, onCancel }: ConnectFlowP
|
||||
return (
|
||||
<SecretModal
|
||||
key={key}
|
||||
title="输入连接密码"
|
||||
title={t('workspace.connect.passwordTitle')}
|
||||
description={`${conn.username}@${conn.host}:${conn.port}`}
|
||||
placeholder="密码"
|
||||
placeholder={t('workspace.connect.passwordPlaceholder')}
|
||||
value={password}
|
||||
onChange={(v) => setPassword(v)}
|
||||
onOk={() => onConfirmed({ password })}
|
||||
@@ -69,9 +70,9 @@ export function ConnectFlow({ conn, phase, onConfirmed, onCancel }: ConnectFlowP
|
||||
return (
|
||||
<SecretModal
|
||||
key={key}
|
||||
title="输入私钥口令"
|
||||
title={t('workspace.connect.passphraseTitle')}
|
||||
description={`${conn.username}@${conn.host}:${conn.port}`}
|
||||
placeholder="私钥口令"
|
||||
placeholder={t('workspace.connect.passphrasePlaceholder')}
|
||||
value={passphrase}
|
||||
onChange={(v) => setPassphrase(v)}
|
||||
onOk={() => onConfirmed({ passphrase })}
|
||||
@@ -85,7 +86,7 @@ export function ConnectFlow({ conn, phase, onConfirmed, onCancel }: ConnectFlowP
|
||||
<Modal
|
||||
key={key}
|
||||
open
|
||||
title="连接中…"
|
||||
title={t('workspace.connect.connecting')}
|
||||
closable={false}
|
||||
maskClosable={false}
|
||||
keyboard={false}
|
||||
@@ -95,7 +96,7 @@ export function ConnectFlow({ conn, phase, onConfirmed, onCancel }: ConnectFlowP
|
||||
<div className="connect-flow-connecting">
|
||||
<LoadingOutlined spin style={{ fontSize: 26 }} />
|
||||
<span className="connect-flow-connecting-text">
|
||||
正在连接 {conn?.name != null ? conn.name : '…'}
|
||||
{t('workspace.connect.connectingNamed', { name: conn?.name != null ? conn.name : '…' })}
|
||||
</span>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -137,8 +138,8 @@ function SecretModal({
|
||||
key={key}
|
||||
open
|
||||
title={title}
|
||||
okText="连接"
|
||||
cancelText="取消"
|
||||
okText={t('workspace.connect.action')}
|
||||
cancelText={t('common.cancel')}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
confirmLoading={submitting}
|
||||
@@ -169,7 +170,7 @@ export function ConnectSpinnerButton({ busy }: { busy: boolean }): React.JSX.Ele
|
||||
return busy ? (
|
||||
<Button size="small" type="text" disabled className="workspace-connect-busy-button">
|
||||
<LoadingOutlined spin />
|
||||
连接中…
|
||||
{t('workspace.connect.connecting')}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Alert, Button, Modal } from 'antd'
|
||||
import type { HostKeyPromptEvent } from '@shared/connections'
|
||||
import { t } from '@shared/i18n'
|
||||
|
||||
/**
|
||||
* Host key verification dialog. Shown one at a time (Workspace keeps a FIFO
|
||||
@@ -27,13 +28,13 @@ export function HostKeyModal({ event, onDecision }: HostKeyModalProps): React.JS
|
||||
open
|
||||
title={
|
||||
isChanged ? (
|
||||
<div className="hostkey-changed-title">主机指纹已变更!</div>
|
||||
<div className="hostkey-changed-title">{t('ssh.hostKey.changedTitle')}</div>
|
||||
) : (
|
||||
'未知主机'
|
||||
t('ssh.hostKey.unknownHost')
|
||||
)
|
||||
}
|
||||
okText="接受并连接"
|
||||
cancelText="拒绝"
|
||||
okText={t('ssh.hostKey.accept')}
|
||||
cancelText={t('ssh.hostKey.reject')}
|
||||
okButtonProps={isChanged ? { danger: true } : { type: 'primary' }}
|
||||
cancelButtonProps={{ danger: !isChanged }}
|
||||
onOk={() => onDecision('accept')}
|
||||
@@ -45,15 +46,17 @@ export function HostKeyModal({ event, onDecision }: HostKeyModalProps): React.JS
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="可能遭受中间人攻击"
|
||||
description="远程主机的密钥指纹与之前记录的不一致。如果这不是你本人更换了服务器密钥,可能有人正在冒充该主机。"
|
||||
message={t('ssh.hostKey.mitmTitle')}
|
||||
description={t('ssh.hostKey.mitmDesc')}
|
||||
/>
|
||||
) : (
|
||||
<div className="hostkey-desc">首次连接该主机,尚未记录其指纹。</div>
|
||||
<div className="hostkey-desc">{t('ssh.hostKey.firstConnect')}</div>
|
||||
)}
|
||||
|
||||
<div className="hostkey-target">主机:{target}</div>
|
||||
<div className="hostkey-fingerprint">指纹:{event.fingerprint}</div>
|
||||
<div className="hostkey-target">{t('ssh.hostKey.target', { target })}</div>
|
||||
<div className="hostkey-fingerprint">
|
||||
{t('ssh.hostKey.fingerprint', { fingerprint: event.fingerprint })}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
|
||||
import { t } from '@shared/i18n'
|
||||
import { FilePanel } from '../sftp/FilePanel'
|
||||
|
||||
export interface SshBottomPanelProps {
|
||||
@@ -55,7 +56,7 @@ export function SshBottomPanel({ sessionId, terminal }: SshBottomPanelProps): Re
|
||||
className="ssh-bottom-divider"
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="调整文件面板高度"
|
||||
aria-label={t('ssh.bottomPanel.resize')}
|
||||
onMouseDown={onDividerMouseDown}
|
||||
/>
|
||||
<div className="ssh-bottom-files" style={{ height: filesHeight }}>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { t } 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
|
||||
@@ -9,7 +11,7 @@ export function SshHostBadge({ label }: { label: string }): React.JSX.Element {
|
||||
<span
|
||||
className="ssh-host-badge"
|
||||
title={label}
|
||||
aria-label={`SSH 主机 ${label}`}
|
||||
aria-label={t('ssh.hostBadge.aria', { label })}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { IDockviewPanelProps } from 'dockview-react'
|
||||
import { Button } from 'antd'
|
||||
import { AreaChartOutlined } from '@ant-design/icons'
|
||||
|
||||
import { t } from '@shared/i18n'
|
||||
import { TerminalView } from '../terminal/TerminalView'
|
||||
import { MonitorPanel } from '../monitor/MonitorPanel'
|
||||
import { SshBottomPanel } from './SshBottomPanel'
|
||||
@@ -78,8 +79,8 @@ export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps
|
||||
size="small"
|
||||
className="workspace-terminal-monitor-toggle"
|
||||
icon={<AreaChartOutlined />}
|
||||
aria-label={sideOpen ? '收起侧边栏' : '展开侧边栏'}
|
||||
title={sideOpen ? '收起侧边栏' : '展开侧边栏'}
|
||||
aria-label={sideOpen ? t('workspace.panel.hideSidebar') : t('workspace.panel.showSidebar')}
|
||||
title={sideOpen ? t('workspace.panel.hideSidebar') : t('workspace.panel.showSidebar')}
|
||||
onClick={() => setSideOpen((prev) => !prev)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { IDockviewPanelHeaderProps } from 'dockview-react'
|
||||
|
||||
import { useBroadcastStore } from './broadcastStore'
|
||||
import { SshHostBadge } from './SshHostBadge'
|
||||
import { t } from '@shared/i18n'
|
||||
import type { LayoutMeta } from '@shared/ipc'
|
||||
|
||||
export type TerminalTabProps = IDockviewPanelHeaderProps<{
|
||||
@@ -35,7 +36,7 @@ export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Elemen
|
||||
? hostLabel && hostLabel !== title
|
||||
? `${title} — ${hostLabel}`
|
||||
: (hostLabel || title)
|
||||
: '本地终端'
|
||||
: t('workspace.tab.localTerminal')
|
||||
// M6 broadcast: show a badge on tabs that are current broadcast targets.
|
||||
const isBroadcastTarget = useBroadcastStore((state) => state.targets.has(params?.sessionId ?? ''))
|
||||
|
||||
@@ -54,9 +55,9 @@ export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Elemen
|
||||
|
||||
const menu: MenuProps = {
|
||||
items: [
|
||||
{ key: 'close', label: '关闭' },
|
||||
{ key: 'closeOthers', label: '关闭其他', disabled: panel.panels.length <= 1 },
|
||||
{ key: 'closeRight', label: '关闭右侧', disabled: selfIndex === -1 || selfIndex === panel.panels.length - 1 }
|
||||
{ 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 }
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'close') {
|
||||
@@ -85,16 +86,16 @@ export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Elemen
|
||||
{isBroadcastTarget && (
|
||||
<span
|
||||
className="workspace-terminal-tab-bcast"
|
||||
title="广播目标"
|
||||
aria-label="广播目标"
|
||||
title={t('workspace.tab.broadcastTarget')}
|
||||
aria-label={t('workspace.tab.broadcastTarget')}
|
||||
>
|
||||
播
|
||||
{t('workspace.tab.broadcastBadge')}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="workspace-terminal-tab-close"
|
||||
aria-label="关闭"
|
||||
aria-label={t('common.close')}
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
@@ -144,17 +145,17 @@ export function SaveTemplateModal({
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="保存布局模板"
|
||||
title={t('workspace.template.saveTitle')}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={handleOk}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
okText={t('common.save')}
|
||||
cancelText={t('common.cancel')}
|
||||
confirmLoading={saving}
|
||||
okButtonProps={{ disabled: !name.trim() }}
|
||||
>
|
||||
<Input
|
||||
placeholder="模板名称"
|
||||
placeholder={t('workspace.template.namePlaceholder')}
|
||||
value={name}
|
||||
autoFocus
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
@@ -210,9 +211,9 @@ export function ApplyTemplateModal({
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="应用布局模板" open={open} onCancel={onClose} footer={null} width={420}>
|
||||
<Modal title={t('workspace.template.applyTitle')} open={open} onCancel={onClose} footer={null} width={420}>
|
||||
{items.length === 0 ? (
|
||||
<div className="workspace-template-empty">暂无已保存的模板</div>
|
||||
<div className="workspace-template-empty">{t('workspace.template.empty')}</div>
|
||||
) : (
|
||||
<ul className="workspace-template-list">
|
||||
{items.map((meta) => (
|
||||
@@ -229,16 +230,16 @@ export function ApplyTemplateModal({
|
||||
loading={busyId === meta.id}
|
||||
onClick={() => void handleApply(meta)}
|
||||
>
|
||||
应用
|
||||
{t('workspace.template.apply')}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="删除该模板?"
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
title={t('workspace.template.deleteConfirm')}
|
||||
okText={t('common.delete')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={() => void handleDelete(meta)}
|
||||
>
|
||||
<Button size="small" danger disabled={busyId === meta.id}>
|
||||
删除
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
import 'dockview-react/dist/styles/dockview.css'
|
||||
|
||||
import type { HostKeyPromptEvent, SshConnection, SshSecretOverride } from '@shared/connections'
|
||||
import { t } from '@shared/i18n'
|
||||
import { ConnectionSidebar } from '../connections/ConnectionSidebar'
|
||||
import type { ConnectionSidebarHandle } from '../connections/ConnectionSidebar'
|
||||
import { ConnectFlow } from './ConnectFlow'
|
||||
@@ -68,13 +69,21 @@ const TERMINAL_TAB_COMPONENT = 'terminal-tab'
|
||||
*/
|
||||
function nextTerminalTitle(existing: Iterable<string | undefined>): string {
|
||||
const used = new Set<number>()
|
||||
// The pattern is translated, so the number is read back through that same
|
||||
// pattern (the `{n}` placeholder marks the digits) instead of a fixed one.
|
||||
const [head, tail] = t('workspace.tab.terminalTitle', { n: '\u0000' }).split('\u0000')
|
||||
const matcher = new RegExp(`^${escapeRegExp(head)}(\\d+)${escapeRegExp(tail ?? '')}$`)
|
||||
for (const title of existing) {
|
||||
const n = title?.match(/^终端 (\d+)$/)?.[1]
|
||||
const n = title?.match(matcher)?.[1]
|
||||
if (n) used.add(Number(n))
|
||||
}
|
||||
let n = 1
|
||||
while (used.has(n)) n++
|
||||
return `终端 ${n}`
|
||||
return t('workspace.tab.terminalTitle', { n })
|
||||
}
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
function sessionIdOf(panel: IDockviewPanel | undefined): string | undefined {
|
||||
@@ -184,7 +193,7 @@ function createTabActions(mode: WorkspaceMode): (props: IDockviewHeaderActionsPr
|
||||
const { message } = App.useApp()
|
||||
const handleAdd = (): void => {
|
||||
if (mode === 'ssh') {
|
||||
message.info('SSH 会话请从服务器列表连接')
|
||||
message.info(t('workspace.hint.sshFromList'))
|
||||
return
|
||||
}
|
||||
void (async (): Promise<void> => {
|
||||
@@ -665,7 +674,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
// B's sidebar refreshes lastConnectedAt on connect.
|
||||
void sidebarRef.current?.refresh()
|
||||
} catch (error) {
|
||||
message.error(typeof error === 'string' ? error : 'SSH 连接失败')
|
||||
message.error(typeof error === 'string' ? error : t('workspace.connect.failed'))
|
||||
} finally {
|
||||
setConnecting(false)
|
||||
setRequestedConn(null)
|
||||
@@ -714,7 +723,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
const handleRailNewTerminal = useCallback((): void => {
|
||||
const mode = useWorkspaceModeStore.getState().mode
|
||||
if (mode === 'ssh') {
|
||||
message.info('SSH 会话请从服务器列表连接')
|
||||
message.info(t('workspace.hint.sshFromList'))
|
||||
return
|
||||
}
|
||||
void addTerminal()
|
||||
@@ -732,7 +741,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
const current = sshApiRef.current
|
||||
const hasSession = current ? current.panels.some((p) => sessionIdOf(p)) : false
|
||||
if (!hasSession) {
|
||||
message.info('请先连接 SSH 服务器')
|
||||
message.info(t('workspace.hint.connectSshFirst'))
|
||||
return
|
||||
}
|
||||
const reference = activeSshPanelRef.current
|
||||
@@ -947,12 +956,12 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
(cmd: string): void => {
|
||||
const panel = activePanelRef.current
|
||||
if (!panel) {
|
||||
message.info('没有激活的终端:请至少打开一个本地终端或 SSH 会话')
|
||||
message.info(t('workspace.hint.noActiveTerminal'))
|
||||
return
|
||||
}
|
||||
const sid = sessionIdOf(panel)
|
||||
if (!sid) {
|
||||
message.info('当前激活面板没有可用的会话')
|
||||
message.info(t('workspace.hint.noSessionInPanel'))
|
||||
return
|
||||
}
|
||||
writeBroadcast(sid, `${cmd}\r`)
|
||||
@@ -963,11 +972,11 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
|
||||
const layoutMenu: MenuProps = {
|
||||
items: [
|
||||
{ key: 'split-right', label: '水平分屏' },
|
||||
{ key: 'split-below', label: '垂直分屏' },
|
||||
{ key: 'split-right', label: t('workspace.layout.splitRight') },
|
||||
{ key: 'split-below', label: t('workspace.layout.splitBelow') },
|
||||
{ type: 'divider' },
|
||||
{ key: 'save', label: '保存为模板' },
|
||||
{ key: 'apply', label: '应用模板' }
|
||||
{ key: 'save', label: t('workspace.layout.saveTemplate') },
|
||||
{ key: 'apply', label: t('workspace.layout.applyTemplate') }
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'split-right') handleSplit('right')
|
||||
@@ -1022,14 +1031,14 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
{terminalCount === 0 && (
|
||||
<div className="workspace-empty-overlay">
|
||||
<div className="workspace-empty-inner">
|
||||
<div className="workspace-empty-title">暂无终端</div>
|
||||
<div className="workspace-empty-title">{t('workspace.empty.terminalTitle')}</div>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleNewTerminal}
|
||||
>
|
||||
新建终端
|
||||
{t('workspace.newTerminal')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1056,8 +1065,8 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
{sshCount === 0 && (
|
||||
<div className="workspace-empty-overlay">
|
||||
<div className="workspace-empty-inner">
|
||||
<div className="workspace-empty-title">暂无 SSH 会话</div>
|
||||
<div className="workspace-empty-hint">请从左侧服务器列表连接</div>
|
||||
<div className="workspace-empty-title">{t('workspace.empty.sshTitle')}</div>
|
||||
<div className="workspace-empty-hint">{t('workspace.empty.sshHint')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1102,8 +1111,8 @@ function AddTerminalGroupButton({ onClick }: { onClick: () => void }): React.JSX
|
||||
<button
|
||||
type="button"
|
||||
className="workspace-tabbar-add"
|
||||
title="在此分屏中新建终端"
|
||||
aria-label="在此分屏中新建终端"
|
||||
title={t('workspace.tab.addInSplit')}
|
||||
aria-label={t('workspace.tab.addInSplit')}
|
||||
onClick={onClick}
|
||||
>
|
||||
<PlusOutlined />
|
||||
@@ -1144,8 +1153,8 @@ function BroadcastToggleButton(): React.JSX.Element {
|
||||
<button
|
||||
type="button"
|
||||
className={cls}
|
||||
title={enabled ? `广播中 (${targets.size} 个目标)` : '广播输入'}
|
||||
aria-label="广播输入"
|
||||
title={enabled ? t('workspace.broadcast.activeTitle', { count: targets.size }) : t('workspace.broadcast.toggle')}
|
||||
aria-label={t('workspace.broadcast.toggle')}
|
||||
aria-pressed={enabled}
|
||||
>
|
||||
<ShareAltOutlined />
|
||||
@@ -1179,21 +1188,27 @@ function WorkspaceBroadcastPopover({
|
||||
`${s.title}${s.isSsh ? ' (SSH)' : ''}`
|
||||
return (
|
||||
<div className="workspace-bcast-pop">
|
||||
<div className="workspace-bcast-pop-mode">广播输入({mode === 'ssh' ? 'SSH' : '终端'})</div>
|
||||
<div className="workspace-bcast-pop-mode">
|
||||
{mode === 'ssh' ? t('workspace.broadcast.popoverSsh') : t('workspace.broadcast.popoverTerminal')}
|
||||
</div>
|
||||
<div className="workspace-bcast-pop-switch">
|
||||
<span className="workspace-bcast-pop-label">广播开关</span>
|
||||
<span className="workspace-bcast-pop-label">{t('workspace.broadcast.switch')}</span>
|
||||
<Checkbox
|
||||
checked={enabled}
|
||||
disabled={targets.size < 2}
|
||||
onChange={(e) => onSetEnabled(e.target.checked)}
|
||||
>
|
||||
{enabled ? `已启用 (${targets.size})` : targets.size < 2 ? '需至少选择 2 个目标' : '未启用'}
|
||||
{enabled
|
||||
? t('workspace.broadcast.enabled', { count: targets.size })
|
||||
: targets.size < 2
|
||||
? t('workspace.broadcast.needTwo')
|
||||
: t('workspace.broadcast.disabled')}
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div className="workspace-bcast-pop-list">
|
||||
{groupSessions.length === 0 ? (
|
||||
<div className="workspace-bcast-pop-empty">
|
||||
{mode === 'ssh' ? '暂无 SSH 服务器会话' : '暂无终端'}
|
||||
{mode === 'ssh' ? t('workspace.broadcast.emptySsh') : t('workspace.broadcast.emptyTerminal')}
|
||||
</div>
|
||||
) : (
|
||||
groupSessions.map((s) => (
|
||||
@@ -1205,7 +1220,7 @@ function WorkspaceBroadcastPopover({
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="workspace-bcast-pop-hint">选择 ≥2 个终端后广播自动开启;关闭任意一个使目标少于 2 个时自动关闭。仅当前模式组的会话可被勾选。</div>
|
||||
<div className="workspace-bcast-pop-hint">{t('workspace.broadcast.hint')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1234,22 +1249,22 @@ function IconRail({
|
||||
return (
|
||||
<div className="workspace-rail">
|
||||
<RailButton
|
||||
title={sidebarOpen ? '收起侧栏' : '展开侧栏'}
|
||||
title={sidebarOpen ? t('workspace.rail.hideSidebar') : t('workspace.rail.showSidebar')}
|
||||
icon={sidebarOpen ? <MenuFoldOutlined /> : <MenuUnfoldOutlined />}
|
||||
onClick={onToggleSidebar}
|
||||
/>
|
||||
<div className="workspace-rail-divider" />
|
||||
<RailButton title="新建终端" icon={<PlusOutlined />} onClick={onNewTerminal} />
|
||||
<RailButton title={t('workspace.newTerminal')} icon={<PlusOutlined />} onClick={onNewTerminal} />
|
||||
<LayoutFlyout menu={layoutMenu} />
|
||||
<div className="workspace-rail-divider" />
|
||||
<ModeRailButtons />
|
||||
<div className="workspace-rail-spacer" />
|
||||
{connecting && (
|
||||
<Tooltip title="正在连接…" placement="right">
|
||||
<Tooltip title={t('workspace.rail.connecting')} placement="right">
|
||||
<span className="workspace-rail-busy" />
|
||||
</Tooltip>
|
||||
)}
|
||||
<RailButton title="设置" icon={<SettingOutlined />} onClick={onOpenSettings} />
|
||||
<RailButton title={t('workspace.rail.settings')} icon={<SettingOutlined />} onClick={onOpenSettings} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1261,13 +1276,13 @@ function ModeRailButtons(): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<RailButton
|
||||
title="终端工作区"
|
||||
title={t('workspace.rail.terminalWorkspace')}
|
||||
icon={<CodeOutlined />}
|
||||
active={mode === 'terminal'}
|
||||
onClick={() => setMode('terminal')}
|
||||
/>
|
||||
<RailButton
|
||||
title="SSH 工作区"
|
||||
title={t('workspace.rail.sshWorkspace')}
|
||||
icon={<CloudServerOutlined />}
|
||||
active={mode === 'ssh'}
|
||||
onClick={() => setMode('ssh')}
|
||||
@@ -1310,11 +1325,11 @@ function LayoutFlyout({ menu }: { menu: MenuProps }): React.JSX.Element {
|
||||
|
||||
return (
|
||||
<div className="workspace-flyout-anchor" ref={rootRef}>
|
||||
<Tooltip title={open ? '' : '布局'} placement="right">
|
||||
<Tooltip title={open ? '' : t('workspace.rail.layout')} placement="right">
|
||||
<button
|
||||
type="button"
|
||||
className={'workspace-rail-btn' + (open ? ' is-active' : '')}
|
||||
aria-label="布局"
|
||||
aria-label={t('workspace.rail.layout')}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { App as AntdApp, Button, Modal, Space, Typography } from 'antd'
|
||||
import type { ZmodemOfferEvent, ZmodemResponse } from '@shared/ipc'
|
||||
import { t } from '@shared/i18n'
|
||||
|
||||
/**
|
||||
* M6 ZMODEM offer/done UX: renders one dialog per active zmodem offer.
|
||||
@@ -82,8 +83,11 @@ export default function ZmodemOffers(): null | React.JSX.Element {
|
||||
|
||||
const offDone = useCallback(
|
||||
(evt: { id: string; ok: boolean; message?: string }) => {
|
||||
if (evt.ok) message.success('ZMODEM 传输完成')
|
||||
else message.error(`ZMODEM 传输失败:${evt.message ?? '未知错误'}`)
|
||||
if (evt.ok) message.success(t('ssh.zmodem.done'))
|
||||
else
|
||||
message.error(
|
||||
t('ssh.zmodem.failed', { message: evt.message ?? t('ssh.zmodem.unknownError') })
|
||||
)
|
||||
},
|
||||
[message]
|
||||
)
|
||||
@@ -103,23 +107,21 @@ export default function ZmodemOffers(): null | React.JSX.Element {
|
||||
closable={false}
|
||||
maskClosable={false}
|
||||
keyboard={false}
|
||||
title={isReceive ? '下载到本地' : '上传到服务器'}
|
||||
title={isReceive ? t('ssh.zmodem.titleReceive') : t('ssh.zmodem.titleSend')}
|
||||
footer={
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => (isReceive ? void handlePickDir(first.id) : void handlePickFiles(first.id))}
|
||||
>
|
||||
{isReceive ? '选择保存目录' : '选择文件'}
|
||||
{isReceive ? t('ssh.zmodem.pickDir') : t('ssh.zmodem.pickFiles')}
|
||||
</Button>
|
||||
<Button onClick={() => handleCancel(first.id)}>取消</Button>
|
||||
<Button onClick={() => handleCancel(first.id)}>{t('common.cancel')}</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Typography.Text>
|
||||
{isReceive
|
||||
? '检测到 sz 下载请求(远端正在发送文件),请选择保存在本地的目录。'
|
||||
: '检测到 rz 上传请求(远端正在接收文件),请选择要上传的文件。'}
|
||||
{isReceive ? t('ssh.zmodem.receiveDesc') : t('ssh.zmodem.sendDesc')}
|
||||
</Typography.Text>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user