fix(renderer): 界面与设置页一轮修补

- FilePanel 上传进度监听器组件卸载时清理(原先只在终态/reject 时 off)
- MonitorPanel:首采即失败时显示中断错误条,不再被 meta 门挡成永远
  '采集中'
- HighlightImportExport:blob 下载延迟 revokeObjectURL(原先同步回收
  会中止下载);FileReader 补 onerror
- HighlightTab 新建规则改 getState() 读法防 stale(对齐 replaceRule)
- ConnectionSidebar/CommandsPanel 三处删除/清空 handler 补 try/catch
  与 message.error(原先失败静默)
- highlightIO 导入信封补 version 校验、kind 缺失不再放行
- i18n:补 settings.highlight.builtin.percent 四语言键(原先唯一缺失
  的内置规则,非中文界面显示中文 note)
This commit is contained in:
Bill committed 2026-10-09 11:31:10 +08:00
1 parent 172cce1962
commit 6a218ec6c0
12 files changed
+108 -18

No files matched your search

+17 -5
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import { Button, Modal, Popconfirm, Tabs } from 'antd'
import { App, Button, Modal, Popconfirm, Tabs } from 'antd'
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'
import type { CommandItem } from '@shared/commands'
import { t } from '@shared/i18n'
@@ -24,6 +24,7 @@ export function formatClock(ts: number): string {
* runs the command via the parent workspace's `onRun`.
*/
export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element {
const { message } = App.useApp()
const [history, setHistory] = useState<CommandItem[]>([])
const [library, setLibrary] = useState<CommandItem[]>([])
const [loaded, setLoaded] = useState(false)
@@ -54,16 +55,27 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
}, [refresh])
const handleClearHistory = useCallback(async (): Promise<void> => {
await window.api.clearHistory()
try {
await window.api.clearHistory()
} catch (err) {
// Nothing was removed, so a refresh would repaint the same rows.
message.error((err as Error)?.message || t('common.saveFailed'))
return
}
refresh()
}, [refresh])
}, [refresh, message])
const handleDeleteLibrary = useCallback(
async (id: string): Promise<void> => {
await window.api.deleteLibraryItem(id)
try {
await window.api.deleteLibraryItem(id)
} catch (err) {
message.error((err as Error)?.message || t('common.saveFailed'))
return
}
refresh()
},
[refresh]
[refresh, message]
)
const handleAdd = useCallback((): void => {
@@ -1,5 +1,5 @@
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react'
import { Button, Collapse, Popconfirm, Tooltip } from 'antd'
import { App, 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'
@@ -61,6 +61,7 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
}: ConnectionSidebarProps,
ref
): React.JSX.Element {
const { message } = App.useApp()
const mode = useWorkspaceModeStore((s) => s.mode)
const isTerminal = mode === 'terminal'
const [connections, setConnections] = useState<SshConnection[]>([])
@@ -117,7 +118,14 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
}
const handleDelete = async (id: string): Promise<void> => {
await window.api.deleteConnection(id)
try {
await window.api.deleteConnection(id)
} catch (err) {
// The row is still there, so a refresh would just re-render the same
// list — report the failure and leave it alone.
message.error((err as Error)?.message || t('common.saveFailed'))
return
}
await refresh()
}
+10
View File
@@ -182,6 +182,16 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
}, [sessionId, workspaceMode])
if (!meta || !sample) {
// `meta` is only broadcast after a first sample succeeds, so a session that
// fails from the start would otherwise spin here forever with its error
// sample hidden behind the meta gate.
if (sample?.error) {
return (
<div className="mm-root mm-empty mm-failed">
<div className="mm-error">{t('ssh.monitor.interrupted', { error: sample.error })}</div>
</div>
)
}
return (
<div className="mm-root mm-empty">
<span className="mm-dots"><span /> <span /> <span /></span>
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { Alert, Button, Input, Modal, Popconfirm, Radio, Space } from 'antd'
import { Alert, App, Button, Input, Modal, Popconfirm, Radio, Space } from 'antd'
import { t } from '@shared/i18n'
import { exportHighlightRules, mergeRules, parseHighlightRules } from '@shared/highlightIO'
import type { ImportError } from '@shared/highlightIO'
@@ -24,6 +24,7 @@ export function HighlightImportExport({
open: boolean
onClose: () => void
}): React.JSX.Element {
const { message } = App.useApp()
const highlightRules = useSettingsStore((s) => s.settings.highlightRules)
const setHighlightRules = useSettingsStore((s) => s.setHighlightRules)
@@ -59,18 +60,22 @@ export function HighlightImportExport({
link.href = url
link.download = 'openterminal-highlight-rules.json'
link.click()
URL.revokeObjectURL(url)
// Chromium reads the blob asynchronously after the click, so revoking the
// URL synchronously aborts the download before it can start.
window.setTimeout(() => URL.revokeObjectURL(url), 0)
}
const handleFile = (file: File): void => {
const reader = new FileReader()
reader.onload = () => setDraft(typeof reader.result === 'string' ? reader.result : '')
reader.onerror = () => message.error(t('settings.highlight.importReadFailed'))
reader.readAsText(file)
}
const errorText = (error: ImportError): string => {
if (error === 'not-json') return t('settings.highlight.importBadJson')
if (error === 'wrong-kind') return t('settings.highlight.importWrongKind')
if (error === 'wrong-version') return t('settings.highlight.importWrongVersion')
return t('settings.highlight.importEmpty')
}
+4 -1
View File
@@ -549,7 +549,10 @@ function HighlightEditor({
...(draft.basic ? { basic: true as const } : {}),
note: draft.note?.trim() ? draft.note.trim() : undefined
}
await setHighlightRules([...highlightRules, rule])
// read at call time: the render-scoped array goes stale inside a React
// batch, and another write in the same batch would silently drop the first
const current = useSettingsStore.getState().settings.highlightRules
await setHighlightRules([...current, rule])
} else if (editing !== undefined) {
const rule: HighlightRule = {
...editing,
+12
View File
@@ -536,6 +536,17 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
const refreshSeqRef = useRef(0)
/** Mirrors `dir` for callbacks created before a navigation (upload finish). */
const dirRef = useRef(dir)
/** Unsubscribe for the transfer listener the in-flight upload registered. */
const uploadOffRef = useRef<(() => void) | null>(null)
useEffect(() => {
return () => {
// An upload can still be running — or never deliver a terminal event —
// when the panel unmounts; without this its IPC listener outlives it.
uploadOffRef.current?.()
uploadOffRef.current = null
}
}, [])
const refresh = useCallback(
async (target: string): Promise<void> => {
@@ -602,6 +613,7 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
}
if (e.transferId === targetId) finish()
})
uploadOffRef.current = off
window.api.uploadRemote(sessionId, files, dir).then(
(id: string) => {
targetId = id
+14 -7
View File
@@ -5,10 +5,11 @@ import { isHighlightCategory, type HighlightRule } from './settings'
* a copy before experimenting.
*
* The envelope carries a kind + version so pasting the wrong JSON (a layout, a
* connection list) fails loudly instead of importing nonsense. Validation here
* stays deliberately light: it only rejects what cannot be a rule at all
* (unparseable JSON, no `rules` array, an unusable regex). Everything else is
* repaired by the settings store on save, which already owns that job.
* connection list, a file from a future format) fails loudly instead of
* importing nonsense. Validation here stays deliberately light: past that
* envelope it only rejects what cannot be a rule at all (no `rules` array, an
* unusable regex). Everything else is repaired by the settings store on save,
* which already owns that job.
*/
export const HIGHLIGHT_FILE_KIND = 'openterminal.highlight-rules'
@@ -27,7 +28,7 @@ export interface HighlightFile {
}
/** Reasons a paste can be refused, as codes the UI turns into a message. */
export type ImportError = 'not-json' | 'no-rules' | 'wrong-kind'
export type ImportError = 'not-json' | 'no-rules' | 'wrong-kind' | 'wrong-version'
export interface ParsedHighlightFile {
rules: HighlightRule[]
@@ -68,10 +69,16 @@ export function parseHighlightRules(text: string): ParsedHighlightFile {
if (Array.isArray(raw)) {
list = raw
} else if (raw !== null && typeof raw === 'object' && Array.isArray((raw as { rules?: unknown }).rules)) {
const kind = (raw as { kind?: unknown }).kind
if (typeof kind === 'string' && kind !== HIGHLIGHT_FILE_KIND) {
// An envelope is only trusted when both stamps match: a missing `kind`
// means the JSON is not ours, and a foreign `version` means it was written
// by a format this build does not know how to read.
const { kind, version } = raw as { kind?: unknown; version?: unknown }
if (kind !== HIGHLIGHT_FILE_KIND) {
return { rules: [], warnings: [], error: 'wrong-kind' }
}
if (version !== HIGHLIGHT_FILE_VERSION) {
return { rules: [], warnings: [], error: 'wrong-version' }
}
list = (raw as { rules: unknown[] }).rules
} else {
return { rules: [], warnings: [], error: 'no-rules' }
+3
View File
@@ -205,6 +205,8 @@ const settings: Record<string, string> = {
'settings.highlight.importEmpty': 'No usable rules found',
'settings.highlight.importBadJson': 'Not valid JSON',
'settings.highlight.importWrongKind': 'This JSON is not a highlight-rule file',
'settings.highlight.importWrongVersion': 'This JSON has an unsupported version',
'settings.highlight.importReadFailed': 'Could not read the file',
'settings.highlight.importSkipped': 'Skipped {n}: {list}',
'settings.highlight.imported': 'Imported {n} rule(s)',
'settings.highlight.testText': 'Test text',
@@ -268,6 +270,7 @@ const settings: Record<string, string> = {
'settings.highlight.builtin.loglevel': 'Log levels',
'settings.highlight.builtin.rootat': 'Root prompt (root@)',
'settings.highlight.builtin.exitcode': 'Exit codes',
'settings.highlight.builtin.percent': 'Percentages & progress',
'settings.highlight.builtin.http': 'HTTP status codes',
'settings.highlight.builtin.latency': 'Durations (milliseconds)',
'settings.highlight.builtin.delop': 'Delete / move / overwrite operations',
+3
View File
@@ -201,6 +201,8 @@ const settings: Record<string, string> = {
'settings.highlight.importEmpty': '有効なルールが見つかりません',
'settings.highlight.importBadJson': '有効な JSON ではありません',
'settings.highlight.importWrongKind': 'この JSON はハイライトルールのファイルではありません',
'settings.highlight.importWrongVersion': 'この JSON のバージョンは対応していません',
'settings.highlight.importReadFailed': 'ファイルを読み込めませんでした',
'settings.highlight.importSkipped': '{n} 件をスキップ:{list}',
'settings.highlight.imported': '{n} 件のルールを取り込みました',
'settings.highlight.testText': 'テスト文字列',
@@ -263,6 +265,7 @@ const settings: Record<string, string> = {
'settings.highlight.builtin.loglevel': 'ログレベル',
'settings.highlight.builtin.rootat': 'root プロンプト(root@)',
'settings.highlight.builtin.exitcode': '終了コード',
'settings.highlight.builtin.percent': 'パーセントと進捗',
'settings.highlight.builtin.http': 'HTTP ステータスコード',
'settings.highlight.builtin.latency': '所要時間(ミリ秒)',
'settings.highlight.builtin.delop': '削除・移動・上書きの操作',
+3
View File
@@ -194,6 +194,8 @@ const settings: Record<string, string> = {
'settings.highlight.importEmpty': '没有解析到可用规则',
'settings.highlight.importBadJson': '不是有效的 JSON',
'settings.highlight.importWrongKind': '这份 JSON 不是高亮规则文件',
'settings.highlight.importWrongVersion': '这份 JSON 的版本不受支持',
'settings.highlight.importReadFailed': '读取文件失败',
'settings.highlight.importSkipped': '已跳过 {n} 条:{list}',
'settings.highlight.imported': '已导入 {n} 条规则',
'settings.highlight.testText': '测试文本',
@@ -252,6 +254,7 @@ const settings: Record<string, string> = {
'settings.highlight.builtin.loglevel': '日志级别',
'settings.highlight.builtin.rootat': '管理员提示符 root@',
'settings.highlight.builtin.exitcode': '退出码',
'settings.highlight.builtin.percent': '百分比与进度',
'settings.highlight.builtin.http': 'HTTP 状态码',
'settings.highlight.builtin.latency': '耗时(毫秒)',
'settings.highlight.builtin.delop': '删除/移动/覆盖操作',
+3
View File
@@ -194,6 +194,8 @@ const settings: Record<string, string> = {
'settings.highlight.importEmpty': '沒有解析到可用規則',
'settings.highlight.importBadJson': '不是有效的 JSON',
'settings.highlight.importWrongKind': '這份 JSON 不是高亮規則檔案',
'settings.highlight.importWrongVersion': '這份 JSON 的版本不受支援',
'settings.highlight.importReadFailed': '讀取檔案失敗',
'settings.highlight.importSkipped': '已略過 {n} 條:{list}',
'settings.highlight.imported': '已匯入 {n} 條規則',
'settings.highlight.testText': '測試文字',
@@ -252,6 +254,7 @@ const settings: Record<string, string> = {
'settings.highlight.builtin.loglevel': '日誌等級',
'settings.highlight.builtin.rootat': '管理員提示字元 root@',
'settings.highlight.builtin.exitcode': '結束碼',
'settings.highlight.builtin.percent': '百分比與進度',
'settings.highlight.builtin.http': 'HTTP 狀態碼',
'settings.highlight.builtin.latency': '耗時(毫秒)',
'settings.highlight.builtin.delop': '刪除/移動/覆蓋操作',
+22 -1
View File
@@ -15,7 +15,13 @@
import { compileRules, applyHighlights, __testHooks as hooks } from '../src/renderer/src/terminal/highlightEngine.ts'
import { previewSpans } from '../src/renderer/src/terminal/highlightEngine.ts'
import { DEFAULT_HIGHLIGHT_RULES } from '../src/shared/settings.ts'
import { exportHighlightRules, mergeRules, parseHighlightRules } from '../src/shared/highlightIO.ts'
import {
exportHighlightRules,
HIGHLIGHT_FILE_KIND,
HIGHLIGHT_FILE_VERSION,
mergeRules,
parseHighlightRules
} from '../src/shared/highlightIO.ts'
import {
excludedByProfile,
rulesForProfile,
@@ -549,11 +555,26 @@ console.log('[import / export]')
parseHighlightRules('{"kind":"openterminal.layout","rules":[]}').error === 'wrong-kind',
'a foreign envelope is refused'
)
ok(
parseHighlightRules('{"rules":[]}').error === 'wrong-kind',
'an envelope with no kind at all is refused'
)
ok(
parseHighlightRules(JSON.stringify({ kind: HIGHLIGHT_FILE_KIND, version: HIGHLIGHT_FILE_VERSION + 1, rules: [] }))
.error === 'wrong-version',
'an envelope from another version is refused'
)
ok(
parseHighlightRules(JSON.stringify({ kind: HIGHLIGHT_FILE_KIND, rules: [] })).error === 'wrong-version',
'an envelope with no version at all is refused'
)
ok(parseHighlightRules('{"nope":1}').error === 'no-rules', 'JSON without a rules array is refused')
ok(parseHighlightRules('[]').error === undefined, 'a bare empty array is accepted')
const partial = parseHighlightRules(
JSON.stringify({
kind: HIGHLIGHT_FILE_KIND,
version: HIGHLIGHT_FILE_VERSION,
rules: [
{ pattern: '\\bOK\\b', priority: 500, color: { fg: 'red' } },
{ pattern: '[' },