From 34094d607b7abcfc263f37399dd1dca334e31b3d Mon Sep 17 00:00:00 2001 From: Bill Date: Sun, 20 Sep 2026 11:25:59 +0800 Subject: [PATCH] feat: shortcut recorder, configurable terminal toolbar, offline changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Global shortcut setting: press-to-record input (Esc cancels, Backspace clears); requires a modifier or F-key so plain typing can't be hijacked - Terminal toolbar: three settings-gated buttons — session-log record (default off), open logs dir (default off), open working directory (default on, new; local sessions only, cwd tracked via cd/OSC 7) - Input suggestions + command history now default off, with a one-time migration that resets persisted true values for existing installs - Settings dialog: fix broken scrolling — antd v6 renamed the Tabs scroll container to .ant-tabs-body-holder; theme gallery drops its nested scroll (single outer scrollbar) - Offline changelog: CHANGELOG.md at repo root bundled via ?raw; About tab reads it first, network sources stay as fallback; scripts/sync-changelog.cjs merges RELEASE_NOTES.md per release (release.cjs fails without an entry) - commands.ts history prefs default aligned with new off-by-default --- CHANGELOG.md | 15 +++ scripts/release.cjs | 7 ++ scripts/sync-changelog.cjs | 39 ++++++ src/main/commands.ts | 2 +- src/main/ipc.ts | 16 ++- src/main/settingsStore.ts | 29 ++++- src/preload/index.ts | 1 + src/renderer/src/commands/commands.css | 4 +- src/renderer/src/main.tsx | 1 + src/renderer/src/settings/AboutTab.tsx | 23 +++- src/renderer/src/settings/SettingsTabs.tsx | 120 ++++++++++++++++++- src/renderer/src/settings/settings.css | 17 ++- src/renderer/src/terminal/TerminalView.tsx | 66 ++++++---- src/renderer/src/workspace/TerminalPanel.tsx | 1 + src/shared/api.ts | 2 + src/shared/ipc.ts | 4 +- src/shared/settings.ts | 13 +- 17 files changed, 319 insertions(+), 41 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 scripts/sync-changelog.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8994800 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# OpenTerminal 更新日志 + +发布时由 `scripts/sync-changelog.cjs` 自动把 RELEASE_NOTES.md 合并进此文件; +内容随安装包内置,「关于」页的更新日志直接读这里,不依赖网络。 + +## v1.0.12 - 2026-09-20 + +### 浅色主题对比度修复 +- 标签栏/侧栏颜色改为按主题背景亮度自适应:浅色主题下标签栏与终端同色系(不再出现浑浊灰色条),深色主题外观不变 +- 修复浅色主题下标签呈深色块、白字的问题:标签底色与文字改由主题变量接管,非活动标签浅底深字,活动标签文字自动压深 +- 设置弹窗全面适配浅色主题:字体、高亮、主题等页面的标签/描述/表格/调色板文字改为跟随主题前景色,浅色下清晰可读 + +### 稳定性 +- 会话日志写入改为每文件缓冲 + 单条写循环:高输出场景下合并写入、顺序有保障,不再随写入次数无限堆积 +- 设置保存串行化:托盘修改关闭行为与设置界面保存并发时不再互相覆盖丢更新 diff --git a/scripts/release.cjs b/scripts/release.cjs index 42428f9..409c1c9 100644 --- a/scripts/release.cjs +++ b/scripts/release.cjs @@ -40,6 +40,13 @@ if (pkgVersion !== V) { console.error(`package.json is ${pkgVersion} but you asked to publish ${V}`) process.exit(1) } +// The changelog ships inside the app, so it must mention this version — a +// missing entry means `sync-changelog.cjs` was skipped before the build. +const changelog = path.join(ROOT, 'CHANGELOG.md') +if (!fs.existsSync(changelog) || !fs.readFileSync(changelog, 'utf8').includes(`## v${V} `)) { + console.error(`CHANGELOG.md has no v${V} entry — run: node scripts/sync-changelog.cjs (then rebuild)`) + process.exit(1) +} const ymlPath = path.join(R, 'latest.yml') if (!fs.existsSync(ymlPath)) { console.error('missing release/latest.yml — run npm run dist first'); process.exit(1) } const ymlVersion = fs.readFileSync(ymlPath, 'utf8').match(/^version:\s*(\S+)/m)?.[1] diff --git a/scripts/sync-changelog.cjs b/scripts/sync-changelog.cjs new file mode 100644 index 0000000..b5207ad --- /dev/null +++ b/scripts/sync-changelog.cjs @@ -0,0 +1,39 @@ +/* Merge RELEASE_NOTES.md into CHANGELOG.md as the section for the current + package.json version. Idempotent: skips if the version is already present. + Run BEFORE `npm run dist` — the changelog is bundled into the app. + Usage: node scripts/sync-changelog.cjs */ +const fs = require('node:fs') +const path = require('node:path') + +const ROOT = path.join(__dirname, '..') +const pkgVersion = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version +const notesPath = path.join(ROOT, 'RELEASE_NOTES.md') +const changelogPath = path.join(ROOT, 'CHANGELOG.md') +const HEADER = '# OpenTerminal 更新日志' + +const notes = fs.readFileSync(notesPath, 'utf8') + // Drop the notes' own leading title / `## vX` heading — the section heading + // is constructed here so CHANGELOG stays uniform (`## vX.Y.Z - date`). + .replace(/^#.*\n/gm, '') + .replace(new RegExp(`^## v${pkgVersion}.*\\n`), '') + .replace(/^\s+/, '') + .trimEnd() + +if (!notes) { + console.error('RELEASE_NOTES.md is empty — write release notes first') + process.exit(1) +} + +const section = `## v${pkgVersion} - ${new Date().toISOString().slice(0, 10)}\n\n${notes}\n` +const existing = fs.existsSync(changelogPath) ? fs.readFileSync(changelogPath, 'utf8') : '' +if (existing.includes(`## v${pkgVersion} `)) { + console.log(`CHANGELOG.md already has v${pkgVersion} — nothing to do`) + process.exit(0) +} + +const updated = existing + ? existing.replace(new RegExp(`^(${HEADER}\\n\\n)`), `$1${section}\n`) + : `${HEADER}\n\n${section}` + +fs.writeFileSync(changelogPath, updated, 'utf8') +console.log(`CHANGELOG.md: added v${pkgVersion} (${notes.split('\n').length} lines)`) diff --git a/src/main/commands.ts b/src/main/commands.ts index e458d28..13f3f49 100644 --- a/src/main/commands.ts +++ b/src/main/commands.ts @@ -376,7 +376,7 @@ function loadHistoryPrefs(): { historyEnabled: boolean; historyLimit: number } { try { const terminal = loadSettings().terminal return { - historyEnabled: terminal.historyEnabled !== false, + historyEnabled: terminal.historyEnabled === true, historyLimit: normalizeLimit(terminal.historyLimit) } } catch { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 748a82b..cf1ff79 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,6 +1,7 @@ -import { app, ipcMain } from 'electron' +import { app, ipcMain, shell } from 'electron' import fontList from 'font-list' import { homedir } from 'os' +import { statSync } from 'fs' import { Ipc, type AppInfo, type LayoutMeta, type PtyCreateOptions } from '../shared/ipc' import type { HostKeyAction, SessionOpenOptions, SshConnection, SshConnectionInput } from '../shared/connections' import { getLayout, listLayouts, saveLayout, deleteLayout } from './layouts' @@ -181,4 +182,17 @@ export function registerIpc(): void { resolveCwd(current, arg) ) ipcMain.handle(Ipc.CWD_REPORT, (_event, payload: string) => normalizeReportedCwd(payload)) + + // Open a local directory in the file manager. Validates the path is an + // existing directory so renderer-supplied values can't open arbitrary files. + ipcMain.handle(Ipc.CWD_OPEN, (_event, dir: unknown): boolean => { + if (typeof dir !== 'string' || dir === '') return false + try { + if (!statSync(dir).isDirectory()) return false + } catch { + return false + } + void shell.openPath(dir) + return true + }) } \ No newline at end of file diff --git a/src/main/settingsStore.ts b/src/main/settingsStore.ts index 0a34b58..9587e13 100644 --- a/src/main/settingsStore.ts +++ b/src/main/settingsStore.ts @@ -1,5 +1,5 @@ import { app, ipcMain, powerSaveBlocker } from 'electron' -import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs' +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs' import { join } from 'path' import { Ipc } from '../shared/ipc' import { @@ -161,6 +161,32 @@ export function loadSettings(): AppSettings { let settingsQueue: Promise = Promise.resolve() +/** + * One-time reset for existing installs: input suggestions and command history + * are now off by default, but installs that upgraded from earlier versions + * already have `true` persisted — flip those off exactly once, then leave a + * marker so the user's later choices are never touched again. + */ +function migrateDefaultsOnce(): void { + try { + const flag = join(app.getPath('userData'), '.migrate-defaults-v2') + if (existsSync(flag)) return + const current = loadSettings() + if (current.terminal.suggestEnabled || current.terminal.historyEnabled) { + current.terminal.suggestEnabled = false + current.terminal.historyEnabled = false + mkdirSync(app.getPath('userData'), { recursive: true }) + const path = settingsPath() + const tmp = `${path}.tmp` + writeFileSync(tmp, JSON.stringify(current, null, 2), 'utf8') + renameSync(tmp, path) + } + writeFileSync(flag, '', 'utf8') + } catch { + // best effort: a failed migration just keeps the old values + } +} + /** * Serialize a settings mutation against every other writer. Each queued step * re-reads the file so the mutation applies on top of the latest state — a @@ -196,6 +222,7 @@ export function saveSettings(next: AppSettings): Promise { } export function registerSettingsIpc(): void { + migrateDefaultsOnce() ipcMain.handle(Ipc.SETTINGS_GET, () => loadSettings()) ipcMain.handle(Ipc.SETTINGS_SET, (_event, next: AppSettings) => saveSettings(next)) } \ No newline at end of file diff --git a/src/preload/index.ts b/src/preload/index.ts index 0ef2384..3f51913 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -80,6 +80,7 @@ const api: AppApi = { logStop: (sessionId: string) => ipcRenderer.invoke(Ipc.LOG_STOP, sessionId), listSessionLogs: () => ipcRenderer.invoke(Ipc.LOG_LIST), openLogsDir: () => ipcRenderer.send(Ipc.LOG_OPEN_DIR), + openDirectory: (dir: string) => ipcRenderer.invoke(Ipc.CWD_OPEN, dir), listConnections: () => ipcRenderer.invoke(Ipc.CONNECTIONS_LIST), saveConnection: (input: SshConnectionInput) => ipcRenderer.invoke(Ipc.CONNECTIONS_SAVE, input), diff --git a/src/renderer/src/commands/commands.css b/src/renderer/src/commands/commands.css index f84d7b2..e204c45 100644 --- a/src/renderer/src/commands/commands.css +++ b/src/renderer/src/commands/commands.css @@ -18,12 +18,14 @@ margin: 0; } -.commands-panel .ant-tabs-content-holder { +/* antd v6 renamed the layout container: content-holder -> body-holder. */ +.commands-panel .ant-tabs-body-holder { flex: 1 1 auto; min-height: 0; overflow: hidden; } +.commands-panel .ant-tabs-body, .commands-panel .ant-tabs-content, .commands-panel .ant-tabs-tabpane { height: 100%; diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 6fbeafb..9681c31 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -57,6 +57,7 @@ if (typeof window !== 'undefined' && !window.api) { logStop: async () => undefined, listSessionLogs: async () => [], openLogsDir: noop, + openDirectory: async () => false, listConnections: async () => [], saveConnection: async (input) => ({ id: input.id ?? stubId(), diff --git a/src/renderer/src/settings/AboutTab.tsx b/src/renderer/src/settings/AboutTab.tsx index 244e2e9..9e010d0 100644 --- a/src/renderer/src/settings/AboutTab.tsx +++ b/src/renderer/src/settings/AboutTab.tsx @@ -3,6 +3,7 @@ import { Button, Progress, Select, Switch, Tag } from 'antd' 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' const STATUS_TEXT: Record = { idle: '点击检查更新', @@ -15,7 +16,22 @@ const STATUS_TEXT: Record = { dev: '开发模式不支持更新检查' } -/** 关于页:版本信息 + 更新检查/下载/安装 + 更新日志(国内源优先,GitHub 兜底)。 */ +/** 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[] = [] + 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 }) + } + return notes +} + +/** 关于页:版本信息 + 更新检查/下载/安装 + 更新日志(内置 CHANGELOG 优先,网络兜底)。 */ export function AboutTab(): React.JSX.Element { const [version, setVersion] = useState('') const [state, setState] = useState(null) @@ -25,7 +41,10 @@ export function AboutTab(): React.JSX.Element { useEffect(() => { void window.api.appInfo().then((i: AppInfo) => setVersion(i.appVersion)) - void window.api.updateChangelog().then(setNotes).catch(() => undefined) + // 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) }, []) diff --git a/src/renderer/src/settings/SettingsTabs.tsx b/src/renderer/src/settings/SettingsTabs.tsx index 5753e42..a247b24 100644 --- a/src/renderer/src/settings/SettingsTabs.tsx +++ b/src/renderer/src/settings/SettingsTabs.tsx @@ -293,7 +293,7 @@ export function RenderSettingsTab(): React.JSX.Element { desc="关闭后不再记录新命令;已保存的历史仍然保留" control={ void updateTerminal({ historyEnabled: checked })} /> } @@ -315,6 +315,36 @@ export function RenderSettingsTab(): React.JSX.Element { /> } /> + void updateTerminal({ showRecButton: checked })} + /> + } + /> + void updateTerminal({ showOpenLogsButton: checked })} + /> + } + /> + void updateTerminal({ showOpenCwdButton: checked })} + /> + } + /> ) } @@ -382,13 +412,11 @@ export function SystemSettingsTab(): React.JSX.Element { /> void updateSystem({ globalShowHide: e.target.value })} + onChange={(v) => void updateSystem({ globalShowHide: v })} /> } /> @@ -399,6 +427,86 @@ export function SystemSettingsTab(): React.JSX.Element { ) } +/** + * Convert a keydown into an Electron globalShortcut accelerator (e.g. + * "Control+Shift+Alt+T"). Returns null while only modifiers are held or for + * keys we don't want to bind. The Win key maps to Super. + */ +function acceleratorFromEvent(e: React.KeyboardEvent): string | null { + const modifierCodes = new Set([ + 'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight', + 'ShiftLeft', 'ShiftRight', 'MetaLeft', 'MetaRight' + ]) + if (modifierCodes.has(e.code)) return null + const parts: string[] = [] + if (e.ctrlKey) parts.push('Control') + if (e.altKey) parts.push('Alt') + if (e.shiftKey) parts.push('Shift') + if (e.metaKey) parts.push('Super') + let key: string | null = null + if (/^Key[A-Z]$/.test(e.code)) key = e.code.slice(3) + else if (/^Digit\d$/.test(e.code)) key = e.code.slice(5) + else if (/^F\d{1,2}$/.test(e.code)) key = e.code + else if (/^Numpad[0-9]$/.test(e.code)) key = `num${e.code.slice(6)}` + else { + const named: Record = { + Space: 'Space', Tab: 'Tab', Enter: 'Return', NumpadEnter: 'Return', + ArrowUp: 'Up', ArrowDown: 'Down', ArrowLeft: 'Left', ArrowRight: 'Right', + Home: 'Home', End: 'End', PageUp: 'PageUp', PageDown: 'PageDown', + Insert: 'Insert', Delete: 'Delete', Backspace: 'Backspace', + Minus: '-', Equal: '=', BracketLeft: '[', BracketRight: ']', Backslash: '\\', + Semicolon: ';', Quote: "'", Comma: ',', Period: '.', Slash: '/', Backquote: '`', + NumpadAdd: 'numadd', NumpadSubtract: 'numsub', + NumpadMultiply: 'nummult', NumpadDivide: 'numdiv', NumpadDecimal: 'numdec' + } + key = named[e.code] ?? null + } + if (!key) return null + // Guard against hijacking plain typing: a global shortcut must involve a + // modifier, unless it is a function key (F1-F12 are safe standalone). + const isFunctionKey = /^F\d{1,2}$/.test(key) + if (parts.length === 0 && !isFunctionKey) return null + parts.push(key) + return parts.join('+') +} + +/** + * Press-to-record input for the global shortcut: focus it, hit the combo, the + * accelerator is captured and saved. Esc cancels recording, Backspace/Delete + * clears (disables) the shortcut. Read-only so no stray text can be typed. + */ +function ShortcutInput({ value, onChange }: { value: string; onChange: (v: string) => void }): React.JSX.Element { + const [recording, setRecording] = useState(false) + return ( + setRecording(true)} + onBlur={() => setRecording(false)} + onKeyDown={(e) => { + e.preventDefault() + e.stopPropagation() + if (e.key === 'Escape') { + e.currentTarget.blur() + return + } + if (e.key === 'Backspace' || e.key === 'Delete') { + onChange('') + e.currentTarget.blur() + return + } + const accel = acceleratorFromEvent(e) + if (accel) { + onChange(accel) + e.currentTarget.blur() + } + }} + /> + ) +} + /** * Live preview of the current font settings, rendered as an `ls -la`-style * terminal screen with ANSI-colored tokens on the active theme background. diff --git a/src/renderer/src/settings/settings.css b/src/renderer/src/settings/settings.css index f03f623..7432b60 100644 --- a/src/renderer/src/settings/settings.css +++ b/src/renderer/src/settings/settings.css @@ -58,7 +58,8 @@ min-height: 0; } -.settings-body .ant-tabs-content-holder { +/* antd v6 renamed the scroll container: content-holder -> body-holder. */ +.settings-body .ant-tabs-body-holder { flex: 1; overflow-y: auto; } @@ -240,8 +241,6 @@ display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; - max-height: 480px; - overflow-y: auto; padding: 4px 2px 12px; } @@ -534,3 +533,15 @@ white-space: pre-wrap; line-height: 1.6; } + +/* ---------- shortcut recorder (press-to-record input) ---------- */ + +.shortcut-input { + cursor: pointer; + font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace; +} + +.shortcut-input.is-recording, +.shortcut-input.is-recording .ant-input { + border-color: var(--tab-accent, #3fb950); +} diff --git a/src/renderer/src/terminal/TerminalView.tsx b/src/renderer/src/terminal/TerminalView.tsx index b2287af..5f9e081 100644 --- a/src/renderer/src/terminal/TerminalView.tsx +++ b/src/renderer/src/terminal/TerminalView.tsx @@ -6,7 +6,7 @@ import { FitAddon } from '@xterm/addon-fit' import { WebglAddon } from '@xterm/addon-webgl' import { SearchAddon } from '@xterm/addon-search' import '@xterm/xterm/css/xterm.css' -import { ClearOutlined, CopyOutlined, FolderOpenOutlined, PauseOutlined, SearchOutlined, SelectOutlined, SnippetsOutlined, SoundOutlined } from '@ant-design/icons' +import { ClearOutlined, CopyOutlined, ExportOutlined, FolderOpenOutlined, PauseOutlined, SearchOutlined, SelectOutlined, SnippetsOutlined, SoundOutlined } from '@ant-design/icons' import { Checkbox, Modal } from 'antd' import { getThemeById } from '@shared/theme' import type { CommandItem } from '@shared/commands' @@ -83,6 +83,8 @@ export interface TerminalViewProps { /** called once after the pty process exits and the user closes the dead pane */ onClose: () => void className?: string + /** ssh sessions have a remote cwd — the open-directory button is hidden */ + isSsh?: boolean } interface SearchBarProps { @@ -248,7 +250,7 @@ function differenceWrite(sessionId: string, full: string, buf: string): void { } export const TerminalView: ForwardRefExoticComponent }> = - forwardRef(function TerminalViewInner({ sessionId, onClose, className }, ref) { + forwardRef(function TerminalViewInner({ sessionId, onClose, className, isSsh = false }, ref) { const settings = useSettingsStore((s) => s.settings) const hostRef = useRef(null) @@ -1164,27 +1166,45 @@ export const TerminalView: ForwardRefExoticComponent )}
- - + {settings.terminal.showRecButton && ( + + )} + {settings.terminal.showOpenLogsButton && ( + + )} + {settings.terminal.showOpenCwdButton && !isSsh && ( + + )}
{ if (closedRef.current) return diff --git a/src/shared/api.ts b/src/shared/api.ts index 1b983f8..b6216d1 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -80,6 +80,8 @@ export interface AppApi { logStop(sessionId: string): Promise listSessionLogs(): Promise openLogsDir(): void + /** open a local directory in the OS file manager; false if not a directory */ + openDirectory(dir: string): Promise // ---- ssh connections ---- listConnections(): Promise diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 281502a..3f79b5c 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -106,7 +106,9 @@ export const Ipc = { /** resolve a cd-style argument against the current cwd, platform-aware */ CWD_RESOLVE: 'session:cwdResolve', /** normalize a cwd reported by the shell (OSC 7 / OSC 9;9) */ - CWD_REPORT: 'session:cwdReport' + CWD_REPORT: 'session:cwdReport', + /** open a local directory in the OS file manager (terminal toolbar) */ + CWD_OPEN: 'session:cwdOpen' } as const export interface PtyCreateOptions { diff --git a/src/shared/settings.ts b/src/shared/settings.ts index 71b41b2..0e92cc4 100644 --- a/src/shared/settings.ts +++ b/src/shared/settings.ts @@ -40,6 +40,12 @@ export interface TerminalSettings { historyEnabled: boolean /** max entries kept in command history */ historyLimit: number + /** terminal toolbar: session-log record button */ + showRecButton: boolean + /** terminal toolbar: open logs folder button */ + showOpenLogsButton: boolean + /** terminal toolbar: open working-directory button (local sessions only) */ + showOpenCwdButton: boolean /** accent color for the active tab outline, rail indicator and SSH badges */ tabAccentColor: string } @@ -117,9 +123,12 @@ export const DEFAULT_SETTINGS: AppSettings = { autoWrap: true, copyOnSelect: false, pasteRiskConfirm: true, - suggestEnabled: true, - historyEnabled: true, + suggestEnabled: false, + historyEnabled: false, historyLimit: 100, + showRecButton: false, + showOpenLogsButton: false, + showOpenCwdButton: true, tabAccentColor: '#3fb950' }, customThemes: [],