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

+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