diff --git a/src/renderer/src/commands/CommandsPanel.tsx b/src/renderer/src/commands/CommandsPanel.tsx index f61c292..a73bdbf 100644 --- a/src/renderer/src/commands/CommandsPanel.tsx +++ b/src/renderer/src/commands/CommandsPanel.tsx @@ -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([]) const [library, setLibrary] = useState([]) const [loaded, setLoaded] = useState(false) @@ -54,16 +55,27 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element }, [refresh]) const handleClearHistory = useCallback(async (): Promise => { - 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 => { - 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 => { diff --git a/src/renderer/src/connections/ConnectionSidebar.tsx b/src/renderer/src/connections/ConnectionSidebar.tsx index a213f18..8048641 100644 --- a/src/renderer/src/connections/ConnectionSidebar.tsx +++ b/src/renderer/src/connections/ConnectionSidebar.tsx @@ -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 s.mode) const isTerminal = mode === 'terminal' const [connections, setConnections] = useState([]) @@ -117,7 +118,14 @@ export const ConnectionSidebar = forwardRef => { - 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() } diff --git a/src/renderer/src/monitor/MonitorPanel.tsx b/src/renderer/src/monitor/MonitorPanel.tsx index fbd544d..8cc93da 100644 --- a/src/renderer/src/monitor/MonitorPanel.tsx +++ b/src/renderer/src/monitor/MonitorPanel.tsx @@ -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 ( +
+
{t('ssh.monitor.interrupted', { error: sample.error })}
+
+ ) + } return (
diff --git a/src/renderer/src/settings/HighlightImportExport.tsx b/src/renderer/src/settings/HighlightImportExport.tsx index 0a0cb63..4df048f 100644 --- a/src/renderer/src/settings/HighlightImportExport.tsx +++ b/src/renderer/src/settings/HighlightImportExport.tsx @@ -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') } diff --git a/src/renderer/src/settings/HighlightTab.tsx b/src/renderer/src/settings/HighlightTab.tsx index dd769d4..3b37506 100644 --- a/src/renderer/src/settings/HighlightTab.tsx +++ b/src/renderer/src/settings/HighlightTab.tsx @@ -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, diff --git a/src/renderer/src/sftp/FilePanel.tsx b/src/renderer/src/sftp/FilePanel.tsx index 87376a8..1c00057 100644 --- a/src/renderer/src/sftp/FilePanel.tsx +++ b/src/renderer/src/sftp/FilePanel.tsx @@ -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 => { @@ -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 diff --git a/src/shared/highlightIO.ts b/src/shared/highlightIO.ts index e39a095..7f9c5e5 100644 --- a/src/shared/highlightIO.ts +++ b/src/shared/highlightIO.ts @@ -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' } diff --git a/src/shared/i18n/dicts/en/settings.ts b/src/shared/i18n/dicts/en/settings.ts index 234bacf..c2839de 100644 --- a/src/shared/i18n/dicts/en/settings.ts +++ b/src/shared/i18n/dicts/en/settings.ts @@ -205,6 +205,8 @@ const settings: Record = { '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 = { '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', diff --git a/src/shared/i18n/dicts/ja/settings.ts b/src/shared/i18n/dicts/ja/settings.ts index fc1af46..42c656d 100644 --- a/src/shared/i18n/dicts/ja/settings.ts +++ b/src/shared/i18n/dicts/ja/settings.ts @@ -201,6 +201,8 @@ const settings: Record = { '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 = { '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': '削除・移動・上書きの操作', diff --git a/src/shared/i18n/dicts/zh-CN/settings.ts b/src/shared/i18n/dicts/zh-CN/settings.ts index 83d26be..9cfdd42 100644 --- a/src/shared/i18n/dicts/zh-CN/settings.ts +++ b/src/shared/i18n/dicts/zh-CN/settings.ts @@ -194,6 +194,8 @@ const settings: Record = { '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 = { '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': '删除/移动/覆盖操作', diff --git a/src/shared/i18n/dicts/zh-TW/settings.ts b/src/shared/i18n/dicts/zh-TW/settings.ts index 8b66505..6e46bd4 100644 --- a/src/shared/i18n/dicts/zh-TW/settings.ts +++ b/src/shared/i18n/dicts/zh-TW/settings.ts @@ -194,6 +194,8 @@ const settings: Record = { '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 = { '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': '刪除/移動/覆蓋操作', diff --git a/tests/hl-rules.mjs b/tests/hl-rules.mjs index 2747c2a..0bc98f5 100644 --- a/tests/hl-rules.mjs +++ b/tests/hl-rules.mjs @@ -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: '[' },