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:
1 parent
c1744184a3
commit
34094d607b
17 files changed
+319
-41
No files matched your search
@@ -0,0 +1,15 @@
|
|||||||
|
# OpenTerminal 更新日志
|
||||||
|
|
||||||
|
发布时由 `scripts/sync-changelog.cjs` 自动把 RELEASE_NOTES.md 合并进此文件;
|
||||||
|
内容随安装包内置,「关于」页的更新日志直接读这里,不依赖网络。
|
||||||
|
|
||||||
|
## v1.0.12 - 2026-09-20
|
||||||
|
|
||||||
|
### 浅色主题对比度修复
|
||||||
|
- 标签栏/侧栏颜色改为按主题背景亮度自适应:浅色主题下标签栏与终端同色系(不再出现浑浊灰色条),深色主题外观不变
|
||||||
|
- 修复浅色主题下标签呈深色块、白字的问题:标签底色与文字改由主题变量接管,非活动标签浅底深字,活动标签文字自动压深
|
||||||
|
- 设置弹窗全面适配浅色主题:字体、高亮、主题等页面的标签/描述/表格/调色板文字改为跟随主题前景色,浅色下清晰可读
|
||||||
|
|
||||||
|
### 稳定性
|
||||||
|
- 会话日志写入改为每文件缓冲 + 单条写循环:高输出场景下合并写入、顺序有保障,不再随写入次数无限堆积
|
||||||
|
- 设置保存串行化:托盘修改关闭行为与设置界面保存并发时不再互相覆盖丢更新
|
||||||
@@ -40,6 +40,13 @@ if (pkgVersion !== V) {
|
|||||||
console.error(`package.json is ${pkgVersion} but you asked to publish ${V}`)
|
console.error(`package.json is ${pkgVersion} but you asked to publish ${V}`)
|
||||||
process.exit(1)
|
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')
|
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) }
|
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]
|
const ymlVersion = fs.readFileSync(ymlPath, 'utf8').match(/^version:\s*(\S+)/m)?.[1]
|
||||||
|
|||||||
@@ -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)`)
|
||||||
@@ -376,7 +376,7 @@ function loadHistoryPrefs(): { historyEnabled: boolean; historyLimit: number } {
|
|||||||
try {
|
try {
|
||||||
const terminal = loadSettings().terminal
|
const terminal = loadSettings().terminal
|
||||||
return {
|
return {
|
||||||
historyEnabled: terminal.historyEnabled !== false,
|
historyEnabled: terminal.historyEnabled === true,
|
||||||
historyLimit: normalizeLimit(terminal.historyLimit)
|
historyLimit: normalizeLimit(terminal.historyLimit)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+15
-1
@@ -1,6 +1,7 @@
|
|||||||
import { app, ipcMain } from 'electron'
|
import { app, ipcMain, shell } from 'electron'
|
||||||
import fontList from 'font-list'
|
import fontList from 'font-list'
|
||||||
import { homedir } from 'os'
|
import { homedir } from 'os'
|
||||||
|
import { statSync } from 'fs'
|
||||||
import { Ipc, type AppInfo, type LayoutMeta, type PtyCreateOptions } from '../shared/ipc'
|
import { Ipc, type AppInfo, type LayoutMeta, type PtyCreateOptions } from '../shared/ipc'
|
||||||
import type { HostKeyAction, SessionOpenOptions, SshConnection, SshConnectionInput } from '../shared/connections'
|
import type { HostKeyAction, SessionOpenOptions, SshConnection, SshConnectionInput } from '../shared/connections'
|
||||||
import { getLayout, listLayouts, saveLayout, deleteLayout } from './layouts'
|
import { getLayout, listLayouts, saveLayout, deleteLayout } from './layouts'
|
||||||
@@ -181,4 +182,17 @@ export function registerIpc(): void {
|
|||||||
resolveCwd(current, arg)
|
resolveCwd(current, arg)
|
||||||
)
|
)
|
||||||
ipcMain.handle(Ipc.CWD_REPORT, (_event, payload: string) => normalizeReportedCwd(payload))
|
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
|
||||||
|
})
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { app, ipcMain, powerSaveBlocker } from 'electron'
|
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 { join } from 'path'
|
||||||
import { Ipc } from '../shared/ipc'
|
import { Ipc } from '../shared/ipc'
|
||||||
import {
|
import {
|
||||||
@@ -161,6 +161,32 @@ export function loadSettings(): AppSettings {
|
|||||||
|
|
||||||
let settingsQueue: Promise<unknown> = Promise.resolve()
|
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
|
* 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
|
* 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 {
|
export function registerSettingsIpc(): void {
|
||||||
|
migrateDefaultsOnce()
|
||||||
ipcMain.handle(Ipc.SETTINGS_GET, () => loadSettings())
|
ipcMain.handle(Ipc.SETTINGS_GET, () => loadSettings())
|
||||||
ipcMain.handle(Ipc.SETTINGS_SET, (_event, next: AppSettings) => saveSettings(next))
|
ipcMain.handle(Ipc.SETTINGS_SET, (_event, next: AppSettings) => saveSettings(next))
|
||||||
}
|
}
|
||||||
@@ -80,6 +80,7 @@ const api: AppApi = {
|
|||||||
logStop: (sessionId: string) => ipcRenderer.invoke(Ipc.LOG_STOP, sessionId),
|
logStop: (sessionId: string) => ipcRenderer.invoke(Ipc.LOG_STOP, sessionId),
|
||||||
listSessionLogs: () => ipcRenderer.invoke(Ipc.LOG_LIST),
|
listSessionLogs: () => ipcRenderer.invoke(Ipc.LOG_LIST),
|
||||||
openLogsDir: () => ipcRenderer.send(Ipc.LOG_OPEN_DIR),
|
openLogsDir: () => ipcRenderer.send(Ipc.LOG_OPEN_DIR),
|
||||||
|
openDirectory: (dir: string) => ipcRenderer.invoke(Ipc.CWD_OPEN, dir),
|
||||||
|
|
||||||
listConnections: () => ipcRenderer.invoke(Ipc.CONNECTIONS_LIST),
|
listConnections: () => ipcRenderer.invoke(Ipc.CONNECTIONS_LIST),
|
||||||
saveConnection: (input: SshConnectionInput) => ipcRenderer.invoke(Ipc.CONNECTIONS_SAVE, input),
|
saveConnection: (input: SshConnectionInput) => ipcRenderer.invoke(Ipc.CONNECTIONS_SAVE, input),
|
||||||
|
|||||||
@@ -18,12 +18,14 @@
|
|||||||
margin: 0;
|
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;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.commands-panel .ant-tabs-body,
|
||||||
.commands-panel .ant-tabs-content,
|
.commands-panel .ant-tabs-content,
|
||||||
.commands-panel .ant-tabs-tabpane {
|
.commands-panel .ant-tabs-tabpane {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ if (typeof window !== 'undefined' && !window.api) {
|
|||||||
logStop: async () => undefined,
|
logStop: async () => undefined,
|
||||||
listSessionLogs: async () => [],
|
listSessionLogs: async () => [],
|
||||||
openLogsDir: noop,
|
openLogsDir: noop,
|
||||||
|
openDirectory: async () => false,
|
||||||
listConnections: async () => [],
|
listConnections: async () => [],
|
||||||
saveConnection: async (input) => ({
|
saveConnection: async (input) => ({
|
||||||
id: input.id ?? stubId(),
|
id: input.id ?? stubId(),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Button, Progress, Select, Switch, Tag } from 'antd'
|
|||||||
import type { AppInfo, ReleaseNote, UpdateState, UpdateStatus } from '@shared/ipc'
|
import type { AppInfo, ReleaseNote, UpdateState, UpdateStatus } from '@shared/ipc'
|
||||||
import { useSettingsStore } from './store'
|
import { useSettingsStore } from './store'
|
||||||
import appIconUrl from '../../../../build/icon.png'
|
import appIconUrl from '../../../../build/icon.png'
|
||||||
|
import changelogMd from '../../../../CHANGELOG.md?raw'
|
||||||
|
|
||||||
const STATUS_TEXT: Record<UpdateStatus, string> = {
|
const STATUS_TEXT: Record<UpdateStatus, string> = {
|
||||||
idle: '点击检查更新',
|
idle: '点击检查更新',
|
||||||
@@ -15,7 +16,22 @@ const STATUS_TEXT: Record<UpdateStatus, string> = {
|
|||||||
dev: '开发模式不支持更新检查'
|
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 {
|
export function AboutTab(): React.JSX.Element {
|
||||||
const [version, setVersion] = useState('')
|
const [version, setVersion] = useState('')
|
||||||
const [state, setState] = useState<UpdateState | null>(null)
|
const [state, setState] = useState<UpdateState | null>(null)
|
||||||
@@ -25,7 +41,10 @@ export function AboutTab(): React.JSX.Element {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void window.api.appInfo().then((i: AppInfo) => setVersion(i.appVersion))
|
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)
|
return window.api.onUpdateState(setState)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ export function RenderSettingsTab(): React.JSX.Element {
|
|||||||
desc="关闭后不再记录新命令;已保存的历史仍然保留"
|
desc="关闭后不再记录新命令;已保存的历史仍然保留"
|
||||||
control={
|
control={
|
||||||
<Switch
|
<Switch
|
||||||
checked={settings.terminal.historyEnabled !== false}
|
checked={settings.terminal.historyEnabled}
|
||||||
onChange={(checked) => void updateTerminal({ historyEnabled: checked })}
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -382,13 +412,11 @@ export function SystemSettingsTab(): React.JSX.Element {
|
|||||||
/>
|
/>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label="全局唤起快捷键"
|
label="全局唤起快捷键"
|
||||||
desc="Control+Shift+Alt+T 等,留空禁用"
|
desc="点击输入框后直接按下组合键;Esc 取消,退格清空禁用"
|
||||||
control={
|
control={
|
||||||
<Input
|
<ShortcutInput
|
||||||
className="settings-select"
|
|
||||||
placeholder="如 Control+Shift+Alt+T,留空禁用"
|
|
||||||
value={settings.system.globalShowHide ?? ''}
|
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
|
* Live preview of the current font settings, rendered as an `ls -la`-style
|
||||||
* terminal screen with ANSI-colored tokens on the active theme background.
|
* terminal screen with ANSI-colored tokens on the active theme background.
|
||||||
|
|||||||
@@ -58,7 +58,8 @@
|
|||||||
min-height: 0;
|
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;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
@@ -240,8 +241,6 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
max-height: 480px;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 4px 2px 12px;
|
padding: 4px 2px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -534,3 +533,15 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
line-height: 1.6;
|
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);
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { FitAddon } from '@xterm/addon-fit'
|
|||||||
import { WebglAddon } from '@xterm/addon-webgl'
|
import { WebglAddon } from '@xterm/addon-webgl'
|
||||||
import { SearchAddon } from '@xterm/addon-search'
|
import { SearchAddon } from '@xterm/addon-search'
|
||||||
import '@xterm/xterm/css/xterm.css'
|
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 { Checkbox, Modal } from 'antd'
|
||||||
import { getThemeById } from '@shared/theme'
|
import { getThemeById } from '@shared/theme'
|
||||||
import type { CommandItem } from '@shared/commands'
|
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 */
|
/** called once after the pty process exits and the user closes the dead pane */
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
className?: string
|
className?: string
|
||||||
|
/** ssh sessions have a remote cwd — the open-directory button is hidden */
|
||||||
|
isSsh?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SearchBarProps {
|
interface SearchBarProps {
|
||||||
@@ -248,7 +250,7 @@ function differenceWrite(sessionId: string, full: string, buf: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?: Ref<TerminalHandle> }> =
|
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 settings = useSettingsStore((s) => s.settings)
|
||||||
|
|
||||||
const hostRef = useRef<HTMLDivElement | null>(null)
|
const hostRef = useRef<HTMLDivElement | null>(null)
|
||||||
@@ -1164,27 +1166,45 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="term-recbar">
|
<div className="term-recbar">
|
||||||
<button
|
{settings.terminal.showRecButton && (
|
||||||
type="button"
|
<button
|
||||||
className={`term-rec-btn${recording ? ' is-recording' : ''}`}
|
type="button"
|
||||||
title={recording ? '停止记录会话日志' : '开始记录会话日志'}
|
className={`term-rec-btn${recording ? ' is-recording' : ''}`}
|
||||||
aria-label={recording ? '停止记录' : '开始记录'}
|
title={recording ? '停止记录会话日志' : '开始记录会话日志'}
|
||||||
onClick={() => {
|
aria-label={recording ? '停止记录' : '开始记录'}
|
||||||
if (recording) void handleLogStop()
|
onClick={() => {
|
||||||
else void handleLogStart()
|
if (recording) void handleLogStop()
|
||||||
}}
|
else void handleLogStart()
|
||||||
>
|
}}
|
||||||
{recording ? <PauseOutlined /> : <SoundOutlined />}
|
>
|
||||||
</button>
|
{recording ? <PauseOutlined /> : <SoundOutlined />}
|
||||||
<button
|
</button>
|
||||||
type="button"
|
)}
|
||||||
className="term-rec-btn"
|
{settings.terminal.showOpenLogsButton && (
|
||||||
title="打开日志目录"
|
<button
|
||||||
aria-label="打开日志目录"
|
type="button"
|
||||||
onClick={() => window.api.openLogsDir()}
|
className="term-rec-btn"
|
||||||
>
|
title="打开日志目录"
|
||||||
<FolderOpenOutlined />
|
aria-label="打开日志目录"
|
||||||
</button>
|
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>
|
</div>
|
||||||
<Modal
|
<Modal
|
||||||
open={paste !== null}
|
open={paste !== null}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps
|
|||||||
const terminal = (
|
const terminal = (
|
||||||
<TerminalView
|
<TerminalView
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
|
isSsh={isSsh}
|
||||||
className="workspace-terminal-view"
|
className="workspace-terminal-view"
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
if (closedRef.current) return
|
if (closedRef.current) return
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ export interface AppApi {
|
|||||||
logStop(sessionId: string): Promise<void>
|
logStop(sessionId: string): Promise<void>
|
||||||
listSessionLogs(): Promise<SessionLogMeta[]>
|
listSessionLogs(): Promise<SessionLogMeta[]>
|
||||||
openLogsDir(): void
|
openLogsDir(): void
|
||||||
|
/** open a local directory in the OS file manager; false if not a directory */
|
||||||
|
openDirectory(dir: string): Promise<boolean>
|
||||||
|
|
||||||
// ---- ssh connections ----
|
// ---- ssh connections ----
|
||||||
listConnections(): Promise<SshConnection[]>
|
listConnections(): Promise<SshConnection[]>
|
||||||
|
|||||||
+3
-1
@@ -106,7 +106,9 @@ export const Ipc = {
|
|||||||
/** resolve a cd-style argument against the current cwd, platform-aware */
|
/** resolve a cd-style argument against the current cwd, platform-aware */
|
||||||
CWD_RESOLVE: 'session:cwdResolve',
|
CWD_RESOLVE: 'session:cwdResolve',
|
||||||
/** normalize a cwd reported by the shell (OSC 7 / OSC 9;9) */
|
/** 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
|
} as const
|
||||||
|
|
||||||
export interface PtyCreateOptions {
|
export interface PtyCreateOptions {
|
||||||
|
|||||||
+11
-2
@@ -40,6 +40,12 @@ export interface TerminalSettings {
|
|||||||
historyEnabled: boolean
|
historyEnabled: boolean
|
||||||
/** max entries kept in command history */
|
/** max entries kept in command history */
|
||||||
historyLimit: number
|
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 */
|
/** accent color for the active tab outline, rail indicator and SSH badges */
|
||||||
tabAccentColor: string
|
tabAccentColor: string
|
||||||
}
|
}
|
||||||
@@ -117,9 +123,12 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
autoWrap: true,
|
autoWrap: true,
|
||||||
copyOnSelect: false,
|
copyOnSelect: false,
|
||||||
pasteRiskConfirm: true,
|
pasteRiskConfirm: true,
|
||||||
suggestEnabled: true,
|
suggestEnabled: false,
|
||||||
historyEnabled: true,
|
historyEnabled: false,
|
||||||
historyLimit: 100,
|
historyLimit: 100,
|
||||||
|
showRecButton: false,
|
||||||
|
showOpenLogsButton: false,
|
||||||
|
showOpenCwdButton: true,
|
||||||
tabAccentColor: '#3fb950'
|
tabAccentColor: '#3fb950'
|
||||||
},
|
},
|
||||||
customThemes: [],
|
customThemes: [],
|
||||||
|
|||||||
Reference in new issue
Block a user