OpenTerminal v0.2.1: terminal suite with SSH, split panes, themes and keyword highlighting

- Local terminal (node-pty) + SSH sessions (ssh2) with unified data plane
- dockview split panes, per-group '+' tab bar button, layout templates
- Session replay buffer (late-subscriber catch-up), host key pinning,
  credential encryption via safeStorage
- 12 builtin themes + custom theme editor, system font enumeration
- Keyword highlighting: regex rules with priority, ANSI truecolor injection,
  chunk-boundary-safe stream (carry-over of partial escape sequences)
- Dark antd theme app-wide, settings dialog redesign, error boundary
- Tests: ssh loopback, session e2e, highlight split-chunk regression
This commit is contained in:
954801926@qq.com committed 2026-09-06 11:19:08 +08:00
commit 551723c2d4
58 files changed
+11929

No files matched your search

+109
View File
@@ -0,0 +1,109 @@
import { app, ipcMain } from 'electron'
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs'
import { join } from 'path'
import { Ipc } from '../shared/ipc'
import {
DEFAULT_HIGHLIGHT_RULES,
DEFAULT_SETTINGS,
type AppSettings,
type HighlightRule,
type TerminalSettings
} from '../shared/settings'
import type { TerminalTheme } from '../shared/theme'
import { broadcast } from './broadcast'
const settingsPath = (): string => join(app.getPath('userData'), 'settings.json')
const TERMINAL_KEYS = new Set(Object.keys(DEFAULT_SETTINGS.terminal) as (keyof TerminalSettings)[])
/** Light structural check for a persisted highlight rule. */
function isHighlightRule(value: unknown): value is HighlightRule {
if (value === null || typeof value !== 'object') return false
const rule = value as Record<string, unknown>
return (
typeof rule.id === 'string' &&
typeof rule.pattern === 'string' &&
typeof rule.enabled === 'boolean' &&
typeof rule.priority === 'number' &&
rule.color !== null &&
typeof rule.color === 'object' &&
typeof (rule.color as { fg?: unknown }).fg === 'string'
)
}
function sanitizeRules(value: unknown): HighlightRule[] {
if (!Array.isArray(value)) return DEFAULT_HIGHLIGHT_RULES
const rules = value.filter(isHighlightRule)
return rules.length > 0 ? rules : DEFAULT_HIGHLIGHT_RULES
}
function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
const errors: string[] = []
let terminal: TerminalSettings = { ...DEFAULT_SETTINGS.terminal }
let customThemes: unknown = DEFAULT_SETTINGS.customThemes
let highlightRules: unknown = DEFAULT_HIGHLIGHT_RULES
if (raw !== null && typeof raw === 'object') {
const packageSettings = raw as { terminal?: unknown; customThemes?: unknown; highlightRules?: unknown }
if (packageSettings.terminal !== null && typeof packageSettings.terminal === 'object') {
const candidate = packageSettings.terminal as Record<string, unknown>
const merged: Record<string, unknown> = { ...terminal }
for (const key of TERMINAL_KEYS) {
const keyType = typeof DEFAULT_SETTINGS.terminal[key]
if (candidate[key] !== undefined && typeof candidate[key] === keyType) {
merged[key] = candidate[key]
} else if (candidate[key] !== undefined) {
errors.push(`terminal.${key}`)
}
}
terminal = merged as unknown as TerminalSettings
}
if (Array.isArray(packageSettings.customThemes)) {
customThemes = packageSettings.customThemes
}
if (packageSettings.highlightRules !== undefined) {
highlightRules = packageSettings.highlightRules
}
}
const themes: TerminalTheme[] = Array.isArray(customThemes) ? (customThemes as TerminalTheme[]) : []
return { settings: { terminal, customThemes: themes, highlightRules: sanitizeRules(highlightRules) }, errors }
}
export function loadSettings(): AppSettings {
try {
const raw: unknown = JSON.parse(readFileSync(settingsPath(), 'utf8'))
const { settings } = deepMerge(raw)
return settings
} catch {
return {
terminal: { ...DEFAULT_SETTINGS.terminal },
customThemes: [...DEFAULT_SETTINGS.customThemes],
highlightRules: DEFAULT_HIGHLIGHT_RULES.map((rule) => ({ ...rule }))
}
}
}
export function saveSettings(next: AppSettings): AppSettings {
const merged = deepMerge({
terminal: next.terminal,
customThemes: next.customThemes,
highlightRules: next.highlightRules
}).settings
const pretty = JSON.stringify(merged, null, 2)
mkdirSync(app.getPath('userData'), { recursive: true })
const path = settingsPath()
const tmp = `${path}.tmp`
writeFileSync(tmp, pretty, 'utf8')
renameSync(tmp, path)
broadcast(Ipc.SETTINGS_CHANGED, merged)
return merged
}
export function registerSettingsIpc(): void {
ipcMain.handle(Ipc.SETTINGS_GET, () => loadSettings())
ipcMain.handle(Ipc.SETTINGS_SET, (_event, next: AppSettings) => saveSettings(next))
}