feat: shortcut recorder, configurable terminal toolbar, offline changelog

- 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
This commit is contained in:
Bill committed 2026-09-20 11:25:59 +08:00
1 parent c1744184a3
commit 34094d607b
17 files changed
+319 -41

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
# OpenTerminal 更新日志
发布时由 `scripts/sync-changelog.cjs` 自动把 RELEASE_NOTES.md 合并进此文件;
内容随安装包内置,「关于」页的更新日志直接读这里,不依赖网络。
## v1.0.12 - 2026-09-20
### 浅色主题对比度修复
- 标签栏/侧栏颜色改为按主题背景亮度自适应:浅色主题下标签栏与终端同色系(不再出现浑浊灰色条),深色主题外观不变
- 修复浅色主题下标签呈深色块、白字的问题:标签底色与文字改由主题变量接管,非活动标签浅底深字,活动标签文字自动压深
- 设置弹窗全面适配浅色主题:字体、高亮、主题等页面的标签/描述/表格/调色板文字改为跟随主题前景色,浅色下清晰可读
### 稳定性
- 会话日志写入改为每文件缓冲 + 单条写循环:高输出场景下合并写入、顺序有保障,不再随写入次数无限堆积
- 设置保存串行化:托盘修改关闭行为与设置界面保存并发时不再互相覆盖丢更新
+7
View File
@@ -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]
+39
View File
@@ -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)`)
+1 -1
View File
@@ -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 {
+15 -1
View File
@@ -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
})
}
+28 -1
View File
@@ -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<unknown> = 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<AppSettings> {
}
export function registerSettingsIpc(): void {
migrateDefaultsOnce()
ipcMain.handle(Ipc.SETTINGS_GET, () => loadSettings())
ipcMain.handle(Ipc.SETTINGS_SET, (_event, next: AppSettings) => saveSettings(next))
}
+1
View File
@@ -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),
+3 -1
View File
@@ -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%;
+1
View File
@@ -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(),
+21 -2
View File
@@ -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<UpdateStatus, string> = {
idle: '点击检查更新',
@@ -15,7 +16,22 @@ const STATUS_TEXT: Record<UpdateStatus, string> = {
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<UpdateState | null>(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)
}, [])
+114 -6
View File
@@ -293,7 +293,7 @@ export function RenderSettingsTab(): React.JSX.Element {
desc="关闭后不再记录新命令;已保存的历史仍然保留"
control={
<Switch
checked={settings.terminal.historyEnabled !== false}
checked={settings.terminal.historyEnabled}
onChange={(checked) => void updateTerminal({ historyEnabled: checked })}
/>
}
@@ -315,6 +315,36 @@ export function RenderSettingsTab(): React.JSX.Element {
/>
}
/>
<SettingRow
label="终端工具栏 · 录制"
desc="右上角显示「记录会话日志」按钮"
control={
<Switch
checked={settings.terminal.showRecButton}
onChange={(checked) => void updateTerminal({ showRecButton: checked })}
/>
}
/>
<SettingRow
label="终端工具栏 · 打开日志目录"
desc="右上角显示「打开日志目录」按钮"
control={
<Switch
checked={settings.terminal.showOpenLogsButton}
onChange={(checked) => void updateTerminal({ showOpenLogsButton: checked })}
/>
}
/>
<SettingRow
label="终端工具栏 · 打开工作区目录"
desc="右上角显示「打开当前目录」按钮(SSH 会话不显示;目录随 cd 实时跟踪,需开启 shell 集成或使用 cd 命令)"
control={
<Switch
checked={settings.terminal.showOpenCwdButton}
onChange={(checked) => void updateTerminal({ showOpenCwdButton: checked })}
/>
}
/>
</div>
)
}
@@ -382,13 +412,11 @@ export function SystemSettingsTab(): React.JSX.Element {
/>
<SettingRow
label="全局唤起快捷键"
desc="Control+Shift+Alt+T 等,留空禁用"
desc="点击输入框后直接按下组合键;Esc 取消,退格清空禁用"
control={
<Input
className="settings-select"
placeholder="如 Control+Shift+Alt+T,留空禁用"
<ShortcutInput
value={settings.system.globalShowHide ?? ''}
onChange={(e) => 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<HTMLInputElement>): 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<string, string> = {
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 (
<Input
className={`settings-select shortcut-input${recording ? ' is-recording' : ''}`}
readOnly
value={recording ? '按下组合键…' : value}
placeholder="点击后按下快捷键,留空禁用"
onFocus={() => 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.
+14 -3
View File
@@ -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);
}
+43 -23
View File
@@ -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<TerminalViewProps & { ref?: Ref<TerminalHandle> }> =
forwardRef<TerminalHandle, TerminalViewProps>(function TerminalViewInner({ sessionId, onClose, className }, ref) {
forwardRef<TerminalHandle, TerminalViewProps>(function TerminalViewInner({ sessionId, onClose, className, isSsh = false }, ref) {
const settings = useSettingsStore((s) => s.settings)
const hostRef = useRef<HTMLDivElement | null>(null)
@@ -1164,27 +1166,45 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
/>
)}
<div className="term-recbar">
<button
type="button"
className={`term-rec-btn${recording ? ' is-recording' : ''}`}
title={recording ? '停止记录会话日志' : '开始记录会话日志'}
aria-label={recording ? '停止记录' : '开始记录'}
onClick={() => {
if (recording) void handleLogStop()
else void handleLogStart()
}}
>
{recording ? <PauseOutlined /> : <SoundOutlined />}
</button>
<button
type="button"
className="term-rec-btn"
title="打开日志目录"
aria-label="打开日志目录"
onClick={() => window.api.openLogsDir()}
>
<FolderOpenOutlined />
</button>
{settings.terminal.showRecButton && (
<button
type="button"
className={`term-rec-btn${recording ? ' is-recording' : ''}`}
title={recording ? '停止记录会话日志' : '开始记录会话日志'}
aria-label={recording ? '停止记录' : '开始记录'}
onClick={() => {
if (recording) void handleLogStop()
else void handleLogStart()
}}
>
{recording ? <PauseOutlined /> : <SoundOutlined />}
</button>
)}
{settings.terminal.showOpenLogsButton && (
<button
type="button"
className="term-rec-btn"
title="打开日志目录"
aria-label="打开日志目录"
onClick={() => window.api.openLogsDir()}
>
<FolderOpenOutlined />
</button>
)}
{settings.terminal.showOpenCwdButton && !isSsh && (
<button
type="button"
className="term-rec-btn"
title="打开工作区目录"
aria-label="打开工作区目录"
onClick={() => {
const dir = getSessionCwd(sessionId)
if (dir) void window.api.openDirectory(dir)
}}
>
<ExportOutlined />
</button>
)}
</div>
<Modal
open={paste !== null}
@@ -59,6 +59,7 @@ export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps
const terminal = (
<TerminalView
sessionId={sessionId}
isSsh={isSsh}
className="workspace-terminal-view"
onClose={() => {
if (closedRef.current) return
+2
View File
@@ -80,6 +80,8 @@ export interface AppApi {
logStop(sessionId: string): Promise<void>
listSessionLogs(): Promise<SessionLogMeta[]>
openLogsDir(): void
/** open a local directory in the OS file manager; false if not a directory */
openDirectory(dir: string): Promise<boolean>
// ---- ssh connections ----
listConnections(): Promise<SshConnection[]>
+3 -1
View File
@@ -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 {
+11 -2
View File
@@ -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: [],