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:
1 parent
172cce1962
commit
6a218ec6c0
12 files changed
+108
-18
No files matched your search
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '削除・移動・上書きの操作',
|
||||
|
||||
@@ -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': '删除/移动/覆盖操作',
|
||||
|
||||
@@ -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
@@ -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: '[' },
|
||||
|
||||
Reference in new issue
Block a user