Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1744184a3 | ||
|
|
b3744b4705 | ||
|
|
9e453e272c | ||
|
|
237265e6ba | ||
|
|
3b2fe06f71 | ||
|
|
97fc7171cc | ||
|
|
d3d029fe4f | ||
|
|
77fd241b43 | ||
|
|
4b336360f8 | ||
|
|
d7abbaad9c | ||
|
|
cbf4c224fb | ||
|
|
03aa79a691 | ||
|
|
458dd9c88b | ||
|
|
28d8af6982 | ||
|
|
f09bed06b5 | ||
|
|
fcb2781694 | ||
|
|
0b4a87f1e7 | ||
|
|
01827bf70b | ||
|
|
9a330499de | ||
|
|
3c193a48a7 | ||
|
|
9467b78560 | ||
|
|
be7332db17 | ||
|
|
bbee1d642d | ||
|
|
3aa8b28f89 | ||
|
|
277d8bb117 | ||
|
|
beeb32a76b | ||
|
|
8eacd34338 | ||
|
|
6c100542c5 | ||
|
|
e6fc021903 | ||
|
|
c4fb6fe7e2 | ||
|
|
f63ee379a4 | ||
|
|
ce89dcb1e4 | ||
|
|
acdf955918 | ||
|
|
ab5f8c2f77 |
No files matched your search
@@ -35,3 +35,6 @@ release/
|
||||
|
||||
# release notes draft (consumed by scripts/release.cjs)
|
||||
RELEASE_NOTES.md
|
||||
|
||||
tmp-test/
|
||||
tmp-test*
|
||||
@@ -5,6 +5,7 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
|
||||
## 常用命令
|
||||
|
||||
- 开发:`npm run dev`(主进程改动不热重建,需重启)
|
||||
- dev 实例使用独立用户数据目录 `%APPDATA%\OpenTerminal-dev` 与独立单实例锁(`src/main/index.ts` 顶部 `!app.isPackaged` 分支),窗口标题带 `(dev)`:**可与已安装的正式版同时运行,互不干扰**,也不会把测试设置/会话写进真实配置
|
||||
- 类型检查:`npx tsc --noEmit -p tsconfig.web.json`
|
||||
- 打包:`npm run dist`,产物在 `release/`(msi + exe + latest.yml + blockmap)
|
||||
- 国内网络需镜像:`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ npm run dist`
|
||||
@@ -24,12 +25,12 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
|
||||
|
||||
1. `package.json` 版本号 +1,写 `RELEASE_NOTES.md`(仓库根,已 gitignore)
|
||||
2. `npm run dist` 构建
|
||||
3. `node scripts/release.cjs <版本号>`,例如 `node scripts/release.cjs 1.0.2`
|
||||
- 依次完成:Gitea release(含 msi/exe 资产)→ Gitea 更新通道(`api/packages/admin/generic/openterminal-update/stable`,更新 latest.yml/blockmap/exe/release-notes.md)→ GitHub release
|
||||
- 可用 `--skip-github` / `--skip-gitea` 跳过某步
|
||||
- 访问 GitHub API 不通时,设 `HTTPS_PROXY=http://127.0.0.1:7897` 再走代理
|
||||
3. `node scripts/release.cjs <版本号> --skip-github`,例如 `node scripts/release.cjs 1.0.11 --skip-github`
|
||||
- **只发国内**(2026-09-15 起的产品决定):Gitea release(含 msi/exe 资产)→ Gitea 更新通道(`api/packages/admin/generic/openterminal-update/stable`,更新 latest.yml/blockmap/exe/release-notes.md)
|
||||
- 国内通道全程直连,**不需要设代理**;代理只在显式补发 GitHub 时才用(`HTTPS_PROXY=http://127.0.0.1:7897`,脚本只把它用于 GitHub 请求)
|
||||
- GitHub 那一步的状态:`README` 与更新机制里仍保留 GitHub 作为更新回退源,但**release 资产不再随版本发布同步**;若某天需要补,跑一次 `node scripts/release.cjs <版本号> --skip-gitea` 即可
|
||||
4. 验证更新通道:`curl https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/latest.yml` 应返回新版本号
|
||||
5. `git tag v<版本号>` 并推送两个远程
|
||||
5. `git tag v<版本号>` 并推送两个远程(代码/tag 的镜像保持同步,仅 release 资产不发 GitHub)
|
||||
|
||||
## 更新机制
|
||||
|
||||
@@ -41,3 +42,8 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
|
||||
|
||||
- 终端主题由 xterm 主题派生 UI 配色:`src/renderer/src/theme/chrome.ts` 的 `applyChromeTheme` 写入 `--chrome-bg/-bg-deep/-border/-hover` CSS 变量,antd token 在 `main.tsx` ThemedConfigProvider 派生
|
||||
- 标签强调色:`settings.tabAccentColor`(默认 `#3fb950`),经 `--tab-accent` CSS 变量生效
|
||||
|
||||
## 终端尺寸同步
|
||||
|
||||
- `TerminalView.scheduleFit`:fit 后**去抖 100ms** 再把 cols/rows 发给 PTY,并跳过与上次相同的尺寸。每次 ResizeObserver 都戳 PTY 会让全屏 TUI(Claude Code 等)在最大化/还原的中间尺寸上反复重绘,留下重复帧
|
||||
- 拖动窗口期间 xterm 网格立即更新,PTY 尺寸在停止后 100ms 生效
|
||||
+1
-1
@@ -73,7 +73,7 @@
|
||||
## M6 — 多终端协同 ✅
|
||||
|
||||
- [x] 广播输入(broadcastStore):选中 ≥2 个会话同步键入;标签广播标记 + 按钮计数徽标;目标 <2 自动关闭
|
||||
- [x] 快捷输入面板(Quick Input):右下角浮层 = 命令输入 + 自动执行(\r) + 快捷命令 + 最近命令;广播感知发送
|
||||
- [x] ~~快捷输入面板(Quick Input)~~(已移除:全局单例开关与面板不对一一对应,功能与行内补全/侧边栏命令面板重叠)
|
||||
- [x] ZMODEM(rz/sz):主进程引擎(zmodem.js,仅 SSH 会话;本地 ConPTY 不支持二进制),文件选择/保存目录对话框 + 传输面板进度 + 看门狗超时;双向 e2e(tests/zmodem-e2e.mjs)
|
||||
- [x] 快捷键系统:Ctrl+=/-/0 字号(持久化)、Ctrl+PgUp/PgDn 切换终端、全局唤起/隐藏(设置页可配 globalShowHide)
|
||||
- 验收:3 个分屏同步执行命令 ✅(活体验证:终端 3 键入扇出到终端 4);rz 传输 ✅(e2e 双向内容一致;真实服务器 rz/sz 待用户复测)
|
||||
|
||||
@@ -37,6 +37,9 @@ nsis:
|
||||
oneClick: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
perMachine: false
|
||||
# Installed shortcuts read "OTerminal"; productName stays OpenTerminal so the
|
||||
# userData dir and window title are untouched.
|
||||
shortcutName: OTerminal
|
||||
createDesktopShortcut: true
|
||||
createStartMenuShortcut: true
|
||||
artifactName: ${productName}-${version}-setup.${ext}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "open-terminal",
|
||||
"version": "0.8.0",
|
||||
"version": "1.0.12",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "open-terminal",
|
||||
"version": "0.8.0",
|
||||
"version": "1.0.12",
|
||||
"dependencies": {
|
||||
"@lydell/node-pty": "^1.2.0-beta.15",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "open-terminal",
|
||||
"productName": "OpenTerminal",
|
||||
"version": "1.0.4",
|
||||
"version": "1.0.12",
|
||||
"description": "Open-source terminal with SSH, split panes, themes and fonts",
|
||||
"main": "out/main/index.js",
|
||||
"author": "Bill",
|
||||
|
||||
+51
-16
@@ -31,15 +31,33 @@ const files = [
|
||||
for (const f of files) {
|
||||
if (!fs.existsSync(f)) { console.error('missing build artifact:', f); process.exit(1) }
|
||||
}
|
||||
|
||||
const proxy = process.env.HTTPS_PROXY || process.env.https_proxy || env.HTTPS_PROXY || env.PROXY
|
||||
if (proxy) {
|
||||
const { ProxyAgent, setGlobalDispatcher } = require('undici')
|
||||
setGlobalDispatcher(new ProxyAgent(proxy))
|
||||
console.log('using proxy:', proxy)
|
||||
// Guard rails: the artifacts must belong to the version being published, and
|
||||
// the tag must not exist yet — otherwise a re-run silently republishes a
|
||||
// *different* binary under an already-released version number, and installed
|
||||
// clients never see it (their version check compares numbers, not hashes).
|
||||
const pkgVersion = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version
|
||||
if (pkgVersion !== V) {
|
||||
console.error(`package.json is ${pkgVersion} but you asked to publish ${V}`)
|
||||
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]
|
||||
if (ymlVersion !== V) {
|
||||
console.error(`release/latest.yml says ${ymlVersion} but you asked to publish ${V} — rebuild first`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
async function upload(url, file, token, authScheme) {
|
||||
// Proxy applies to GitHub only. The domestic Gitea host is fastest — and most
|
||||
// reliable — over a direct connection; routing it through a local proxy is
|
||||
// what once broke a channel upload (ECONNRESET mid-PUT), and a proxy that is
|
||||
// down would take the domestic channel with it. So no global dispatcher here:
|
||||
// the agent is passed explicitly on GitHub requests.
|
||||
const proxy = process.env.HTTPS_PROXY || process.env.https_proxy || env.HTTPS_PROXY || env.PROXY
|
||||
const proxyAgent = proxy ? new (require('undici').ProxyAgent)(proxy) : undefined
|
||||
if (proxy) console.log('proxy for GitHub only:', proxy)
|
||||
|
||||
async function upload(url, file, token, authScheme, viaProxy = false) {
|
||||
const name = path.basename(file)
|
||||
const stat = fs.statSync(file)
|
||||
const resp = await fetch(`${url}${url.includes('?') ? '&' : '?'}name=${encodeURIComponent(name)}`, {
|
||||
@@ -50,10 +68,14 @@ async function upload(url, file, token, authScheme) {
|
||||
'Content-Length': String(stat.size)
|
||||
},
|
||||
body: fs.createReadStream(file),
|
||||
duplex: 'half'
|
||||
duplex: 'half',
|
||||
...(viaProxy && proxyAgent ? { dispatcher: proxyAgent } : {})
|
||||
})
|
||||
console.log(` upload ${name}: HTTP ${resp.status}`)
|
||||
if (!resp.ok) console.log(' ', (await resp.text()).slice(0, 300))
|
||||
if (!resp.ok) {
|
||||
const body = (await resp.text()).slice(0, 300)
|
||||
throw new Error(`upload failed (${resp.status}) for ${name}: ${body}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function gitea() {
|
||||
@@ -64,7 +86,7 @@ async function gitea() {
|
||||
headers: { Authorization: `token ${env.GIT_TOKEN}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tag_name: `v${V}`, name: `OpenTerminal v${V}`, body: notes, draft: false, prerelease: false })
|
||||
})
|
||||
if (!resp.ok) { console.log('create failed:', resp.status, (await resp.text()).slice(0, 300)); return }
|
||||
if (!resp.ok) { throw new Error(`Gitea release create failed: ${resp.status} ${(await resp.text()).slice(0, 300)}`) }
|
||||
const rel = await resp.json()
|
||||
console.log('release created, id =', rel.id)
|
||||
for (const f of files) await upload(`${base}/releases/${rel.id}/assets`, f, env.GIT_TOKEN, 'token')
|
||||
@@ -73,8 +95,6 @@ async function gitea() {
|
||||
async function giteaChannel() {
|
||||
console.log('=== Gitea update channel ===')
|
||||
const base = 'https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable'
|
||||
const del = await fetch(base, { method: 'DELETE', headers: { Authorization: `token ${env.GIT_TOKEN}` } })
|
||||
console.log(' delete old stable:', del.status)
|
||||
const channelFiles = [
|
||||
[path.join(R, 'latest.yml'), 'latest.yml'],
|
||||
[path.join(R, `OpenTerminal-${V}-setup.exe.blockmap`), `OpenTerminal-${V}-setup.exe.blockmap`],
|
||||
@@ -83,7 +103,20 @@ async function giteaChannel() {
|
||||
// releases API 404s), but the generic package is publicly readable.
|
||||
[path.join(ROOT, 'RELEASE_NOTES.md'), 'release-notes.md']
|
||||
]
|
||||
for (const [f, name] of channelFiles) {
|
||||
// Validate before touching the channel: a missing file after the DELETE would
|
||||
// leave the update channel empty (clients then fall back to GitHub, which is
|
||||
// unreachable in China for most users).
|
||||
for (const [f] of channelFiles) {
|
||||
if (!fs.existsSync(f)) throw new Error(`missing channel file: ${f}`)
|
||||
}
|
||||
// latest.yml goes last: it is what makes clients start downloading, so the
|
||||
// payload must already be in place.
|
||||
const isLatest = ([f]) => path.basename(f) === 'latest.yml'
|
||||
const ordered = [...channelFiles.filter((e) => !isLatest(e)), ...channelFiles.filter(isLatest)]
|
||||
// Replace the version only once every file is known to be present on disk.
|
||||
const del = await fetch(base, { method: 'DELETE', headers: { Authorization: `token ${env.GIT_TOKEN}` } })
|
||||
console.log(' delete old stable:', del.status)
|
||||
for (const [f, name] of ordered) {
|
||||
const stat = fs.statSync(f)
|
||||
const resp = await fetch(`${base}/${encodeURIComponent(name)}`, {
|
||||
method: 'PUT',
|
||||
@@ -92,6 +125,7 @@ async function giteaChannel() {
|
||||
duplex: 'half'
|
||||
})
|
||||
console.log(` put ${name}: HTTP ${resp.status}`)
|
||||
if (!resp.ok) throw new Error(`channel put failed (${resp.status}) for ${name}: ${(await resp.text()).slice(0, 200)}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,14 +139,15 @@ async function github() {
|
||||
'User-Agent': 'OpenTerminal',
|
||||
Accept: 'application/vnd.github+json'
|
||||
},
|
||||
body: JSON.stringify({ tag_name: `v${V}`, name: `OpenTerminal v${V}`, body: notes, draft: false, prerelease: false })
|
||||
body: JSON.stringify({ tag_name: `v${V}`, name: `OpenTerminal v${V}`, body: notes, draft: false, prerelease: false }),
|
||||
...(proxyAgent ? { dispatcher: proxyAgent } : {})
|
||||
})
|
||||
if (!resp.ok) { console.log('create failed:', resp.status, (await resp.text()).slice(0, 300)); return }
|
||||
if (!resp.ok) { throw new Error(`GitHub release create failed: ${resp.status} ${(await resp.text()).slice(0, 300)}`) }
|
||||
const rel = await resp.json()
|
||||
console.log('release created, id =', rel.id)
|
||||
const up = `https://uploads.github.com/repos/billowliu2/OpenTerminal/releases/${rel.id}/assets`
|
||||
for (const f of [...files, path.join(R, `OpenTerminal-${V}-setup.exe.blockmap`), path.join(R, 'latest.yml')]) {
|
||||
await upload(up, f, env.GH_TOKEN, 'Bearer')
|
||||
await upload(up, f, env.GH_TOKEN, 'Bearer', true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+120
-10
@@ -24,9 +24,14 @@ import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs'
|
||||
import { appendFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import type { CommandItem, SessionLogMeta } from '../shared/commands'
|
||||
import { DEFAULT_SETTINGS } from '../shared/settings'
|
||||
import { loadSettings } from './settingsStore'
|
||||
import { LogSanitizer } from './logSanitizer'
|
||||
|
||||
/** Upper bound on recorded history entries. */
|
||||
/** Upper bound on recorded history entries when no limit is configured. */
|
||||
const HISTORY_CAP = 500
|
||||
/** Hard ceiling: the history is also the completion source, so it stays small. */
|
||||
export const HISTORY_LIMIT_MAX = 500
|
||||
|
||||
interface CommandsFile {
|
||||
history: CommandItem[]
|
||||
@@ -67,6 +72,18 @@ export class CommandsStore {
|
||||
private readonly metasByFile = new Map<string, SessionLogMeta>()
|
||||
/** Logs currently accumulating output, keyed by sessionId. */
|
||||
private readonly activeBySession = new Map<string, SessionLogMeta>()
|
||||
/**
|
||||
* Buffered plain text per log file, flushed by a single drain loop per file.
|
||||
* Writes land in the buffer synchronously (so order is preserved) and the
|
||||
* drain merges everything that accumulated during the previous appendFile
|
||||
* round trip into one append call — burst output costs one syscall per IO
|
||||
* tick instead of one Promise chain link per logWrite.
|
||||
*/
|
||||
private readonly logBuffers = new Map<string, string>()
|
||||
/** In-flight drain loop per log file; absent when the file is settled. */
|
||||
private readonly logDrains = new Map<string, Promise<void>>()
|
||||
/** Plain-text transformer per actively-logged session (see logSanitizer). */
|
||||
private readonly sanitizers = new Map<string, LogSanitizer>()
|
||||
|
||||
constructor(userData?: string, openPathImpl?: (p: string) => Promise<unknown>) {
|
||||
const ud = userData ?? app.getPath('userData')
|
||||
@@ -100,15 +117,32 @@ export class CommandsStore {
|
||||
writeFileSync(this.file, JSON.stringify(data, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
/** Record one executed command: trim; dedupe vs newest; move to front; cap. */
|
||||
/**
|
||||
* Record one executed command.
|
||||
*
|
||||
* Deduplication is over the *whole* history, not just the newest entry: a
|
||||
* command used three commands ago is the same command, so re-running it moves
|
||||
* the existing entry to the front (refreshing `lastUsedAt`) instead of adding
|
||||
* a second copy. History reads back sorted by `lastUsedAt`, so hoisting is
|
||||
* exactly what "most recently used" means — and it keeps the list from
|
||||
* filling up with one line repeated.
|
||||
*
|
||||
* Silently does nothing when 设置 → 终端 → 记录命令历史 is off: the setting is
|
||||
* read here (not at the call site) so every recording path honours it, and
|
||||
* existing history is left untouched rather than cleared.
|
||||
*/
|
||||
recordCommand(cmd: string): void {
|
||||
const trimmed = cmd.trim()
|
||||
if (!trimmed) return
|
||||
const { historyEnabled, historyLimit } = loadHistoryPrefs()
|
||||
if (!historyEnabled) return
|
||||
const cap = normalizeLimit(historyLimit)
|
||||
const data = this.loadCommands()
|
||||
const top = data.history[0]
|
||||
if (top && top.command === trimmed) {
|
||||
// Same as the newest entry: refresh its recency (already at the front).
|
||||
top.lastUsedAt = Date.now()
|
||||
const existing = data.history.findIndex((item) => item.command === trimmed)
|
||||
if (existing >= 0) {
|
||||
const [item] = data.history.splice(existing, 1)
|
||||
item.lastUsedAt = Date.now()
|
||||
data.history.unshift(item)
|
||||
} else {
|
||||
data.history.unshift({
|
||||
id: randomUUID(),
|
||||
@@ -116,16 +150,23 @@ export class CommandsStore {
|
||||
createdAt: Date.now(),
|
||||
lastUsedAt: Date.now()
|
||||
})
|
||||
if (data.history.length > HISTORY_CAP) data.history.length = HISTORY_CAP
|
||||
}
|
||||
if (data.history.length > cap) data.history.length = cap
|
||||
this.saveCommands(data)
|
||||
}
|
||||
|
||||
/** History sorted by most recently used. */
|
||||
/**
|
||||
* History sorted by most recently used, trimmed to the configured limit.
|
||||
*
|
||||
* The trim happens on read as well as on write so lowering the limit in
|
||||
* settings takes effect at once instead of waiting for the next command.
|
||||
*/
|
||||
listHistory(): CommandItem[] {
|
||||
const { historyLimit } = loadHistoryPrefs()
|
||||
return this.loadCommands()
|
||||
.history.slice()
|
||||
.sort((a, b) => (b.lastUsedAt ?? 0) - (a.lastUsedAt ?? 0))
|
||||
.slice(0, normalizeLimit(historyLimit))
|
||||
}
|
||||
|
||||
clearHistory(): void {
|
||||
@@ -235,6 +276,7 @@ export class CommandsStore {
|
||||
const meta: SessionLogMeta = { sessionId, file, fileName, startedAt: Date.now() }
|
||||
this.metasByFile.set(file, meta)
|
||||
this.activeBySession.set(sessionId, meta)
|
||||
this.sanitizers.set(sessionId, new LogSanitizer())
|
||||
this.persistIndex()
|
||||
return meta
|
||||
}
|
||||
@@ -246,15 +288,51 @@ export class CommandsStore {
|
||||
// not logging (or already stopped) -> ignore, prevents stop/append race
|
||||
return
|
||||
}
|
||||
appendFile(meta.file, data, 'utf8').catch(() => {
|
||||
// file may have been removed after stop -> ignore
|
||||
// Raw PTY output is escape-sequence soup in a text file; log plain text.
|
||||
const clean = this.sanitizers.get(sessionId)?.push(data) ?? ''
|
||||
if (!clean) return
|
||||
this.logBuffers.set(meta.file, (this.logBuffers.get(meta.file) ?? '') + clean)
|
||||
this.drainLog(meta.file)
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush a log file's buffer, keeping at most one drain (and therefore one
|
||||
* in-flight appendFile) per file. New writes that arrive mid-flush re-enter
|
||||
* the buffer and are picked up by the next loop iteration, merged.
|
||||
*/
|
||||
private drainLog(file: string): Promise<void> {
|
||||
const existing = this.logDrains.get(file)
|
||||
if (existing) return existing
|
||||
const run = (async (): Promise<void> => {
|
||||
for (;;) {
|
||||
const chunk = this.logBuffers.get(file)
|
||||
if (chunk === undefined) break
|
||||
this.logBuffers.delete(file)
|
||||
try {
|
||||
await appendFile(file, chunk, 'utf8')
|
||||
} catch {
|
||||
// file may have been removed after stop -> ignore
|
||||
}
|
||||
}
|
||||
})()
|
||||
run.finally(() => {
|
||||
if (this.logDrains.get(file) === run) this.logDrains.delete(file)
|
||||
})
|
||||
this.logDrains.set(file, run)
|
||||
return run
|
||||
}
|
||||
|
||||
/** Stop logging a session, stamping endedAt into the registry + index. */
|
||||
logStop(sessionId: string): void {
|
||||
const meta = this.activeBySession.get(sessionId)
|
||||
if (!meta) return
|
||||
const tail = this.sanitizers.get(sessionId)?.flush() ?? ''
|
||||
this.sanitizers.delete(sessionId)
|
||||
if (tail) {
|
||||
// Best effort: the trailing partial line belongs in the file too.
|
||||
this.logBuffers.set(meta.file, (this.logBuffers.get(meta.file) ?? '') + tail)
|
||||
this.drainLog(meta.file)
|
||||
}
|
||||
this.finishLog(meta)
|
||||
}
|
||||
|
||||
@@ -285,4 +363,36 @@ function describeFile(p: string): void {
|
||||
} catch {
|
||||
// best effort; appends will also create it lazily
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* History preferences straight from settings.
|
||||
*
|
||||
* Read on every use rather than cached: the store is constructed once at boot
|
||||
* but the user can flip the switch at any time, and a stale copy would keep
|
||||
* recording after they turned it off.
|
||||
*/
|
||||
function loadHistoryPrefs(): { historyEnabled: boolean; historyLimit: number } {
|
||||
try {
|
||||
const terminal = loadSettings().terminal
|
||||
return {
|
||||
historyEnabled: terminal.historyEnabled !== false,
|
||||
historyLimit: normalizeLimit(terminal.historyLimit)
|
||||
}
|
||||
} catch {
|
||||
// Settings unreadable (e.g. store used outside Electron in a test): fall
|
||||
// back to the same defaults the app ships with.
|
||||
return {
|
||||
historyEnabled: DEFAULT_SETTINGS.terminal.historyEnabled,
|
||||
historyLimit: DEFAULT_SETTINGS.terminal.historyLimit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Clamp a configured limit into 1..HISTORY_LIMIT_MAX; unusable → the default. */
|
||||
function normalizeLimit(value: unknown): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT_SETTINGS.terminal.historyLimit
|
||||
const rounded = Math.floor(value)
|
||||
if (rounded < 1) return 1
|
||||
return Math.min(rounded, HISTORY_LIMIT_MAX)
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import { statSync } from 'fs'
|
||||
import { homedir, hostname } from 'os'
|
||||
import { isAbsolute, resolve as resolvePath, normalize } from 'path'
|
||||
|
||||
/**
|
||||
* Resolve a cd-style argument against a known working directory.
|
||||
*
|
||||
* Lives in the main process because this is the only side with node's `path`,
|
||||
* and it must stay platform-aware for the cross-platform roadmap: `path.resolve`
|
||||
* already does the right thing for `C:\a`, `/home/a`, `..`, `.` and mixed
|
||||
* separators on the host platform, so the core carries no drive-letter or
|
||||
* backslash assumptions of its own.
|
||||
*
|
||||
* Returns the resolved, existing directory, or null when the argument does not
|
||||
* denote one (in which case callers keep the previous cwd).
|
||||
*/
|
||||
|
||||
/** Flags that a shell may put in front of the path (`cd /d X`, `sl -Path X`). */
|
||||
const LEADING_FLAGS = /^(?:\/d|-d|-p|-path|-literalpath)\s+/i
|
||||
|
||||
/** Everything from a command separator on is a different command. */
|
||||
const SEPARATOR = /\s*(?:&&|\|\||[;|])\s*/
|
||||
|
||||
export function resolveCwd(current: string | undefined, rawArg: string): string | null {
|
||||
let arg = rawArg.trim()
|
||||
// `cd X && something` — only the cd part is ours.
|
||||
arg = arg.split(SEPARATOR)[0]?.trim() ?? ''
|
||||
// Repeatedly strip shell flags (`cd /d "D:\x"`, `sl -Path .`).
|
||||
while (LEADING_FLAGS.test(arg)) arg = arg.replace(LEADING_FLAGS, '').trim()
|
||||
// Strip one layer of matching quotes ("D:\Program Files" / '~/src').
|
||||
const quoted = arg.match(/^(["'])(.*)\1$/)
|
||||
if (quoted) arg = quoted[2]
|
||||
|
||||
// Bare `cd`, `cd ~`, `cd $HOME` → home directory.
|
||||
if (arg === '' || arg === '~' || arg === '$HOME' || arg === '%USERPROFILE%') {
|
||||
return existingDir(homedir())
|
||||
}
|
||||
// `cd -` means the shell's previous directory, which we cannot know here.
|
||||
if (arg === '-') return null
|
||||
// Drive-relative `cd d:` (cmd/PowerShell): the target is the drive's
|
||||
// current directory, which the shell never tells us — path.isAbsolute('d:')
|
||||
// is false, so without this branch it resolves against the base and dies on
|
||||
// a nonexistent `<base>\d:`. The drive root is the fresh-shell answer.
|
||||
if (process.platform === 'win32' && /^[a-zA-Z]:$/.test(arg)) {
|
||||
return existingDir(`${arg.toUpperCase()}\\`)
|
||||
}
|
||||
|
||||
const base = current && current.trim() ? current : homedir()
|
||||
// An absolute argument wins; otherwise resolve against the base. Both forms
|
||||
// are handled by path.resolve on every platform.
|
||||
const target = isAbsolute(arg) ? normalize(arg) : resolvePath(base, arg)
|
||||
return existingDir(target)
|
||||
}
|
||||
|
||||
function existingDir(candidate: string): string | null {
|
||||
try {
|
||||
return statSync(candidate).isDirectory() ? candidate : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a cwd reported by the shell itself.
|
||||
*
|
||||
* Both portable forms land here:
|
||||
* - OSC 7: `file://<host>/<path>` (bash/zsh/fish, iTerm2, WezTerm, kitty…)
|
||||
* - OSC 9;9: a bare path (ConEmu convention, Windows shells)
|
||||
*
|
||||
* A report from a remote host is ignored (an ssh/container shell is not our
|
||||
* filesystem). On Windows the POSIX-ish `/C:/dir` form is folded back to
|
||||
* `C:\dir`; path.resolve does the rest per platform.
|
||||
*/
|
||||
export function normalizeReportedCwd(raw: string): string | null {
|
||||
let value = raw.trim()
|
||||
if (!value) return null
|
||||
|
||||
const fileUrl = value.match(/^file:\/\/([^/]*)(\/.*)$/i)
|
||||
if (fileUrl) {
|
||||
const host = fileUrl[1]
|
||||
if (host && !isLocalHost(host)) return null
|
||||
value = safeDecode(fileUrl[2])
|
||||
// `/C:/Users/x` → `C:/Users/x` (Windows shells report the URL this way).
|
||||
const drive = value.match(/^\/([A-Za-z]:)(\/.*)?$/)
|
||||
if (drive && process.platform === 'win32') value = `${drive[1]}${drive[2] ?? '\\'}`
|
||||
}
|
||||
|
||||
// A Windows drive path reported with forward slashes still resolves fine.
|
||||
return existingDir(resolvePath(value)) ?? existingDir(value)
|
||||
}
|
||||
|
||||
function isLocalHost(host: string): boolean {
|
||||
const lower = host.toLowerCase()
|
||||
return lower === 'localhost' || lower === '127.0.0.1' || lower === '::1' || lower === osHostname()
|
||||
}
|
||||
|
||||
function osHostname(): string {
|
||||
try {
|
||||
return hostname().toLowerCase()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function safeDecode(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,15 @@ function showOrCreate(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Dev runs get their own userData directory (settings, session snapshot,
|
||||
// command history, logs) *and* their own single-instance lock — both are keyed
|
||||
// on that path. Without this a dev instance fights the installed build: it
|
||||
// takes the lock, so the other side is refused / must be killed, and it writes
|
||||
// test data straight into the real profile. Must run before the lock below.
|
||||
if (!app.isPackaged) {
|
||||
app.setPath('userData', join(app.getPath('appData'), 'OpenTerminal-dev'))
|
||||
}
|
||||
|
||||
// Single instance: a second launch just surfaces the existing window (pulls
|
||||
// it out of the tray if hidden there) instead of starting another process.
|
||||
const gotSingleInstanceLock = app.requestSingleInstanceLock()
|
||||
@@ -64,6 +73,15 @@ function createWindow(): void {
|
||||
|
||||
win.on('ready-to-show', () => win.show())
|
||||
|
||||
// Dev window wears a "(dev)" tag so it is never confused with the installed
|
||||
// build sitting next to it (they no longer share userData — see above).
|
||||
if (!app.isPackaged) {
|
||||
win.on('page-title-updated', (e) => {
|
||||
e.preventDefault()
|
||||
win.setTitle('OpenTerminal (dev)')
|
||||
})
|
||||
}
|
||||
|
||||
// Close button → tray / exit per the closeAction setting (ask by default).
|
||||
win.on('close', (e) => {
|
||||
void onMainWindowClose(win, e, showOrCreate)
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { HostKeyAction, SessionOpenOptions, SshConnection, SshConnectionInp
|
||||
import { getLayout, listLayouts, saveLayout, deleteLayout } from './layouts'
|
||||
import { startPolling, stopPolling } from './sysinfo'
|
||||
import { registerSettingsIpc } from './settingsStore'
|
||||
import { registerSessionStateIpc } from './sessionState'
|
||||
import { normalizeReportedCwd, resolveCwd } from './cwd'
|
||||
import { ConnectionsStore, defaultConnectionsPath } from './connectionsStore'
|
||||
import { KnownHostsStore, defaultKnownHostsPath } from './knownHosts'
|
||||
import { resolveHostKey } from './ssh'
|
||||
@@ -171,4 +173,12 @@ export function registerIpc(): void {
|
||||
ipcMain.on(Ipc.LOG_OPEN_DIR, () => cmds.openLogsDir())
|
||||
|
||||
registerSettingsIpc()
|
||||
registerSessionStateIpc()
|
||||
|
||||
// Resolve a cd-style argument against the current cwd (platform-aware; only
|
||||
// the main process has node's `path`).
|
||||
ipcMain.handle(Ipc.CWD_RESOLVE, (_event, current: string | undefined, arg: string) =>
|
||||
resolveCwd(current, arg)
|
||||
)
|
||||
ipcMain.handle(Ipc.CWD_REPORT, (_event, payload: string) => normalizeReportedCwd(payload))
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Plain-text sanitizer for session logs.
|
||||
*
|
||||
* Raw PTY output is unreadable in a text file: SGR colors, cursor moves,
|
||||
* synchronized-output markers — and for full-screen TUIs (vim, htop) every
|
||||
* redrawn frame. For each logged session this filter:
|
||||
* 1. strips ANSI escape sequences (CSI / OSC / single-char), statefully
|
||||
* across chunk boundaries (a sequence may split between two PTY chunks);
|
||||
* 2. collapses carriage-return overwrites (progress bars, spinner lines)
|
||||
* keeping only the final text of the line — a `\r` directly followed by
|
||||
* `\n` is a line ending, not an overwrite;
|
||||
* 3. drops output produced while the alternate screen is active (TUI
|
||||
* redraw frames), emitting one marker line per suppressed span.
|
||||
*
|
||||
* Note: inline TUIs that redraw in the *normal* buffer (Ink-based CLIs such
|
||||
* as Claude Code / kimi) cannot be frame-collapsed without full screen
|
||||
* emulation; their committed lines are preserved as-is.
|
||||
*/
|
||||
|
||||
const MARKER = '\n──── [全屏界面(TUI)输出已省略] ────\n'
|
||||
|
||||
type Mode = 'text' | 'esc' | 'esc-skip1' | 'csi' | 'osc' | 'osc-esc'
|
||||
|
||||
/** Alt-screen toggles: CSI ? 1049|1047|47 h / l */
|
||||
const ALT_ON = /^\?(?:1049|1047|47)h$/
|
||||
const ALT_OFF = /^\?(?:1049|1047|47)l$/
|
||||
|
||||
export class LogSanitizer {
|
||||
private mode: Mode = 'text'
|
||||
/** raw bytes of the escape sequence in flight (bounded: OSC can be long) */
|
||||
private seq = ''
|
||||
private alt = false
|
||||
/** some output was suppressed since the last marker */
|
||||
private altDirty = false
|
||||
/** current line, not yet newline-terminated */
|
||||
private line = ''
|
||||
/** previous text char was \r — the next char decides ending vs overwrite */
|
||||
private cr = false
|
||||
|
||||
/** Transform one raw chunk; returns the plain text to append ('' for none). */
|
||||
push(data: string): string {
|
||||
let out = ''
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const c = data[i]
|
||||
|
||||
// A pending \r: `\r\n` completes the line; anything else overwrites it.
|
||||
if (this.cr) {
|
||||
this.cr = false
|
||||
if (c === '\n') {
|
||||
out += this.emitLine()
|
||||
continue
|
||||
}
|
||||
this.line = ''
|
||||
// fall through and process c normally
|
||||
}
|
||||
|
||||
switch (this.mode) {
|
||||
case 'text':
|
||||
if (c === '\x1b') {
|
||||
this.mode = 'esc'
|
||||
this.seq = ''
|
||||
} else if (c === '\r') {
|
||||
this.cr = true
|
||||
} else if (c === '\n') {
|
||||
out += this.emitLine()
|
||||
} else if (c === '\t' || c >= ' ' || c === '\x7f') {
|
||||
// printable + tab; DEL and C0 controls (bell etc.) are dropped
|
||||
if (this.alt) this.altDirty = true
|
||||
else this.line += c
|
||||
}
|
||||
break
|
||||
case 'esc':
|
||||
if (c === '[') this.mode = 'csi'
|
||||
else if (c === ']') this.mode = 'osc'
|
||||
else if (c === '(' || c === ')' || c === '#' || c === '%') this.mode = 'esc-skip1'
|
||||
else this.mode = 'text' // single-character escape, consumed
|
||||
break
|
||||
case 'esc-skip1':
|
||||
this.mode = 'text'
|
||||
break
|
||||
case 'csi':
|
||||
if (c >= '@' && c <= '~') {
|
||||
// the final byte is not part of `seq` — test the full sequence
|
||||
const full = this.seq + c
|
||||
if (ALT_ON.test(full)) {
|
||||
this.alt = true
|
||||
} else if (ALT_OFF.test(full)) {
|
||||
this.alt = false
|
||||
if (this.altDirty) {
|
||||
this.altDirty = false
|
||||
out += MARKER
|
||||
}
|
||||
}
|
||||
this.seq = ''
|
||||
this.mode = 'text'
|
||||
} else if (this.seq.length < 1024) {
|
||||
this.seq += c
|
||||
}
|
||||
break
|
||||
case 'osc':
|
||||
if (c === '\x07') this.mode = 'text'
|
||||
else if (c === '\x1b') this.mode = 'osc-esc'
|
||||
break
|
||||
case 'osc-esc':
|
||||
// ST is ESC \; a new ESC [ / ESC ] starts the next sequence
|
||||
if (c === '\\') this.mode = 'text'
|
||||
else if (c === '[') {
|
||||
this.mode = 'csi'
|
||||
this.seq = ''
|
||||
} else if (c === ']') this.mode = 'osc'
|
||||
else this.mode = 'text'
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Flush the pending partial line at log stop. */
|
||||
flush(): string {
|
||||
let out = ''
|
||||
if (this.cr) {
|
||||
this.cr = false
|
||||
// A trailing \r at stop: treat as line ending rather than overwrite.
|
||||
out += this.emitLine()
|
||||
} else if (this.line && !this.alt) {
|
||||
out += this.line
|
||||
this.line = ''
|
||||
}
|
||||
if (this.altDirty) {
|
||||
this.altDirty = false
|
||||
out += MARKER
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Complete the current line: hand it out with a newline and reset. */
|
||||
private emitLine(): string {
|
||||
if (this.alt) {
|
||||
if (this.line) this.altDirty = true
|
||||
this.line = ''
|
||||
return ''
|
||||
}
|
||||
const out = this.line ? this.line + '\n' : '\n'
|
||||
this.line = ''
|
||||
return out
|
||||
}
|
||||
}
|
||||
+40
-2
@@ -9,6 +9,9 @@ import type { HostKeyCheckResult } from './knownHosts'
|
||||
import { configureSysinfo, registerSysinfoClient, stopPolling } from './sysinfo'
|
||||
import { registerSftpClientProvider, closeSftp } from './sftp'
|
||||
import { attachZmodem, detachZmodem, feedZmodem, isZmodemActive } from './zmodem'
|
||||
import { pickAdapter } from './shellIntegration'
|
||||
import { loadSettings } from './settingsStore'
|
||||
import { statSync } from 'fs'
|
||||
|
||||
// Re-export so the session-layer loopback bundle (tests/*-e2e.mjs) can drive the
|
||||
// M3 polling engine without importing src/main/sysinfo.ts separately.
|
||||
@@ -124,6 +127,15 @@ export function configureSessionRuntime(deps: SessionRuntimeDeps): void {
|
||||
})
|
||||
}
|
||||
|
||||
function existingDir(candidate: string | undefined): string | null {
|
||||
if (!candidate || !candidate.trim()) return null
|
||||
try {
|
||||
return statSync(candidate).isDirectory() ? candidate : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function defaultShell(): string {
|
||||
switch (process.platform) {
|
||||
case 'win32':
|
||||
@@ -138,7 +150,10 @@ function defaultShell(): string {
|
||||
export function createPty(opts: PtyCreateOptions = {}): PtyCreateResult {
|
||||
const id = randomUUID()
|
||||
const shell = opts.shell ?? defaultShell()
|
||||
const cwd = opts.cwd ?? homedir()
|
||||
// A remembered directory can be gone by the next launch (renamed, unmounted,
|
||||
// another machine's path after syncing settings) — fall back to home rather
|
||||
// than failing the spawn.
|
||||
const cwd = existingDir(opts.cwd) ?? homedir()
|
||||
// Advertise color capability + terminal identity so TUIs (e.g. kimi CLI)
|
||||
// use their full-color theme instead of the degraded white/amber fallback.
|
||||
const env = {
|
||||
@@ -148,8 +163,27 @@ export function createPty(opts: PtyCreateOptions = {}): PtyCreateResult {
|
||||
TERM_PROGRAM: 'OpenTerminal',
|
||||
...opts.env
|
||||
} as Record<string, string>
|
||||
// NO_COLOR (no-color.org) leaks in from whatever launched the app — an agent
|
||||
// CLI, an IDE, a CI shell. Every library that honors it (chalk and friends)
|
||||
// then prints uncolored output, which reads as "this terminal is broken".
|
||||
// The pty we hand out does truecolor, so the opt-out is dropped. Same for the
|
||||
// explicit FORCE_COLOR=0 form of the same request.
|
||||
delete env.NO_COLOR
|
||||
if (env.FORCE_COLOR === '0') delete env.FORCE_COLOR
|
||||
|
||||
const pty = spawn(shell, [], { name: 'xterm-256color', cols: 80, rows: 24, cwd, env })
|
||||
// Shell integration (opt-in): let the shell announce its own cwd over OSC 7 so
|
||||
// the remembered path is exact instead of inferred from typed `cd` commands.
|
||||
// Every platform/shell difference lives in the adapter table, not here.
|
||||
const args = [...(opts.shellArgs ?? [])]
|
||||
if (opts.shellArgs === undefined && loadSettings().system.shellIntegration) {
|
||||
const adapter = pickAdapter(shell)
|
||||
if (adapter) {
|
||||
args.push(...adapter.args(shell))
|
||||
Object.assign(env, adapter.env?.() ?? {})
|
||||
}
|
||||
}
|
||||
|
||||
const pty = spawn(shell, args, { name: 'xterm-256color', cols: 80, rows: 24, cwd, env })
|
||||
sessions.set(id, { kind: 'local', pty })
|
||||
replayBuffers.set(id, '')
|
||||
|
||||
@@ -166,6 +200,9 @@ export function createPty(opts: PtyCreateOptions = {}): PtyCreateResult {
|
||||
pty.onExit(({ exitCode }) => {
|
||||
try {
|
||||
sessions.delete(id)
|
||||
// The session is gone: drop its replay buffer too (killPty was the only
|
||||
// path that did, so naturally-exiting shells leaked up to 64KB each).
|
||||
replayBuffers.delete(id)
|
||||
safeStopLog(id)
|
||||
broadcast(Ipc.PTY_EXIT, { id, exitCode })
|
||||
} catch {
|
||||
@@ -251,6 +288,7 @@ export async function openSession(opts: SessionOpenOptions): Promise<{ id: strin
|
||||
detachZmodem(handle.id)
|
||||
safeStopLog(handle.id)
|
||||
sessions.delete(handle.id)
|
||||
replayBuffers.delete(handle.id)
|
||||
deps.broadcast(Ipc.PTY_EXIT, { id: handle.id, exitCode: 0 })
|
||||
} catch {
|
||||
// never crash the event loop
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { app, ipcMain } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { Ipc, type SessionPanelState, type SessionSnapshot } from '../shared/ipc'
|
||||
import { isRecord, readJson, writeJson } from './store'
|
||||
|
||||
/**
|
||||
* The automatic session snapshot: dockview layouts plus a per-panel record, so
|
||||
* the next launch can restore the arrangement and drop every pane back into the
|
||||
* directory it was in.
|
||||
*
|
||||
* Distinct from the user-managed layout *templates* (`layouts/`): templates are
|
||||
* deliberately saved/loaded sets of layouts, the snapshot is the single
|
||||
* "where I left off" memory. They coexist.
|
||||
*/
|
||||
|
||||
const MAX_PANELS = 64
|
||||
/** A layout is a few KB; anything past this is a bug or a runaway, not a layout. */
|
||||
const MAX_LAYOUT_BYTES = 2 * 1024 * 1024
|
||||
|
||||
const snapshotPath = (): string => join(app.getPath('userData'), 'session.json')
|
||||
|
||||
function isPanel(value: unknown): value is SessionPanelState {
|
||||
if (!isRecord(value)) return false
|
||||
return (
|
||||
typeof value.panelId === 'string' &&
|
||||
typeof value.title === 'string' &&
|
||||
(value.kind === 'local' || value.kind === 'ssh') &&
|
||||
(value.cwd === undefined || typeof value.cwd === 'string') &&
|
||||
(value.connectionId === undefined || typeof value.connectionId === 'string') &&
|
||||
(value.hostLabel === undefined || typeof value.hostLabel === 'string')
|
||||
)
|
||||
}
|
||||
|
||||
/** Parse defensively: a corrupt snapshot must degrade to "no snapshot". */
|
||||
function sanitize(raw: unknown): SessionSnapshot | null {
|
||||
if (!isRecord(raw)) return null
|
||||
const layouts = isRecord(raw.layouts) ? raw.layouts : {}
|
||||
const panels = Array.isArray(raw.panels) ? raw.panels.filter(isPanel).slice(0, MAX_PANELS) : []
|
||||
if (panels.length === 0 && !layouts.terminal && !layouts.ssh) return null
|
||||
|
||||
const encode = (value: unknown): unknown => {
|
||||
if (value === undefined || value === null) return undefined
|
||||
// Cheap size guard: serializing here means a too-large layout is dropped
|
||||
// rather than written to disk on every save.
|
||||
try {
|
||||
const json = JSON.stringify(value)
|
||||
return json.length > MAX_LAYOUT_BYTES ? undefined : value
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
savedAt: typeof raw.savedAt === 'number' ? raw.savedAt : Date.now(),
|
||||
mode: raw.mode === 'ssh' ? 'ssh' : 'terminal',
|
||||
layouts: { terminal: encode(layouts.terminal), ssh: encode(layouts.ssh) },
|
||||
panels,
|
||||
lastLocalCwd: typeof raw.lastLocalCwd === 'string' ? raw.lastLocalCwd : undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function loadSessionSnapshot(): SessionSnapshot | null {
|
||||
return sanitize(readJson(snapshotPath()))
|
||||
}
|
||||
|
||||
export function saveSessionSnapshot(next: SessionSnapshot): SessionSnapshot | null {
|
||||
const clean = sanitize(next)
|
||||
if (!clean) return null
|
||||
clean.savedAt = Date.now()
|
||||
writeJson(snapshotPath(), clean)
|
||||
return clean
|
||||
}
|
||||
|
||||
export function clearSessionSnapshot(): void {
|
||||
writeJson(snapshotPath(), { version: 1, savedAt: Date.now(), mode: 'terminal', layouts: {}, panels: [] })
|
||||
}
|
||||
|
||||
export function registerSessionStateIpc(): void {
|
||||
ipcMain.handle(Ipc.SESSION_STATE_GET, () => loadSessionSnapshot())
|
||||
ipcMain.handle(Ipc.SESSION_STATE_SET, (_event, snapshot: SessionSnapshot) => saveSessionSnapshot(snapshot))
|
||||
}
|
||||
+44
-22
@@ -21,7 +21,9 @@ const DEFAULT_SYSTEM: SystemSettings = {
|
||||
launchAtLogin: false,
|
||||
preventSleep: false,
|
||||
globalShowHide: '',
|
||||
closeAction: 'ask',
|
||||
// Must match DEFAULT_SETTINGS.system in @shared/settings — an "ask" here made
|
||||
// a fresh install prompt on close while the docs and UI promised tray.
|
||||
closeAction: 'tray',
|
||||
autoCheckUpdate: true
|
||||
}
|
||||
|
||||
@@ -43,9 +45,11 @@ function isHighlightRule(value: unknown): value is HighlightRule {
|
||||
}
|
||||
|
||||
function sanitizeRules(value: unknown): HighlightRule[] {
|
||||
// An explicit empty array is a valid choice ("no highlighting"); only
|
||||
// malformed data falls back to the built-in rules. Returning the defaults for
|
||||
// [] made deleting the last rule look like it silently failed.
|
||||
if (!Array.isArray(value)) return DEFAULT_HIGHLIGHT_RULES
|
||||
const rules = value.filter(isHighlightRule)
|
||||
return rules.length > 0 ? rules : DEFAULT_HIGHLIGHT_RULES
|
||||
return value.filter(isHighlightRule)
|
||||
}
|
||||
|
||||
function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
|
||||
@@ -87,13 +91,16 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
|
||||
const closeAction =
|
||||
candidate.closeAction === 'tray' || candidate.closeAction === 'exit'
|
||||
? candidate.closeAction
|
||||
: 'ask'
|
||||
: 'tray'
|
||||
system = {
|
||||
launchAtLogin: candidate.launchAtLogin === true,
|
||||
preventSleep: candidate.preventSleep === true,
|
||||
globalShowHide,
|
||||
closeAction,
|
||||
autoCheckUpdate: candidate.autoCheckUpdate !== false
|
||||
autoCheckUpdate: candidate.autoCheckUpdate !== false,
|
||||
// Both default on/off as in DEFAULT_SETTINGS; absent means "not chosen".
|
||||
restoreSession: candidate.restoreSession !== false,
|
||||
shellIntegration: candidate.shellIntegration === true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,25 +159,40 @@ export function loadSettings(): AppSettings {
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSettings(next: AppSettings): AppSettings {
|
||||
const merged = deepMerge({
|
||||
terminal: next.terminal,
|
||||
customThemes: next.customThemes,
|
||||
highlightRules: next.highlightRules,
|
||||
system: next.system ?? DEFAULT_SYSTEM
|
||||
}).settings
|
||||
applySystemSettings(merged.system)
|
||||
applyWindowChrome(merged)
|
||||
const pretty = JSON.stringify(merged, null, 2)
|
||||
let settingsQueue: Promise<unknown> = Promise.resolve()
|
||||
|
||||
mkdirSync(app.getPath('userData'), { recursive: true })
|
||||
const path = settingsPath()
|
||||
const tmp = `${path}.tmp`
|
||||
writeFileSync(tmp, pretty, 'utf8')
|
||||
renameSync(tmp, path)
|
||||
/**
|
||||
* 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
|
||||
* full-snapshot save can no longer silently revert a change written between
|
||||
* its read and its write (e.g. tray close-action vs settings UI save).
|
||||
*/
|
||||
export function mutateSettings(mutate: (settings: AppSettings) => AppSettings): Promise<AppSettings> {
|
||||
const run = settingsQueue.then((): AppSettings => {
|
||||
const merged = deepMerge(mutate(loadSettings())).settings
|
||||
applySystemSettings(merged.system)
|
||||
applyWindowChrome(merged)
|
||||
|
||||
broadcast(Ipc.SETTINGS_CHANGED, merged)
|
||||
return merged
|
||||
mkdirSync(app.getPath('userData'), { recursive: true })
|
||||
const path = settingsPath()
|
||||
const tmp = `${path}.tmp`
|
||||
writeFileSync(tmp, JSON.stringify(merged, null, 2), 'utf8')
|
||||
renameSync(tmp, path)
|
||||
|
||||
broadcast(Ipc.SETTINGS_CHANGED, merged)
|
||||
return merged
|
||||
})
|
||||
// Keep the queue alive when a mutation throws; the caller still sees it.
|
||||
settingsQueue = run.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
return run
|
||||
}
|
||||
|
||||
/** Replace the persisted settings with `next` (serialized). */
|
||||
export function saveSettings(next: AppSettings): Promise<AppSettings> {
|
||||
return mutateSettings((current) => ({ ...current, ...next }))
|
||||
}
|
||||
|
||||
export function registerSettingsIpc(): void {
|
||||
|
||||
+12
-2
@@ -195,13 +195,23 @@ export function deleteRemote(sessionId: string, paths: string[]): Promise<void>
|
||||
* chmod/chown run over an exec channel: the JD test server's sftp subsystem
|
||||
* accepts SETSTAT but silently ignores it, while shell chmod/chown work.
|
||||
*/
|
||||
/**
|
||||
* POSIX single-quote escaping. `JSON.stringify` only escapes `"`, so a remote
|
||||
* file name containing `$`, a backtick or a quote would still be expanded by the
|
||||
* far-side shell — that is remote command execution triggered by a file name.
|
||||
*/
|
||||
function shQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`
|
||||
}
|
||||
|
||||
export function chmodRemote(sessionId: string, path: string, mode: string): Promise<void> {
|
||||
if (!/^[0-7]{1,4}$/.test(mode)) throw new Error(`非法权限值: ${mode}`)
|
||||
return execQuiet(sessionId, `chmod ${mode} ${JSON.stringify(path)}`)
|
||||
return execQuiet(sessionId, `chmod ${mode} ${shQuote(path)}`)
|
||||
}
|
||||
|
||||
export function chownRemote(sessionId: string, path: string, uid: number, gid: number): Promise<void> {
|
||||
return execQuiet(sessionId, `chown ${uid}:${gid} ${JSON.stringify(path)}`)
|
||||
if (!Number.isInteger(uid) || !Number.isInteger(gid)) throw new Error('非法 uid/gid')
|
||||
return execQuiet(sessionId, `chown ${uid}:${gid} ${shQuote(path)}`)
|
||||
}
|
||||
|
||||
/** Run a command on the session's shell channel and wait for it to finish. */
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { basename } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
/**
|
||||
* Shell integration: make the shell announce its working directory so the app
|
||||
* can remember it exactly, instead of guessing from typed `cd` commands.
|
||||
*
|
||||
* The channel is the industry standard **OSC 7** (`ESC ] 7 ; file://<host><path>
|
||||
* BEL`): bash, zsh, fish, iTerm2, WezTerm, kitty and Windows Terminal all speak
|
||||
* it, so the app's parser is written once and works on every platform. Windows
|
||||
* shells that historically emit ConEmu's **OSC 9;9** (`ESC ] 9 ; 9 ; <path> BEL`)
|
||||
* are handled by the same parser as a second form.
|
||||
*
|
||||
* Everything platform-specific is confined to an adapter below: adding a shell
|
||||
* or a platform means adding one entry here, never touching the renderer, the
|
||||
* snapshot format or the restore path.
|
||||
*
|
||||
* Trade-off: an adapter wraps the user's prompt (that is the only reliable hook
|
||||
* a foreign process has), so this whole feature is opt-in via
|
||||
* 设置 → 系统 → Shell 集成. With it off the app falls back to parsing typed
|
||||
* `cd` commands, which covers the common case with zero interference.
|
||||
*/
|
||||
|
||||
export interface ShellAdapter {
|
||||
/** Stable id used by the setting and by logs. */
|
||||
id: string
|
||||
/** Human label shown in settings. */
|
||||
label: string
|
||||
/** True when this adapter can drive the given shell executable. */
|
||||
matches(shellPath: string, platform: NodeJS.Platform): boolean
|
||||
/** Extra argv appended when spawning the shell. */
|
||||
args(shellPath: string): string[]
|
||||
/** Extra environment variables for the child. */
|
||||
env?(): Record<string, string>
|
||||
}
|
||||
|
||||
/** `ESC ] 7 ; file://<host>/<path> BEL` — the portable form. */
|
||||
function posixPromptCommand(): string {
|
||||
return `printf '\\033]7;file://%s%s\\033\\\\' "$HOSTNAME" "$PWD"`
|
||||
}
|
||||
|
||||
/**
|
||||
* PowerShell: keep the user's prompt function and re-emit it after our own
|
||||
* write, so oh-my-posh / starship / a hand-written prompt all keep working.
|
||||
*/
|
||||
const POWERSHELL_ADAPTER: ShellAdapter = {
|
||||
id: 'powershell',
|
||||
label: 'PowerShell',
|
||||
matches: (shellPath) => /(?:^|[\\/])(pwsh|powershell)(\.exe)?$/i.test(shellPath),
|
||||
args: () => [
|
||||
'-NoLogo',
|
||||
'-NoExit',
|
||||
'-Command',
|
||||
[
|
||||
// Save whatever prompt is in effect, then wrap it.
|
||||
'$global:__otPrompt = $function:prompt',
|
||||
'function global:prompt {',
|
||||
' $p = $PWD.ProviderPath; if (-not $p) { $p = $PWD.Path }',
|
||||
' $esc = [char]27; $bel = [char]7',
|
||||
' Write-Host -NoNewline "$esc]7;file://$env:COMPUTERNAME/$($p -replace \'\\\\\',\'/\')$bel"',
|
||||
' if ($global:__otPrompt) { & $global:__otPrompt } else { "PS $p> " }',
|
||||
'}'
|
||||
].join(' ')
|
||||
]
|
||||
}
|
||||
|
||||
/** cmd.exe: `$E` is ESC inside the prompt string, `$P` the current directory. */
|
||||
const CMD_ADAPTER: ShellAdapter = {
|
||||
id: 'cmd',
|
||||
label: '命令提示符 (cmd)',
|
||||
matches: (shellPath, platform) => platform === 'win32' && /(?:^|[\\/])cmd(\.exe)?$/i.test(shellPath),
|
||||
args: () => [
|
||||
'/K',
|
||||
// Emit OSC 7 (path with backslashes swapped for slashes) then the normal
|
||||
// `C:\dir>` prompt.
|
||||
'prompt $E]7;file://%COMPUTERNAME%/$P$E\\$P$G'
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* POSIX shells: a one-line prompt hook. bash honours PROMPT_COMMAND directly;
|
||||
* zsh/fish hook through the same env var where their startup files cooperate —
|
||||
* see `env()` notes. Implementation is kept deliberately small because these
|
||||
* need verifying on their own platforms before being enabled by default.
|
||||
*/
|
||||
const POSIX_ADAPTER: ShellAdapter = {
|
||||
id: 'posix',
|
||||
label: 'bash / zsh / fish',
|
||||
matches: (shellPath, platform) =>
|
||||
platform !== 'win32' && /(?:^|[\\/])(bash|zsh|dash|fish|sh)$/i.test(shellPath),
|
||||
args: () => [],
|
||||
env: () => ({
|
||||
// bash reads this before every prompt. zsh ignores it (no harm); fish uses
|
||||
// its own hook, so fish users on this path simply keep the cd-parsing
|
||||
// fallback until a dedicated adapter is verified.
|
||||
PROMPT_COMMAND: posixPromptCommand()
|
||||
})
|
||||
}
|
||||
|
||||
/** Every adapter the app knows about. Order matters: first match wins. */
|
||||
export const SHELL_ADAPTERS: ShellAdapter[] = [POWERSHELL_ADAPTER, CMD_ADAPTER, POSIX_ADAPTER]
|
||||
|
||||
export function pickAdapter(shellPath: string, platform: NodeJS.Platform = process.platform): ShellAdapter | null {
|
||||
const name = basename(shellPath)
|
||||
return SHELL_ADAPTERS.find((a) => a.matches(name, platform) || a.matches(shellPath, platform)) ?? null
|
||||
}
|
||||
|
||||
/** The default POSIX shell, used when the setting is on and no shell is given. */
|
||||
export function posixFallbackShell(): string {
|
||||
return process.env.SHELL || '/bin/sh'
|
||||
}
|
||||
|
||||
/** Kept for symmetry with the adapters that need a home directory. */
|
||||
export const integrationHome = (): string => homedir()
|
||||
@@ -0,0 +1,63 @@
|
||||
import { mkdirSync, readFileSync, renameSync, writeFileSync, unlinkSync } from 'fs'
|
||||
import { dirname, join } from 'path'
|
||||
|
||||
/**
|
||||
* Small shared persistence helper for the app's JSON stores (settings,
|
||||
* connections, known hosts, commands, layouts, the session snapshot).
|
||||
*
|
||||
* Why not a database: these are a handful of KB written by a single process at
|
||||
* human speed, and every native DB binding (better-sqlite3 et al) would add a
|
||||
* per-platform rebuild + packaging burden to a cross-platform app. What the
|
||||
* stores actually needed was the *reliability* a DB implies — that is what this
|
||||
* gives: an atomic write (write to a sibling temp file, then rename) so a crash
|
||||
* or a full disk can never leave a half-written file behind, and a capped size
|
||||
* so a runaway value cannot fill the disk either.
|
||||
*
|
||||
* If a dataset ever outgrows this (command history in the tens of thousands, or
|
||||
* full-text search over session logs), swap the implementation here — callers
|
||||
* only ever see `readJson`/`writeJson`.
|
||||
*/
|
||||
|
||||
/** Refuse to load a file larger than this (guards against a runaway store). */
|
||||
const MAX_BYTES = 8 * 1024 * 1024
|
||||
|
||||
const tmpPath = (file: string): string => `${file}.tmp`
|
||||
|
||||
export function readJson<T = unknown>(file: string): T | null {
|
||||
try {
|
||||
const raw = readFileSync(file, 'utf8')
|
||||
if (raw.length > MAX_BYTES) return null
|
||||
return JSON.parse(raw) as T
|
||||
} catch {
|
||||
// Missing, unreadable or corrupt: callers fall back to their defaults.
|
||||
// A corrupt file is left in place (not deleted) so it can be inspected.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writeJson(file: string, value: unknown): void {
|
||||
mkdirSync(dirname(file), { recursive: true })
|
||||
const tmp = tmpPath(file)
|
||||
try {
|
||||
writeFileSync(tmp, JSON.stringify(value, null, 2), 'utf8')
|
||||
renameSync(tmp, file)
|
||||
} catch (err) {
|
||||
// Clean up the temp file so a failed write does not leave litter behind.
|
||||
try {
|
||||
unlinkSync(tmp)
|
||||
} catch {
|
||||
/* nothing to clean */
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience for stores whose file lives under a directory. */
|
||||
export function jsonFileIn(dir: string, name: string): string {
|
||||
return join(dir, name)
|
||||
}
|
||||
|
||||
/** Minimal shape guard used by the stores before trusting a parsed value. */
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
+11
-6
@@ -1,6 +1,6 @@
|
||||
import { app, BrowserWindow, dialog, Menu, nativeImage, Tray } from 'electron'
|
||||
import type { SystemSettings } from '@shared/settings'
|
||||
import { loadSettings, saveSettings } from './settingsStore'
|
||||
import { loadSettings, mutateSettings } from './settingsStore'
|
||||
import trayIconPath from './assets/tray.png?asset'
|
||||
|
||||
export type CloseAction = NonNullable<SystemSettings['closeAction']>
|
||||
@@ -15,9 +15,12 @@ export function markQuitting(): void {
|
||||
}
|
||||
|
||||
function persistCloseAction(action: CloseAction): void {
|
||||
const next = loadSettings()
|
||||
next.system.closeAction = action
|
||||
saveSettings(next)
|
||||
// Serialized read-modify-write on the latest state: must not stomp (or be
|
||||
// stomped by) a concurrent full-snapshot save from the settings UI.
|
||||
void mutateSettings((current) => {
|
||||
current.system.closeAction = action
|
||||
return current
|
||||
})
|
||||
}
|
||||
|
||||
function showMainWindow(showOrCreate: () => void): void {
|
||||
@@ -74,8 +77,10 @@ function hideToTray(win: BrowserWindow): void {
|
||||
balloonShown = true
|
||||
tray.displayBalloon({
|
||||
iconType: 'info',
|
||||
title: 'OpenTerminal 仍在运行',
|
||||
content: '已最小化到系统托盘,终端会话保持运行。点击托盘图标可恢复窗口。'
|
||||
// The Windows toast already prints the app name as its header, so the
|
||||
// balloon title carries the state only — no "OpenTerminal" twice.
|
||||
title: '已最小化到托盘',
|
||||
content: '终端会话仍在后台运行,点击托盘图标可恢复窗口。'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,6 +309,17 @@ export function attachZmodem(sessionId: string, deps: ZmodemDeps): void {
|
||||
engine.mode = role === 'receive' ? 'receive' : 'send'
|
||||
engine.detection = detection
|
||||
engine.active = true
|
||||
// A detect is the start of a fresh transfer on this session, so the
|
||||
// one-shot flags of the previous one must go with it. Leaving `confirmed`
|
||||
// set made the *second* `sz`/`rz` on a session a no-op: respondZmodem
|
||||
// bailed out, the engine stayed `active` (swallowing every keystroke and
|
||||
// all terminal data) until the stall timer finally fired.
|
||||
engine.confirmed = false
|
||||
engine.progressEmitted = false
|
||||
engine.doneEmitted = false
|
||||
engine.dir = null
|
||||
engine.session = null
|
||||
engine.receiveStream = null
|
||||
engine.transferId = `zm-${sessionId}`
|
||||
emitProgress(engine, { file: '', bytes: 0, totalBytes: 0 })
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { Ipc } from '../shared/ipc'
|
||||
import type { AppSettings } from '../shared/settings'
|
||||
import type { AppApi } from '../shared/api'
|
||||
import type { LayoutMeta, PtyCreateOptions, PtyDataEvent, PtyExitEvent, ReleaseNote, UpdateState, ZmodemOfferEvent, ZmodemResponse, ZmodemDoneEvent } from '../shared/ipc'
|
||||
import type { LayoutMeta, PtyCreateOptions, PtyDataEvent, PtyExitEvent, ReleaseNote, SessionSnapshot, UpdateState, ZmodemOfferEvent, ZmodemResponse, ZmodemDoneEvent } from '../shared/ipc'
|
||||
import type {
|
||||
HostKeyAction,
|
||||
HostKeyPromptEvent,
|
||||
@@ -121,13 +121,18 @@ const api: AppApi = {
|
||||
|
||||
updateCheck: () => ipcRenderer.invoke(Ipc.UPDATE_CHECK),
|
||||
updateDownload: () => ipcRenderer.invoke(Ipc.UPDATE_DOWNLOAD),
|
||||
updateInstall: () => ipcRenderer.send(Ipc.UPDATE_INSTALL),
|
||||
updateInstall: () => ipcRenderer.invoke(Ipc.UPDATE_INSTALL),
|
||||
updateChangelog: () => ipcRenderer.invoke(Ipc.UPDATE_CHANGELOG),
|
||||
onUpdateState: (cb: (s: UpdateState) => void) => {
|
||||
const listener = (_: unknown, s: UpdateState): void => cb(s)
|
||||
ipcRenderer.on(Ipc.UPDATE_STATE, listener)
|
||||
return () => ipcRenderer.removeListener(Ipc.UPDATE_STATE, listener)
|
||||
}
|
||||
},
|
||||
|
||||
getSessionState: () => ipcRenderer.invoke(Ipc.SESSION_STATE_GET),
|
||||
saveSessionState: (snapshot: SessionSnapshot) => ipcRenderer.invoke(Ipc.SESSION_STATE_SET, snapshot),
|
||||
resolveCwd: (current: string | undefined, arg: string) => ipcRenderer.invoke(Ipc.CWD_RESOLVE, current, arg),
|
||||
reportCwd: (payload: string) => ipcRenderer.invoke(Ipc.CWD_REPORT, payload)
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('api', api)
|
||||
@@ -12,7 +12,12 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--chrome-bg);
|
||||
/* Framed like the dockview area: the right edge was the only border, which
|
||||
left the sidebar's top (against the title bar) and bottom (against the
|
||||
window edge) looking unfinished next to the neighbouring separators. */
|
||||
border-right: 1px solid var(--chrome-border);
|
||||
border-top: 1px solid var(--chrome-border);
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
--chrome-border: #2d2d2d;
|
||||
--chrome-accent: #3b8eea;
|
||||
--chrome-hover: #2a2a2a;
|
||||
--chrome-bg-deep: #101418;
|
||||
/* Tab-bar overlays; overwritten per theme by applyChromeTheme(). */
|
||||
--chrome-tab-bg: rgba(255, 255, 255, 0.04);
|
||||
--chrome-tab-bg-hover: rgba(255, 255, 255, 0.07);
|
||||
--chrome-tab-ring: rgba(255, 255, 255, 0.1);
|
||||
--chrome-tab-ring-hover: rgba(255, 255, 255, 0.2);
|
||||
--chrome-tab-fg: #98a2ab;
|
||||
--chrome-tab-fg-hover: #d0d0d0;
|
||||
}
|
||||
|
||||
html,
|
||||
|
||||
@@ -89,9 +89,13 @@ if (typeof window !== 'undefined' && !window.api) {
|
||||
deleteLayout: async () => undefined,
|
||||
updateCheck: async () => ({ status: 'dev' as const, currentVersion: 'dev' }),
|
||||
updateDownload: async () => undefined,
|
||||
updateInstall: noop,
|
||||
updateInstall: async () => undefined,
|
||||
updateChangelog: async () => [],
|
||||
onUpdateState: () => noop
|
||||
onUpdateState: () => noop,
|
||||
getSessionState: async () => null,
|
||||
saveSessionState: async () => null,
|
||||
resolveCwd: async () => null,
|
||||
reportCwd: async () => null
|
||||
}
|
||||
;(window as unknown as { api: AppApi }).api = api
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
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'
|
||||
|
||||
const STATUS_TEXT: Record<UpdateStatus, string> = {
|
||||
idle: '点击检查更新',
|
||||
@@ -34,7 +35,7 @@ export function AboutTab(): React.JSX.Element {
|
||||
return (
|
||||
<div className="about-tab">
|
||||
<div className="about-header">
|
||||
<div className="about-logo">OT</div>
|
||||
<img className="about-logo" src={appIconUrl} alt="OpenTerminal" draggable={false} />
|
||||
<div>
|
||||
<div className="about-name">OpenTerminal</div>
|
||||
<div className="about-version">
|
||||
@@ -67,7 +68,7 @@ export function AboutTab(): React.JSX.Element {
|
||||
</Button>
|
||||
)}
|
||||
{status === 'downloaded' && (
|
||||
<Button type="primary" onClick={() => window.api.updateInstall()}>
|
||||
<Button type="primary" onClick={() => void window.api.updateInstall()}>
|
||||
重启安装
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Button, Modal, Tabs } from 'antd'
|
||||
import type { TabsProps } from 'antd'
|
||||
import { DEFAULT_SETTINGS } from '@shared/settings'
|
||||
@@ -20,6 +20,53 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS
|
||||
const [editorState, setEditorState] = useState<{ open: boolean; themeId?: string }>({
|
||||
open: false
|
||||
})
|
||||
// Dialog size: proportional to the main window by default, user-draggable
|
||||
// afterwards. `null` means "use the proportional default". While the dialog
|
||||
// is open, a main-window resize re-derives that default in real time.
|
||||
const [size, setSize] = useState<{ w: number; h: number } | null>(null)
|
||||
const [winSize, setWinSize] = useState({ w: window.innerWidth, h: window.innerHeight })
|
||||
useEffect(() => {
|
||||
if (open) setSize(null)
|
||||
}, [open])
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onResize = (): void => setWinSize({ w: window.innerWidth, h: window.innerHeight })
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => window.removeEventListener('resize', onResize)
|
||||
}, [open])
|
||||
|
||||
const clampToWindow = (w: number, h: number): { w: number; h: number } => ({
|
||||
w: Math.max(720, Math.min(w, winSize.w - 96)),
|
||||
h: Math.max(440, Math.min(h, winSize.h - 140))
|
||||
})
|
||||
const dialogSize = size
|
||||
? clampToWindow(size.w, size.h)
|
||||
: clampToWindow(Math.round(winSize.w * 0.7), Math.round(winSize.h * 0.7))
|
||||
// The modal chrome (header + footer + paddings) is ~132px; the body takes the rest.
|
||||
const bodyHeight = Math.max(360, dialogSize.h - 132)
|
||||
|
||||
const startResize = (e: React.MouseEvent<HTMLSpanElement>): void => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const startX = e.clientX
|
||||
const startY = e.clientY
|
||||
const base = dialogSize
|
||||
const onMove = (ev: MouseEvent): void => {
|
||||
const w = Math.max(720, Math.min(window.innerWidth - 96, base.w + (ev.clientX - startX)))
|
||||
const h = Math.max(440, Math.min(window.innerHeight - 140, base.h + (ev.clientY - startY)))
|
||||
setSize({ w, h })
|
||||
}
|
||||
const onUp = (): void => {
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
window.removeEventListener('mouseup', onUp)
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
document.body.style.cursor = 'nwse-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
window.addEventListener('mousemove', onMove)
|
||||
window.addEventListener('mouseup', onUp)
|
||||
}
|
||||
|
||||
const tabItems: TabsProps['items'] = [
|
||||
{ key: 'font', label: '字体', children: <FontSettingsTab /> },
|
||||
@@ -50,7 +97,8 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
width={760}
|
||||
centered
|
||||
width={dialogSize.w}
|
||||
title="设置"
|
||||
footer={
|
||||
<div className="settings-footer">
|
||||
@@ -61,7 +109,16 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Tabs defaultActiveKey="font" items={tabItems} />
|
||||
<div className="settings-body" style={{ height: bodyHeight }}>
|
||||
<Tabs tabPosition="left" defaultActiveKey="font" items={tabItems} />
|
||||
</div>
|
||||
{/* Drag corner: resizes the dialog until it is closed. */}
|
||||
<span
|
||||
className="settings-resize"
|
||||
title="拖拽调整大小"
|
||||
aria-label="拖拽调整大小"
|
||||
onMouseDown={startResize}
|
||||
/>
|
||||
<ThemeEditor
|
||||
open={editorState.open}
|
||||
onClose={closeEditor}
|
||||
|
||||
@@ -270,7 +270,7 @@ export function RenderSettingsTab(): React.JSX.Element {
|
||||
/>
|
||||
<SettingRow
|
||||
label="粘贴风险确认"
|
||||
desc="粘贴前弹出风险确认提示"
|
||||
desc="多行文本(或超长单行)粘贴前弹出确认;单行直接粘贴"
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.terminal.pasteRiskConfirm}
|
||||
@@ -288,6 +288,33 @@ export function RenderSettingsTab(): React.JSX.Element {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="记录命令历史"
|
||||
desc="关闭后不再记录新命令;已保存的历史仍然保留"
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.terminal.historyEnabled !== false}
|
||||
onChange={(checked) => void updateTerminal({ historyEnabled: checked })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="历史条数上限"
|
||||
desc="命令历史最多保留多少条,重复命令只记一次"
|
||||
control={
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={500}
|
||||
step={10}
|
||||
value={settings.terminal.historyLimit}
|
||||
disabled={settings.terminal.historyEnabled === false}
|
||||
onChange={(value) => {
|
||||
if (typeof value === 'number') void updateTerminal({ historyLimit: value })
|
||||
}}
|
||||
style={{ width: 110 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -317,6 +344,26 @@ export function SystemSettingsTab(): React.JSX.Element {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="启动时恢复上次会话"
|
||||
desc="下次启动恢复分屏布局,并让每个终端回到它上次所在的目录;关闭则每次都以单个终端启动"
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.system.restoreSession !== false}
|
||||
onChange={(checked) => void updateSystem({ restoreSession: checked })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="Shell 集成"
|
||||
desc="让 shell 主动上报工作目录(OSC 7),路径记忆更精确(含脚本内的 cd);会包装你的命令提示符,遇到提示符主题异常时关掉即可"
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.system.shellIntegration === true}
|
||||
onChange={(checked) => void updateSystem({ shellIntegration: checked })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
label="关闭按钮行为"
|
||||
desc="点击窗口关闭按钮时的动作;最小化到托盘后可在托盘图标右键菜单中退出"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/* ============================================================
|
||||
Highlight settings tab + rule editor (settings/highlight.css)
|
||||
Text/border tints derive from --chrome-fg via color-mix so the
|
||||
panel follows the active terminal theme (white tints on dark
|
||||
themes, black tints on light themes).
|
||||
============================================================ */
|
||||
|
||||
.hl-tab {
|
||||
@@ -21,15 +24,15 @@
|
||||
}
|
||||
|
||||
.hl-table .ant-table-thead > tr > th {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
background: color-mix(in srgb, var(--chrome-fg, #cccccc) 4%, transparent);
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 85%, transparent);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hl-table .ant-table-tbody > tr > td {
|
||||
background: transparent;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 6%, transparent);
|
||||
}
|
||||
|
||||
.hl-table .ant-table-tbody > tr:hover > td {
|
||||
@@ -48,7 +51,7 @@
|
||||
.hl-pattern-text {
|
||||
font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 88%, transparent);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -71,17 +74,17 @@
|
||||
.hl-cell-priority {
|
||||
font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 88%, transparent);
|
||||
}
|
||||
|
||||
.hl-cell-note {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 70%, transparent);
|
||||
}
|
||||
|
||||
.hl-cell-empty {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 30%, transparent);
|
||||
}
|
||||
|
||||
.hl-preview-text {
|
||||
@@ -114,7 +117,7 @@
|
||||
|
||||
.hl-editor-label {
|
||||
flex: 0 0 56px;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 88%, transparent);
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
@@ -129,7 +132,7 @@
|
||||
.hl-editor-hint {
|
||||
align-self: center;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 45%, transparent);
|
||||
}
|
||||
|
||||
.hl-editor-error {
|
||||
@@ -149,7 +152,7 @@
|
||||
|
||||
.hl-editor-sub-label {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 60%, transparent);
|
||||
}
|
||||
|
||||
.hl-editor-inline {
|
||||
@@ -170,7 +173,7 @@
|
||||
width: 40px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 20%, transparent);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
@@ -197,7 +200,7 @@
|
||||
|
||||
.hl-palette-tag {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 50%, transparent);
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
@@ -205,7 +208,7 @@
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border: 1px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 30%, transparent);
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
@@ -218,4 +221,4 @@
|
||||
.hl-swatch-selected {
|
||||
box-shadow: 0 0 0 2px var(--chrome-accent) inset;
|
||||
border-color: var(--chrome-accent);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
/* ============================================================
|
||||
Settings dialog + theme editor styles (settings/**, theme/**)
|
||||
Text/border tints derive from --chrome-fg via color-mix so the
|
||||
dialog follows the active terminal theme (white tints on dark
|
||||
themes, black tints on light themes).
|
||||
============================================================ */
|
||||
|
||||
/* ---------- settings dialog scaffolding ---------- */
|
||||
@@ -14,6 +17,75 @@
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
}
|
||||
|
||||
/* ---------- left-rail navigation (tabPosition="left") ----------
|
||||
The tab list doubles as the dialog's sidebar: fixed narrow column, roomy
|
||||
click targets, active item tinted with the tab accent. The content column
|
||||
scrolls on its own so long panes (字体/高亮) never grow the dialog. */
|
||||
.settings-body .ant-tabs-left > .ant-tabs-nav {
|
||||
margin: 0;
|
||||
border-bottom: none;
|
||||
border-right: 1px solid var(--chrome-border);
|
||||
padding-right: 6px;
|
||||
min-width: 148px;
|
||||
}
|
||||
|
||||
.settings-body .ant-tabs-left > .ant-tabs-nav .ant-tabs-tab {
|
||||
margin: 2px 0;
|
||||
padding: 8px 14px;
|
||||
border-radius: 6px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.settings-body .ant-tabs-left > .ant-tabs-nav .ant-tabs-tab:hover {
|
||||
background: var(--chrome-hover);
|
||||
}
|
||||
|
||||
.settings-body .ant-tabs-left > .ant-tabs-nav .ant-tabs-tab-active {
|
||||
background: color-mix(in srgb, var(--tab-accent, #3fb950) 16%, transparent);
|
||||
}
|
||||
|
||||
.settings-body .ant-tabs-left > .ant-tabs-nav .ant-tabs-ink-bar {
|
||||
background: var(--tab-accent, #3fb950);
|
||||
}
|
||||
|
||||
.settings-body {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-body > .ant-tabs {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.settings-body .ant-tabs-content-holder {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Corner grip for resizing the dialog (wired in SettingsDialog). */
|
||||
.ant-modal .ant-modal-body {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.settings-resize {
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
bottom: 3px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: nwse-resize;
|
||||
border-radius: 3px;
|
||||
background:
|
||||
linear-gradient(135deg, transparent 52%, var(--chrome-border) 52%, var(--chrome-border) 58%, transparent 58%),
|
||||
linear-gradient(135deg, transparent 64%, var(--chrome-border) 64%, var(--chrome-border) 70%, transparent 70%),
|
||||
linear-gradient(135deg, transparent 76%, var(--chrome-border) 76%, var(--chrome-border) 82%, transparent 82%);
|
||||
}
|
||||
|
||||
.settings-resize:hover {
|
||||
filter: brightness(1.6);
|
||||
}
|
||||
|
||||
.settings-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -37,7 +109,7 @@
|
||||
}
|
||||
|
||||
.settings-row:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: color-mix(in srgb, var(--chrome-fg, #cccccc) 3%, transparent);
|
||||
}
|
||||
|
||||
.settings-row + .settings-row {
|
||||
@@ -46,7 +118,7 @@
|
||||
|
||||
.settings-row-label {
|
||||
flex: 0 0 46%;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 88%, transparent);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
@@ -56,7 +128,7 @@
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 45%, transparent);
|
||||
}
|
||||
|
||||
.settings-row-control {
|
||||
@@ -87,7 +159,7 @@
|
||||
.settings-block-hint {
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 45%, transparent);
|
||||
}
|
||||
|
||||
.settings-radio-group {
|
||||
@@ -123,7 +195,7 @@
|
||||
.settings-preview-title {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 50%, transparent);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -178,14 +250,14 @@
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
border: 2px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 2px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 12%, transparent);
|
||||
background: color-mix(in srgb, var(--chrome-fg, #cccccc) 4%, transparent);
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.theme-card:hover {
|
||||
border-color: var(--chrome-accent);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
background: color-mix(in srgb, var(--chrome-fg, #cccccc) 7%, transparent);
|
||||
}
|
||||
|
||||
.theme-card-selected {
|
||||
@@ -211,7 +283,7 @@
|
||||
.theme-card-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 88%, transparent);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -224,7 +296,7 @@
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
background: rgba(59, 142, 234, 0.2);
|
||||
color: #6fb3ff;
|
||||
color: color-mix(in srgb, var(--chrome-accent, #3b8eea) 55%, var(--chrome-fg, #cccccc));
|
||||
}
|
||||
|
||||
.theme-card-dots {
|
||||
@@ -237,7 +309,7 @@
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border: 1px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 25%, transparent);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@@ -267,12 +339,12 @@
|
||||
.theme-card-new-icon {
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 55%, transparent);
|
||||
}
|
||||
|
||||
.theme-card-new-text {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 60%, transparent);
|
||||
}
|
||||
|
||||
/* ---------- theme editor ---------- */
|
||||
@@ -290,7 +362,7 @@
|
||||
|
||||
.theme-editor-label {
|
||||
flex: 0 0 56px;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 88%, transparent);
|
||||
}
|
||||
|
||||
.theme-editor-colors {
|
||||
@@ -310,7 +382,7 @@
|
||||
|
||||
.theme-editor-color-label {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 70%, transparent);
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -322,7 +394,7 @@
|
||||
width: 40px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 20%, transparent);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
@@ -339,7 +411,7 @@
|
||||
|
||||
.theme-editor-divider {
|
||||
grid-column: 1 / -1;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-top: 1px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 10%, transparent);
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
@@ -350,7 +422,7 @@
|
||||
.theme-editor-preview-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 50%, transparent);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
@@ -380,13 +452,8 @@
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: var(--chrome-hover);
|
||||
color: #3fd06a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.about-name {
|
||||
@@ -431,7 +498,7 @@
|
||||
}
|
||||
|
||||
.about-changelog {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-top: 1px solid color-mix(in srgb, var(--chrome-fg, #cccccc) 8%, transparent);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ForwardRefExoticComponent, Ref } from 'react'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import type { ITheme } from '@xterm/xterm'
|
||||
import type { ILink, ITheme } from '@xterm/xterm'
|
||||
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 { Checkbox, Modal } from 'antd'
|
||||
import { getThemeById } from '@shared/theme'
|
||||
import type { CommandItem } from '@shared/commands'
|
||||
import type { PtyDataEvent, PtyExitEvent } from '@shared/ipc'
|
||||
import type { TerminalSettings } from '@shared/settings'
|
||||
import { useSettingsStore } from '@renderer/settings/store'
|
||||
import { writeBroadcast } from '@renderer/workspace/broadcastStore'
|
||||
import { compileRules, HighlightStream, type CompiledRule } from './highlightEngine'
|
||||
import { findUrls } from './urlLinks'
|
||||
import { subscribePtyData, subscribePtyExit } from './ptyDispatcher'
|
||||
import { cdArgument, conemuCwd } from './cwdTracker'
|
||||
import { getSessionCwd, setSessionCwd } from '@renderer/workspace/sessionCwdStore'
|
||||
import './terminal.css'
|
||||
|
||||
/** Platform-modifier-based chord (Cmd on macOS, Ctrl elsewhere), plus Shift. */
|
||||
@@ -29,12 +33,27 @@ function toFontWeight(n: number): 'normal' | 'bold' | number {
|
||||
return n
|
||||
}
|
||||
|
||||
/** Paste-risk heuristic: multi-line or unreasonably long text. */
|
||||
function isRiskyPaste(text: string): boolean {
|
||||
if (text.includes('\n') || text.includes('\r')) return true
|
||||
return text.length > 100
|
||||
/**
|
||||
* Paste-risk heuristic, split by shape rather than by raw length:
|
||||
* - two or more lines always confirm — a stray newline executes a command
|
||||
* the user never reviewed;
|
||||
* - a single line pastes straight through, unless it is unusually long
|
||||
* (those are pasted scripts rather than something typed by hand).
|
||||
*/
|
||||
const SINGLE_LINE_CONFIRM_LENGTH = 1000
|
||||
function needsPasteConfirm(text: string): boolean {
|
||||
const normalized = text.replace(/\r\n?/g, '\n').replace(/\n$/, '')
|
||||
if (normalized.includes('\n')) return true
|
||||
return normalized.length > SINGLE_LINE_CONFIRM_LENGTH
|
||||
}
|
||||
|
||||
/**
|
||||
* "本次会话不再提示" from the paste confirmation dialog. Deliberately in memory
|
||||
* only: it lasts until the app exits and never becomes a stored preference
|
||||
* (that is what 关闭后续粘贴检测 in the dialog is for).
|
||||
*/
|
||||
let pasteConfirmedForSession = false
|
||||
|
||||
const SEARCH_DECORATIONS: NonNullable<NonNullable<Parameters<SearchAddon['findNext']>[1]>['decorations']> = {
|
||||
matchBackground: '#3b8eea',
|
||||
matchBorder: '#2d2d2d',
|
||||
@@ -150,11 +169,20 @@ function stepLineBuffer(
|
||||
outBuf.current = ''
|
||||
return { line: '' }
|
||||
}
|
||||
// xterm-native paste (plain Ctrl+V): with the shell's bracketed-paste mode
|
||||
// on, the pasted block arrives wrapped in \x1b[200~ / \x1b[201~ and the chunk
|
||||
// starts with ESC, which the guards below would drop wholesale. Strip the
|
||||
// markers and keep the pasted text as ordinary input.
|
||||
if (data.includes('\x1b[200~') || data.includes('\x1b[201~')) {
|
||||
const text = data.replace(/\x1b\[20[01]~/g, '')
|
||||
if (text) outBuf.current = b + text
|
||||
return { line: outBuf.current }
|
||||
}
|
||||
// A chunk that begins with escape/control sequences (e.g. the terminal's
|
||||
// clear-line) only removes buffer content; never appends.
|
||||
const first = data[0]
|
||||
if (b === '' && first === '\x1b') return { line: '' }
|
||||
if (first === '\r') return submitOr(b)
|
||||
if (first === '\r') return submitOr(outBuf, b)
|
||||
if (first === '\x7f') {
|
||||
outBuf.current = b.slice(0, -1)
|
||||
return { line: outBuf.current }
|
||||
@@ -168,8 +196,12 @@ function stepLineBuffer(
|
||||
return { line: outBuf.current }
|
||||
}
|
||||
|
||||
function submitOr(b: string): { line: string; submit?: string } {
|
||||
function submitOr(outBuf: React.MutableRefObject<string>, b: string): { line: string; submit?: string } {
|
||||
const t = b.trim()
|
||||
// Commit the line out of the buffer: the shell has it now, and anything typed
|
||||
// next belongs to a fresh line. Leaving it in made every following command
|
||||
// accumulate onto the previous one (`ls` + `pwd` → `lspwd` in the history).
|
||||
outBuf.current = ''
|
||||
return t ? { line: '', submit: t } : { line: '', submit: '' }
|
||||
}
|
||||
|
||||
@@ -226,6 +258,13 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
const searchRef = useRef<SearchAddon | null>(null)
|
||||
const observerRef = useRef<ResizeObserver | null>(null)
|
||||
const timerRef = useRef<number>(0)
|
||||
// PTY resize bookkeeping: `ptyTimerRef` debounces the resize while a window
|
||||
// drag/maximize settles, `ptySizeRef` drops the no-op resizes in between.
|
||||
// Poking the pty on every intermediate layout tick makes full-screen TUIs
|
||||
// (Claude Code et al.) redraw repeatedly at half-settled sizes, which is what
|
||||
// leaves duplicated frames behind in the scrollback.
|
||||
const ptyTimerRef = useRef<number>(0)
|
||||
const ptySizeRef = useRef<{ cols: number; rows: number } | null>(null)
|
||||
const deadRef = useRef(false)
|
||||
const searchOpenRef = useRef(false)
|
||||
const searchTermRef = useRef('')
|
||||
@@ -284,7 +323,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
// time (decides if 复制 is enabled).
|
||||
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null)
|
||||
const [menuHasSel, setMenuHasSel] = useState(false)
|
||||
const [paste, setPaste] = useState<string | null>(null)
|
||||
const [paste, setPaste] = useState<{ noPrompt: boolean; disableDetection: boolean } | null>(null)
|
||||
const [dead, setDead] = useState(false)
|
||||
const [exitCode, setExitCode] = useState(0)
|
||||
const [recording, setRecording] = useState(false)
|
||||
@@ -337,9 +376,29 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
return
|
||||
}
|
||||
const term = termRef.current
|
||||
if (term && !deadRef.current) {
|
||||
window.api.resizePty(sessionId, term.cols, term.rows)
|
||||
if (!term || deadRef.current) return
|
||||
// The xterm grid is already correct; only tell the pty once it settles.
|
||||
const last = ptySizeRef.current
|
||||
if (last && last.cols === term.cols && last.rows === term.rows) return
|
||||
const send = (): void => {
|
||||
const live = termRef.current
|
||||
if (!live || deadRef.current) return
|
||||
if (live.cols === ptySizeRef.current?.cols && live.rows === ptySizeRef.current?.rows) return
|
||||
ptySizeRef.current = { cols: live.cols, rows: live.rows }
|
||||
window.api.resizePty(sessionId, live.cols, live.rows)
|
||||
}
|
||||
window.clearTimeout(ptyTimerRef.current)
|
||||
// First size for a session goes out immediately (the pty spawns at a
|
||||
// placeholder 80x24 and a shell may already be drawing); later changes —
|
||||
// window drags, maximize/restore — wait for the layout to settle.
|
||||
if (!last) {
|
||||
send()
|
||||
return
|
||||
}
|
||||
// 100ms mirrors what other Electron terminals use: long enough to swallow
|
||||
// a maximize/restore animation tick storm, short enough that a plain
|
||||
// window drag does not visibly wrap against a stale width.
|
||||
ptyTimerRef.current = window.setTimeout(send, 100)
|
||||
})
|
||||
}, [sessionId])
|
||||
|
||||
@@ -396,15 +455,39 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
setResultInfo(found ? '已找到' : '未找到')
|
||||
}, [])
|
||||
|
||||
/** Record a submitted command line; cd-style lines also update cwd memory. */
|
||||
const trackSubmittedLine = useCallback(
|
||||
(submit: string): void => {
|
||||
void window.api.recordCommand(submit).catch(() => undefined)
|
||||
// Remember the directory for the next launch: a cd-style line is
|
||||
// resolved in the main process (the only side with node's `path`).
|
||||
const cdArg = cdArgument(submit)
|
||||
if (cdArg !== null) {
|
||||
void window.api
|
||||
.resolveCwd(getSessionCwd(sessionId), cdArg)
|
||||
.then((next: string | null) => {
|
||||
if (next) setSessionCwd(sessionId, next)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
},
|
||||
[sessionId]
|
||||
)
|
||||
|
||||
const confirmPaste = useCallback(
|
||||
(text: string) => {
|
||||
const data = nativePasteRef.current ?? text
|
||||
nativePasteRef.current = null
|
||||
setPaste(null)
|
||||
if (!deadRef.current) writeBroadcast(sessionId, data)
|
||||
// Route through xterm's own paste: when the shell asked for bracketed
|
||||
// paste (PSReadLine does), the text is wrapped in \x1b[200~ / \x1b[201~
|
||||
// and inserted as ONE edit instead of executing line by line — which is
|
||||
// what a large multi-line paste needs. It also feeds onData, so the line
|
||||
// buffer, command history and cwd memory see the pasted text.
|
||||
if (!deadRef.current) termRef.current?.paste(data)
|
||||
termRef.current?.focus()
|
||||
},
|
||||
[sessionId]
|
||||
[]
|
||||
)
|
||||
|
||||
const cancelPaste = useCallback(() => {
|
||||
@@ -437,16 +520,28 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
|
||||
const beginPaste = useCallback(
|
||||
(text: string) => {
|
||||
if (!deadRef.current && settings.terminal.pasteRiskConfirm && isRiskyPaste(text)) {
|
||||
// Read the setting at call time: this runs from a long-lived keydown
|
||||
// listener, so a captured value would go stale after a settings change.
|
||||
const confirmOn = useSettingsStore.getState().settings.terminal.pasteRiskConfirm
|
||||
if (!deadRef.current && confirmOn && !pasteConfirmedForSession && needsPasteConfirm(text)) {
|
||||
nativePasteRef.current = text
|
||||
setPaste(text)
|
||||
setPaste({ noPrompt: false, disableDetection: false })
|
||||
return
|
||||
}
|
||||
confirmPaste(text)
|
||||
},
|
||||
[settings.terminal.pasteRiskConfirm, confirmPaste]
|
||||
[confirmPaste]
|
||||
)
|
||||
|
||||
/** Confirm the risky-paste dialog: apply its choices, then paste. */
|
||||
const acceptPaste = useCallback((): void => {
|
||||
if (paste?.noPrompt) pasteConfirmedForSession = true
|
||||
if (paste?.disableDetection) {
|
||||
void useSettingsStore.getState().updateTerminal({ pasteRiskConfirm: false })
|
||||
}
|
||||
confirmPaste(nativePasteRef.current ?? '')
|
||||
}, [paste, confirmPaste])
|
||||
|
||||
const copySelection = useCallback(async () => {
|
||||
const term = termRef.current
|
||||
if (!term) return
|
||||
@@ -607,6 +702,29 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
term.loadAddon(searchAddon)
|
||||
searchRef.current = searchAddon
|
||||
|
||||
// cwd reports straight from the shell: OSC 7 is the portable form (bash,
|
||||
// zsh, fish, iTerm2, WezTerm, kitty all speak it), OSC 9;9 is ConEmu's,
|
||||
// used by the Windows shells. Normalization happens in the main process.
|
||||
const reportCwd = (payload: string): void => {
|
||||
void window.api
|
||||
.reportCwd(payload)
|
||||
.then((cwd: string | null) => {
|
||||
if (cwd) setSessionCwd(sessionId, cwd)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
const oscDisposables = [
|
||||
term.parser.registerOscHandler(7, (payload) => {
|
||||
reportCwd(payload)
|
||||
return true
|
||||
}),
|
||||
term.parser.registerOscHandler(9, (payload) => {
|
||||
const value = conemuCwd(payload)
|
||||
if (value) reportCwd(value)
|
||||
return true
|
||||
})
|
||||
]
|
||||
|
||||
termRef.current = term
|
||||
// Rebind (e.g. template apply) must clear a death marker left by the
|
||||
// previous session's exit — otherwise the pane stays under the dead mask.
|
||||
@@ -616,6 +734,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
// M5: reset per-session state on (re)bind — fresh line buffer, no leftovers
|
||||
// from a template apply or panel reuse; recording resumes idle.
|
||||
lineBufRef.current = ''
|
||||
ptySizeRef.current = null
|
||||
setSuggestions([])
|
||||
setSuggestionIndex(0)
|
||||
if (recordingRef.current) {
|
||||
@@ -649,17 +768,80 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
if (out) term.write(out)
|
||||
scheduleCarryFlush()
|
||||
}
|
||||
// Clickable URLs. Ctrl/Cmd+Click opens the target in the system browser;
|
||||
// the pointer cursor + underline advertise it on hover. A plain click stays
|
||||
// with the terminal (cursor placement / selection), so a link can never
|
||||
// open by accident while selecting text.
|
||||
const linkProvider = term.registerLinkProvider({
|
||||
provideLinks: (bufferLineNumber, callback) => {
|
||||
const buffer = term.buffer.active
|
||||
// provideLinks speaks 1-based line numbers, IBuffer.getLine 0-based.
|
||||
let first = bufferLineNumber - 1
|
||||
if (first < 0) {
|
||||
callback(undefined)
|
||||
return
|
||||
}
|
||||
while (first > 0 && buffer.getLine(first)?.isWrapped) first--
|
||||
const rows: { index: number; text: string }[] = []
|
||||
for (let row = first; row < buffer.length; row++) {
|
||||
const line = buffer.getLine(row)
|
||||
if (!line || (row > first && !line.isWrapped)) break
|
||||
rows.push({ index: row, text: line.translateToString(false) })
|
||||
}
|
||||
const text = rows.map((r) => r.text).join('')
|
||||
const cols = term.cols > 0 ? term.cols : 1
|
||||
const base = rows[0]?.index ?? first
|
||||
const links: ILink[] = findUrls(text).map((found) => {
|
||||
const startRow = Math.floor(found.start / cols)
|
||||
const endRow = Math.floor((found.end - 1) / cols)
|
||||
return {
|
||||
range: {
|
||||
start: { x: (found.start % cols) + 1, y: base + startRow + 1 },
|
||||
end: { x: ((found.end - 1) % cols) + 1, y: base + endRow + 1 }
|
||||
},
|
||||
text: found.url,
|
||||
decorations: { pointerCursor: true, underline: true },
|
||||
activate: (event: MouseEvent): void => {
|
||||
const mod = navigator.platform.toLowerCase().includes('mac')
|
||||
? event.metaKey
|
||||
: event.ctrlKey
|
||||
if (!mod) return
|
||||
// The main process routes window.open through shell.openExternal.
|
||||
window.open(found.url, '_blank', 'noopener')
|
||||
}
|
||||
}
|
||||
})
|
||||
callback(links.length > 0 ? links : undefined)
|
||||
}
|
||||
})
|
||||
const disposables: { dispose(): void }[] = [
|
||||
...oscDisposables,
|
||||
linkProvider,
|
||||
term.onData((data) => {
|
||||
if (!deadRef.current) writeBroadcast(sessionId, data)
|
||||
// M5: a completion acceptance rewrite armed `diffRewriteRef`; consume it
|
||||
// now so this redraw chunk replaces (not appends to) the line buffer.
|
||||
const consumeRewrite = diffRewriteRef.current
|
||||
diffRewriteRef.current = false
|
||||
// Full-screen TUIs (vim, Claude Code, …) own the alternate buffer, their
|
||||
// own key handling and their own cursor: the line buffer, the command
|
||||
// history and the completion popup have no business there. Interfering
|
||||
// swallowed ↑/↓/Tab/Esc (Esc could not even leave vim's insert mode) and
|
||||
// wrote TUI keystrokes into the command history.
|
||||
if (term.buffer.active.type === 'alternate') {
|
||||
lineBufRef.current = ''
|
||||
if (suggestionsRef.current.length > 0) {
|
||||
suggestionsRef.current = []
|
||||
selIndexRef.current = 0
|
||||
setSuggestions([])
|
||||
setSuggestionIndex(0)
|
||||
}
|
||||
return
|
||||
}
|
||||
// M5: line capture → recordCommand + inline completion overlay.
|
||||
const step = stepLineBuffer(lineBufRef, data, consumeRewrite)
|
||||
if (typeof step.submit === 'string' && step.submit) {
|
||||
void window.api.recordCommand(step.submit).catch(() => undefined)
|
||||
trackSubmittedLine(step.submit)
|
||||
}
|
||||
if (deadRef.current) {
|
||||
suggestionsRef.current = []
|
||||
@@ -708,18 +890,19 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
let replayDone = false
|
||||
const pending: string[] = []
|
||||
const unsubscribes: (() => void)[] = [
|
||||
window.api.onPtyData((e: PtyDataEvent) => {
|
||||
if (e.id !== sessionId || deadRef.current) return
|
||||
// Routed via the shared dispatcher (one IPC listener for all panes)
|
||||
// instead of a per-pane global listener.
|
||||
subscribePtyData(sessionId, (data: string) => {
|
||||
if (deadRef.current) return
|
||||
if (!replayDone) {
|
||||
pending.push(e.data)
|
||||
pending.push(data)
|
||||
return
|
||||
}
|
||||
writeHighlighted(e.data)
|
||||
writeHighlighted(data)
|
||||
}),
|
||||
window.api.onPtyExit((e: PtyExitEvent) => {
|
||||
if (e.id !== sessionId) return
|
||||
subscribePtyExit(sessionId, (code: number) => {
|
||||
deadRef.current = true
|
||||
setExitCode(e.exitCode)
|
||||
setExitCode(code)
|
||||
setDead(true)
|
||||
})
|
||||
]
|
||||
@@ -744,8 +927,36 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
observerRef.current = observer
|
||||
}
|
||||
|
||||
// Plain Ctrl/Cmd+V: not a chord xterm handles, so it used to reach the pty
|
||||
// as a literal ^V (0x16) — nothing pasted. Handled here, on the terminal
|
||||
// host in the capture phase, with preventDefault so the pty never sees it
|
||||
// and Chromium's own paste cannot double up. Text follows the same
|
||||
// single-line / multi-line rule as the Ctrl+Shift+V path.
|
||||
const host = hostRef.current
|
||||
const onPasteKey = (e: KeyboardEvent): void => {
|
||||
if (e.type !== 'keydown' || e.altKey) return
|
||||
const mod = navigator.platform.toLowerCase().includes('mac') ? e.metaKey : e.ctrlKey
|
||||
if (!mod || e.shiftKey || e.key.toLowerCase() !== 'v') return
|
||||
const target = e.target as HTMLElement | null
|
||||
const isXtermHelper =
|
||||
target instanceof HTMLTextAreaElement && target.classList.contains('xterm-helper-textarea')
|
||||
if (
|
||||
target &&
|
||||
!isXtermHelper &&
|
||||
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
||||
) {
|
||||
return // ordinary inputs keep the browser's native paste
|
||||
}
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
void pasteFromClipboard()
|
||||
}
|
||||
host?.addEventListener('keydown', onPasteKey, true)
|
||||
|
||||
return () => {
|
||||
if (carryTimer !== undefined) window.clearTimeout(carryTimer)
|
||||
if (ptyTimerRef.current) window.clearTimeout(ptyTimerRef.current)
|
||||
host?.removeEventListener('keydown', onPasteKey, true)
|
||||
for (const unsubscribe of unsubscribes) unsubscribe()
|
||||
for (const disposable of disposables) disposable.dispose()
|
||||
observer?.disconnect()
|
||||
@@ -781,6 +992,9 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
term.options.cursorBlink = tSettings.cursorBlink
|
||||
term.options.cursorStyle = tSettings.cursorStyle
|
||||
term.options.cursorInactiveStyle = tSettings.cursorInactiveStyle
|
||||
// Live-appliable in xterm 6: without this the setting only took effect for
|
||||
// terminals opened after the change, which reads as "the setting is broken".
|
||||
term.options.scrollback = tSettings.scrollback
|
||||
term.options.theme = withChromeColors(getThemeById(tSettings.themeId, settings.customThemes).colors as ITheme)
|
||||
scheduleFit()
|
||||
}, [tSettings, settings.customThemes, scheduleFit])
|
||||
@@ -972,17 +1186,33 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
|
||||
<FolderOpenOutlined />
|
||||
</button>
|
||||
</div>
|
||||
{paste !== null && !dead && (
|
||||
<div className="term-pastebar">
|
||||
<span className="term-pastebar-text">检测到多行/长文本粘贴</span>
|
||||
<button type="button" className="term-pastebar-btn term-pastebar-btn-ok" onClick={() => confirmPaste(paste)}>
|
||||
粘贴
|
||||
</button>
|
||||
<button type="button" className="term-pastebar-btn term-pastebar-btn-cancel" onClick={cancelPaste}>
|
||||
取消
|
||||
</button>
|
||||
<Modal
|
||||
open={paste !== null}
|
||||
title="确认粘贴"
|
||||
okText="粘贴"
|
||||
cancelText="取消"
|
||||
width={420}
|
||||
maskClosable={false}
|
||||
onOk={acceptPaste}
|
||||
onCancel={cancelPaste}
|
||||
>
|
||||
<div className="term-paste-confirm">
|
||||
<p>检测到多行或潜在危险命令,确定要粘贴到终端吗?</p>
|
||||
<Checkbox
|
||||
checked={paste?.noPrompt ?? false}
|
||||
onChange={(e) => setPaste((prev) => (prev ? { ...prev, noPrompt: e.target.checked } : prev))}
|
||||
>
|
||||
本次会话不再提示
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
checked={paste?.disableDetection ?? false}
|
||||
onChange={(e) => setPaste((prev) => (prev ? { ...prev, disableDetection: e.target.checked } : prev))}
|
||||
>
|
||||
关闭后续粘贴检测
|
||||
</Checkbox>
|
||||
<p className="term-paste-confirm-hint">可在通用终端设置中重新开启或切换检测策略。</p>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
{/* Relative-positioned wrapper around the xterm surface: the suggestion
|
||||
popup anchors to this box, so its coordinates start at the terminal
|
||||
content rows (the search bar above never offsets the top). */}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Working-directory tracking helpers.
|
||||
*
|
||||
* Two signals feed the same store:
|
||||
* 1. the shell itself, over OSC 7 (`file://host/path`) or ConEmu's OSC 9;9 —
|
||||
* exact, but only present when shell integration is on;
|
||||
* 2. the command the user typed, matched against the `cd` family plus the
|
||||
* bare drive switch (`d:`) of cmd.exe / PowerShell — the fallback that
|
||||
* works with no shell cooperation at all.
|
||||
*
|
||||
* Both are platform-neutral: path resolution and existence checks happen in the
|
||||
* main process (`src/main/cwd.ts`) where node's `path` lives, so nothing here
|
||||
* assumes backslashes or drive letters.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The `cd` family, per shell:
|
||||
* bash/zsh/dash/fish cd
|
||||
* cmd.exe cd, chdir
|
||||
* PowerShell cd (alias), sl (alias), Set-Location, pushd/Push-Location
|
||||
* The `$` anchor is what keeps lookalikes out: `cdrom`, `cdemo` never match.
|
||||
*/
|
||||
const CD_COMMAND = /^\s*(?:cd|chdir|sl|set-location|pushd|push-location)(?:\s+([\s\S]*))?$/i
|
||||
|
||||
/**
|
||||
* Bare drive switch (`d:`, `D:`) — cmd.exe and PowerShell change the current
|
||||
* drive with no `cd` keyword at all, and it is the common way to land on
|
||||
* another drive on Windows. Treated as a cd to the drive root; the main
|
||||
* process's existence check quietly drops it on platforms where drive
|
||||
* letters mean nothing (POSIX resolves it to a nonexistent relative path).
|
||||
*/
|
||||
const DRIVE_SWITCH = /^\s*([a-zA-Z]):\s*$/
|
||||
|
||||
/**
|
||||
* Return the argument of a cd-style command line, or null when the line is not
|
||||
* one. An empty string means "bare cd" (the shell's home directory). A bare
|
||||
* drive switch yields the drive root (`D:\`).
|
||||
*/
|
||||
export function cdArgument(line: string): string | null {
|
||||
const match = line.match(CD_COMMAND)
|
||||
if (match) return (match[1] ?? '').trim()
|
||||
const drive = line.match(DRIVE_SWITCH)
|
||||
if (drive) return `${drive[1].toUpperCase()}:\\`
|
||||
return null
|
||||
}
|
||||
|
||||
/** Payload of an OSC 9 sequence that follows the ConEmu `9;<path>` convention. */
|
||||
export function conemuCwd(payload: string): string | null {
|
||||
if (!payload.startsWith('9;')) return null
|
||||
const value = payload.slice(2).trim()
|
||||
return value || null
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { PtyDataEvent, PtyExitEvent } from '@shared/ipc'
|
||||
|
||||
/**
|
||||
* Single shared PTY stream listener. Every pane used to register its own
|
||||
* window.api.onPtyData/onPtyExit listener, so each output chunk woke N
|
||||
* listeners (N = pane count) and N-1 of them discarded it after an id
|
||||
* compare. With many terminals under heavy output that per-chunk fan-out is
|
||||
* pure overhead. Here one IPC listener dispatches through a Map keyed by
|
||||
* sessionId: one lookup per chunk, and only the owning pane runs.
|
||||
*/
|
||||
|
||||
type DataHandler = (data: string) => void
|
||||
type ExitHandler = (exitCode: number) => void
|
||||
|
||||
const dataHandlers = new Map<string, DataHandler>()
|
||||
const exitHandlers = new Map<string, ExitHandler>()
|
||||
let bound = false
|
||||
|
||||
function ensureBound(): void {
|
||||
if (bound) return
|
||||
bound = true
|
||||
window.api.onPtyData((e: PtyDataEvent) => {
|
||||
dataHandlers.get(e.id)?.(e.data)
|
||||
})
|
||||
window.api.onPtyExit((e: PtyExitEvent) => {
|
||||
exitHandlers.get(e.id)?.(e.exitCode)
|
||||
})
|
||||
}
|
||||
|
||||
/** Subscribe to one session's output. Returns the unsubscribe function. */
|
||||
export function subscribePtyData(id: string, cb: DataHandler): () => void {
|
||||
ensureBound()
|
||||
dataHandlers.set(id, cb)
|
||||
return () => {
|
||||
// A rebind (template apply) may have replaced the handler already; only
|
||||
// delete when this subscription is still the registered one.
|
||||
if (dataHandlers.get(id) === cb) dataHandlers.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscribe to one session's exit. Returns the unsubscribe function. */
|
||||
export function subscribePtyExit(id: string, cb: ExitHandler): () => void {
|
||||
ensureBound()
|
||||
exitHandlers.set(id, cb)
|
||||
return () => {
|
||||
if (exitHandlers.get(id) === cb) exitHandlers.delete(id)
|
||||
}
|
||||
}
|
||||
@@ -328,51 +328,20 @@
|
||||
}
|
||||
|
||||
/* ---- paste risk confirm bar ---- */
|
||||
.term-pastebar {
|
||||
/* ---- risky-paste confirmation dialog body ---- */
|
||||
.term-paste-confirm p {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.term-paste-confirm .ant-checkbox-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
padding: 4px 8px;
|
||||
background: #3a2e1a;
|
||||
border-bottom: 1px solid #5c4a26;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.term-paste-confirm-hint {
|
||||
margin: 12px 0 0 !important;
|
||||
font-size: 12px;
|
||||
color: #e8d9a8;
|
||||
}
|
||||
|
||||
.term-pastebar-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.term-pastebar-btn {
|
||||
flex: none;
|
||||
padding: 3px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.term-pastebar-btn-ok {
|
||||
background: var(--chrome-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.term-pastebar-btn-ok:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.term-pastebar-btn-cancel {
|
||||
background: transparent;
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
|
||||
.term-pastebar-btn-cancel:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
/* ---- terminal docked area (flex:1) ---- */
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Clickable URLs in terminal output.
|
||||
*
|
||||
* xterm has no link detection of its own for plain text (OSC 8 hyperlinks are
|
||||
* a separate, opt-in protocol the CLI must emit), so a link provider scans the
|
||||
* buffer line and hands back ranges. Two shapes are recognised:
|
||||
* 1. fully qualified `http(s)://host[:port][/path][?query]`;
|
||||
* 2. the scheme-less dev-server forms `localhost:5000`, `127.0.0.1:8080`,
|
||||
* `0.0.0.0:3000` — opened as http.
|
||||
*
|
||||
* Because PTY output wraps long URLs across buffer rows, the provider rebuilds
|
||||
* the whole *logical* line (walking back through `isWrapped` rows and forward
|
||||
* to the last wrapped one) before matching, so a link split by the terminal
|
||||
* width still resolves as one range.
|
||||
*/
|
||||
|
||||
/** Characters that end a URL: whitespace, quotes, brackets, CJK punctuation. */
|
||||
const URL_BODY = `[^\\s"'\`<>()\\[\\]{},。;:、]+`
|
||||
|
||||
const SCHEME_URL = new RegExp(`\\bhttps?://${URL_BODY}`, 'gi')
|
||||
const FTP_URL = new RegExp(`\\bftp://${URL_BODY}`, 'gi')
|
||||
const WWW_HOST = new RegExp(`\\bwww\\.[A-Za-z0-9-]+\\.[A-Za-z]{2,}(?::\\d{1,5})?(?:/${URL_BODY})?`, 'gi')
|
||||
const BARE_HOST = new RegExp(`\\b(?:localhost|127\\.0\\.0\\.1|0\\.0\\.0\\.0)(?::\\d{1,5})(?:/${URL_BODY})?`, 'gi')
|
||||
|
||||
/** Trailing punctuation that belongs to the sentence, not the URL. */
|
||||
const TRAILING = /[.,;:!?)\]}>"',。;:、]+$/
|
||||
|
||||
export interface FoundLink {
|
||||
/** absolute offset of the first character, within the logical line */
|
||||
start: number
|
||||
/** absolute offset one past the last character */
|
||||
end: number
|
||||
/** the clickable target, scheme guaranteed */
|
||||
url: string
|
||||
}
|
||||
|
||||
/** Match every URL in one logical line of text. */
|
||||
export function findUrls(text: string): FoundLink[] {
|
||||
const out: FoundLink[] = []
|
||||
const scan = (re: RegExp, withScheme: boolean): void => {
|
||||
re.lastIndex = 0
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const trimmed = m[0].replace(TRAILING, '')
|
||||
if (!trimmed) continue
|
||||
out.push({
|
||||
start: m.index,
|
||||
end: m.index + trimmed.length,
|
||||
url: withScheme ? trimmed : `http://${trimmed}`
|
||||
})
|
||||
}
|
||||
}
|
||||
scan(SCHEME_URL, true)
|
||||
scan(FTP_URL, true)
|
||||
// `www.…` (and the bare host forms) get an http:// target when opened.
|
||||
scan(WWW_HOST, false)
|
||||
scan(BARE_HOST, false)
|
||||
// A bare host inside a scheme URL (`http://localhost:5000`) matches both
|
||||
// patterns; keep the longer, scheme-carrying one.
|
||||
out.sort((a, b) => a.start - b.start || b.end - a.end)
|
||||
const kept: FoundLink[] = []
|
||||
for (const link of out) {
|
||||
if (kept.some((k) => link.start < k.end && link.end > k.start)) continue
|
||||
kept.push(link)
|
||||
}
|
||||
return kept
|
||||
}
|
||||
@@ -7,6 +7,17 @@ function parseHex(color: string): [number, number, number] | null {
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
|
||||
}
|
||||
|
||||
/** WCAG relative luminance (0=black, 1=white); null for non-hex input. */
|
||||
function luminance(color: string): number | null {
|
||||
const c = parseHex(color)
|
||||
if (!c) return null
|
||||
const lin = c.map((v) => {
|
||||
const s = v / 255
|
||||
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
|
||||
})
|
||||
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
|
||||
}
|
||||
|
||||
function toHex(v: number): string {
|
||||
return Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
|
||||
}
|
||||
@@ -19,6 +30,10 @@ export function mix(a: string, b: string, t: number): string | null {
|
||||
return `#${toHex(ca[0] + (cb[0] - ca[0]) * t)}${toHex(ca[1] + (cb[1] - ca[1]) * t)}${toHex(ca[2] + (cb[2] - ca[2]) * t)}`
|
||||
}
|
||||
|
||||
/** Whether the active terminal theme has a light background. Tab bars derived
|
||||
* from it flip their overlay direction (white-on-dark vs black-on-light). */
|
||||
let chromeIsLight = false
|
||||
|
||||
/** Derive the app chrome palette (sidebar, tab bars, dividers) from the active
|
||||
* terminal theme so the whole window follows theme switches instead of only
|
||||
* the terminal canvas. Values land on :root CSS variables; anything the theme
|
||||
@@ -30,13 +45,36 @@ export function applyChromeTheme(colors: ThemeColors): void {
|
||||
}
|
||||
set('--chrome-bg', colors.background)
|
||||
set('--chrome-fg', colors.foreground)
|
||||
set('--chrome-bg-deep', mix(colors.background, '#000000', 0.35))
|
||||
set('--chrome-hover', mix(colors.background, colors.foreground, 0.08))
|
||||
set('--chrome-border', mix(colors.background, colors.foreground, 0.16))
|
||||
chromeIsLight = (luminance(colors.background) ?? 0) > 0.5
|
||||
if (chromeIsLight) {
|
||||
// Light chrome: keep the bar close to the background (a big mix toward
|
||||
// black turns pale themes into a muddy gray strip) and overlay tabs with
|
||||
// translucent black instead of the translucent white used on dark themes.
|
||||
set('--chrome-bg-deep', mix(colors.background, '#000000', 0.08))
|
||||
set('--chrome-hover', mix(colors.background, '#000000', 0.06))
|
||||
set('--chrome-border', mix(colors.background, '#000000', 0.2))
|
||||
set('--chrome-tab-bg', 'rgba(0, 0, 0, 0.045)')
|
||||
set('--chrome-tab-bg-hover', 'rgba(0, 0, 0, 0.09)')
|
||||
set('--chrome-tab-ring', 'rgba(0, 0, 0, 0.18)')
|
||||
set('--chrome-tab-ring-hover', 'rgba(0, 0, 0, 0.32)')
|
||||
set('--chrome-tab-fg', mix(colors.background, '#000000', 0.68))
|
||||
set('--chrome-tab-fg-hover', mix(colors.background, '#000000', 0.88))
|
||||
} else {
|
||||
set('--chrome-bg-deep', mix(colors.background, '#000000', 0.35))
|
||||
set('--chrome-hover', mix(colors.background, colors.foreground, 0.08))
|
||||
set('--chrome-border', mix(colors.background, colors.foreground, 0.16))
|
||||
set('--chrome-tab-bg', 'rgba(255, 255, 255, 0.04)')
|
||||
set('--chrome-tab-bg-hover', 'rgba(255, 255, 255, 0.07)')
|
||||
set('--chrome-tab-ring', 'rgba(255, 255, 255, 0.1)')
|
||||
set('--chrome-tab-ring-hover', 'rgba(255, 255, 255, 0.2)')
|
||||
set('--chrome-tab-fg', '#98a2ab')
|
||||
set('--chrome-tab-fg-hover', '#d0d0d0')
|
||||
}
|
||||
}
|
||||
|
||||
/** Tab accent: base color + a lightened foreground variant for text on dark
|
||||
* tinted backgrounds. Consumed via var(--tab-accent*) in workspace.css. */
|
||||
/** Tab accent: base color + a foreground variant that stays readable on the
|
||||
* bar (lightened on dark chrome, darkened on light chrome). Consumed via
|
||||
* var(--tab-accent*) in workspace.css. */
|
||||
export function applyTabAccent(color: string): void {
|
||||
const style = document.documentElement.style
|
||||
// Settings hydrated from an older main process may lack the key; clear the
|
||||
@@ -47,5 +85,6 @@ export function applyTabAccent(color: string): void {
|
||||
return
|
||||
}
|
||||
style.setProperty('--tab-accent', color)
|
||||
style.setProperty('--tab-accent-fg', mix(color, '#ffffff', 0.18) ?? color)
|
||||
const fg = chromeIsLight ? mix(color, '#000000', 0.35) : mix(color, '#ffffff', 0.18)
|
||||
style.setProperty('--tab-accent-fg', fg ?? color)
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { App, Input } from 'antd'
|
||||
import { create } from 'zustand'
|
||||
import type { CommandItem } from '@shared/commands'
|
||||
import { writeBroadcast } from './broadcastStore'
|
||||
|
||||
/** Open state shared between the tab-bar toggle button and the floating panel. */
|
||||
export const useQuickInputStore = create<{
|
||||
open: boolean
|
||||
toggle: () => void
|
||||
setOpen: (open: boolean) => void
|
||||
}>((set) => ({
|
||||
open: window.localStorage.getItem('ot.quickInputOpen') === '1',
|
||||
toggle: () =>
|
||||
set((state) => {
|
||||
const next = !state.open
|
||||
window.localStorage.setItem('ot.quickInputOpen', next ? '1' : '0')
|
||||
return { open: next }
|
||||
}),
|
||||
setOpen: (open) => {
|
||||
window.localStorage.setItem('ot.quickInputOpen', open ? '1' : '0')
|
||||
set({ open })
|
||||
}
|
||||
}))
|
||||
|
||||
/**
|
||||
* M6 quick-input panel (bottom-right floating). Three sections:
|
||||
* 1. free-text command line (with auto-execute(\r) toggle)
|
||||
* 2. library commands (api.listLibrary())
|
||||
* 3. recent history (api.listHistory(), top 10)
|
||||
*
|
||||
* Sends target = the active session via `onResolveActiveSession`; when broadcast
|
||||
* is enabled and the active session is a target, the send routes through the
|
||||
* broadcast fan-out so it reaches every target. Lists refresh on open.
|
||||
*/
|
||||
export interface QuickInputPanelProps {
|
||||
/** resolve the currently active terminal session id ('' when none) */
|
||||
onResolveActiveSession: () => string
|
||||
}
|
||||
|
||||
export function QuickInputPanel({ onResolveActiveSession }: QuickInputPanelProps): React.JSX.Element | null {
|
||||
const { message } = App.useApp()
|
||||
const open = useQuickInputStore((s) => s.open)
|
||||
const close = useQuickInputStore((s) => s.setOpen)
|
||||
// auto-execute(\r); persisted across sessions.
|
||||
const [autoEnter, setAutoEnter] = useState<boolean>(() => window.localStorage.getItem('ot.quickInputEnter') !== '0')
|
||||
const [value, setValue] = useState('')
|
||||
const [history, setHistory] = useState<CommandItem[]>([])
|
||||
const [library, setLibrary] = useState<CommandItem[]>([])
|
||||
/** tracks the previous `open` so lists refresh on the false→true transition */
|
||||
const prevOpenRef = useRef(open)
|
||||
|
||||
// Refresh lists every time the panel opens (and stay fresh if it was open).
|
||||
const refresh = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const [h, l] = await Promise.all([window.api.listHistory(), window.api.listLibrary()])
|
||||
setHistory(h.slice(0, 10))
|
||||
setLibrary(l)
|
||||
} catch {
|
||||
setHistory([])
|
||||
setLibrary([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !prevOpenRef.current) void refresh()
|
||||
prevOpenRef.current = open
|
||||
}, [open, refresh])
|
||||
|
||||
const send = useCallback(
|
||||
(text: string) => {
|
||||
const target = onResolveActiveSession()
|
||||
if (!target) {
|
||||
void message.info('没有激活的终端会话')
|
||||
return
|
||||
}
|
||||
const data = autoEnter ? `${text}\r` : text
|
||||
writeBroadcast(target, data)
|
||||
// keep "最近命令" fresh immediately after a send
|
||||
void window.api
|
||||
.listHistory()
|
||||
.then((h: CommandItem[]) => setHistory(h.slice(0, 10)))
|
||||
.catch(() => undefined)
|
||||
setValue('')
|
||||
},
|
||||
[autoEnter, onResolveActiveSession]
|
||||
)
|
||||
|
||||
const onEnter = useCallback((): void => {
|
||||
const t = value.trim()
|
||||
if (t) send(t)
|
||||
}, [value, send])
|
||||
|
||||
const onToggleAutoEnter = useCallback((checked: boolean): void => {
|
||||
setAutoEnter(checked)
|
||||
window.localStorage.setItem('ot.quickInputEnter', checked ? '1' : '0')
|
||||
}, [])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="quick-input-panel">
|
||||
<div className="quick-input-headbar">
|
||||
<span className="quick-input-headbar-title">快捷输入</span>
|
||||
<button type="button" className="quick-input-close" title="收起" aria-label="收起" onClick={() => close(false)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="quick-input-row">
|
||||
<Input
|
||||
className="quick-input-field"
|
||||
placeholder="输入命令…"
|
||||
value={value}
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onPressEnter={onEnter}
|
||||
/>
|
||||
<label className="quick-input-autoenter" title="关闭后只键入不回车">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoEnter}
|
||||
onChange={(e) => onToggleAutoEnter(e.target.checked)}
|
||||
/>
|
||||
<span>自动执行(\r)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="quick-input-section">
|
||||
<div className="quick-input-head">快捷命令</div>
|
||||
{library.length === 0 ? (
|
||||
<div className="quick-input-empty">暂无快捷命令</div>
|
||||
) : (
|
||||
<div className="quick-input-list">
|
||||
{library.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="quick-input-item"
|
||||
title={item.command}
|
||||
onClick={() => send(item.command)}
|
||||
>
|
||||
<span className="quick-input-item-cmd">{item.command}</span>
|
||||
{item.name && <span className="quick-input-item-tag">{item.name}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="quick-input-section">
|
||||
<div className="quick-input-head">最近命令</div>
|
||||
{history.length === 0 ? (
|
||||
<div className="quick-input-empty">暂无历史记录</div>
|
||||
) : (
|
||||
<div className="quick-input-list">
|
||||
{history.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="quick-input-item"
|
||||
title={item.command}
|
||||
onClick={() => send(item.command)}
|
||||
>
|
||||
<span className="quick-input-item-cmd">{item.command}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,12 +10,12 @@ import {
|
||||
MenuUnfoldOutlined,
|
||||
PlusOutlined,
|
||||
SettingOutlined,
|
||||
ShareAltOutlined,
|
||||
ThunderboltOutlined
|
||||
ShareAltOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { DockviewReact } from 'dockview-react'
|
||||
import type {
|
||||
DockviewApi,
|
||||
DockviewPanelApi,
|
||||
DockviewReadyEvent,
|
||||
IDockviewHeaderActionsProps,
|
||||
IDockviewPanel,
|
||||
@@ -32,7 +32,9 @@ import { HostKeyModal } from './HostKeyModal'
|
||||
import { TerminalPanel } from './TerminalPanel'
|
||||
import { ApplyTemplateModal, SaveTemplateModal, TerminalTab } from './TerminalTab'
|
||||
import { useBroadcastStore, writeBroadcast } from './broadcastStore'
|
||||
import { QuickInputPanel, useQuickInputStore } from './QuickInputPanel'
|
||||
import { forgetSessionCwd, getSessionCwd, onSessionCwdChange, setSessionCwd } from './sessionCwdStore'
|
||||
import { useSettingsStore } from '@renderer/settings/store'
|
||||
import type { SessionSnapshot, SessionPanelState } from '@shared/ipc'
|
||||
import type { WorkspaceMode } from './workspaceMode'
|
||||
import { useWorkspaceModeStore } from './workspaceModeStore'
|
||||
import './workspace.css'
|
||||
@@ -59,18 +61,113 @@ const TERMINAL_COMPONENT = 'terminal'
|
||||
const TERMINAL_TAB_COMPONENT = 'terminal-tab'
|
||||
|
||||
/**
|
||||
* Per-workspace monotonic title counter for "终端 N". Lives on `window` so
|
||||
* dev hot-reloads (which reset module state) don't restart the numbering and
|
||||
* produce duplicate tab titles.
|
||||
* "终端 N" titles fill the lowest free number among live panels: closing a
|
||||
* pane frees its number for the next new terminal, and a session restore can
|
||||
* no longer leave the numbering behind (the old in-memory counter restarted
|
||||
* at zero on launch and produced duplicate titles).
|
||||
*/
|
||||
const titleSeqHolder = window as unknown as { __otTitleSeq?: number }
|
||||
function nextTerminalTitle(): string {
|
||||
titleSeqHolder.__otTitleSeq = (titleSeqHolder.__otTitleSeq ?? 0) + 1
|
||||
return `终端 ${titleSeqHolder.__otTitleSeq}`
|
||||
function nextTerminalTitle(existing: Iterable<string | undefined>): string {
|
||||
const used = new Set<number>()
|
||||
for (const title of existing) {
|
||||
const n = title?.match(/^终端 (\d+)$/)?.[1]
|
||||
if (n) used.add(Number(n))
|
||||
}
|
||||
let n = 1
|
||||
while (used.has(n)) n++
|
||||
return `终端 ${n}`
|
||||
}
|
||||
|
||||
function sessionIdOf(panel: IDockviewPanel): string | undefined {
|
||||
return (panel.params as TerminalParams | undefined)?.sessionId
|
||||
function sessionIdOf(panel: IDockviewPanel | undefined): string | undefined {
|
||||
return (panel?.params as TerminalParams | undefined)?.sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* A dockview panel id.
|
||||
*
|
||||
* Panel ids must never be reused as pty session ids. `updateParameters` can
|
||||
* swap the session behind a panel, but the panel id is fixed for the panel's
|
||||
* whole life, so an id that doubles as a session id goes stale the moment the
|
||||
* pane is rebound: the restored pane would be addressed by an id that no longer
|
||||
* names any session, and it comes up with no pty behind it. Ids here are opaque
|
||||
* and distinct from any session id by construction.
|
||||
*/
|
||||
function panelId(): string {
|
||||
return `p-${window.crypto.randomUUID().slice(0, 8)}`
|
||||
}
|
||||
|
||||
interface RawLayout {
|
||||
grid?: unknown
|
||||
panels?: Record<string, { id?: string; params?: { sessionId?: string } }>
|
||||
activeGroup?: string
|
||||
}
|
||||
|
||||
interface RenamedLayout {
|
||||
layout: unknown
|
||||
/** old panel id → new panel id, so a caller holding a snapshot keyed by the
|
||||
* old ids can still find a panel's recorded state afterwards. */
|
||||
renames: Map<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a snapshot layout so no panel id equals its session id.
|
||||
*
|
||||
* Older builds created panels with `id: sessionId`, and those snapshots are
|
||||
* still on disk. `updateParameters` cannot rename a panel, so the id has to be
|
||||
* swapped in the serialized layout where it appears in all three places that
|
||||
* must agree: the `panels` map key, the panel's own `id`, and every `views`
|
||||
* entry in the grid tree.
|
||||
*
|
||||
* Returns the layout untouched when no collision exists (the common case), so
|
||||
* the fast path stays a plain object reference.
|
||||
*/
|
||||
function breakIdCoincidence(layout: unknown): RenamedLayout {
|
||||
if (!layout || typeof layout !== 'object') return { layout, renames: new Map() }
|
||||
const raw = layout as RawLayout
|
||||
const panels = raw.panels
|
||||
if (!panels) return { layout, renames: new Map() }
|
||||
const renames = new Map<string, string>()
|
||||
for (const [key, panel] of Object.entries(panels)) {
|
||||
const sessionId = panel?.params?.sessionId
|
||||
if (sessionId && key === sessionId) renames.set(key, panelId())
|
||||
}
|
||||
if (renames.size === 0) return { layout, renames }
|
||||
|
||||
const next: RawLayout = { ...raw, panels: {} }
|
||||
for (const [key, panel] of Object.entries(panels)) {
|
||||
const target = renames.get(key)
|
||||
next.panels![target ?? key] = target ? { ...panel, id: target } : panel
|
||||
}
|
||||
// `views` / `activeView` are the only fields naming panels. Three shapes to
|
||||
// tell apart, and the walk must preserve whichever is present:
|
||||
// grid { root, width, height, orientation }
|
||||
// branch { type: 'branch', data: node[], size }
|
||||
// leaf { type: 'leaf', data: { views, activeView, id }, size }
|
||||
const renameLeaf = (leaf: Record<string, unknown>): Record<string, unknown> => {
|
||||
if (!Array.isArray(leaf.views)) return leaf
|
||||
return {
|
||||
...leaf,
|
||||
views: leaf.views.map((v) => (typeof v === 'string' ? renames.get(v) ?? v : v)),
|
||||
activeView:
|
||||
typeof leaf.activeView === 'string' ? renames.get(leaf.activeView) ?? leaf.activeView : leaf.activeView
|
||||
}
|
||||
}
|
||||
const renameNode = (node: unknown): unknown => {
|
||||
if (Array.isArray(node)) return node.map(renameNode)
|
||||
if (!node || typeof node !== 'object') return node
|
||||
const entry = node as Record<string, unknown>
|
||||
if (entry.type === 'leaf' || entry.type === 'branch') {
|
||||
const data = entry.data
|
||||
if (entry.type === 'leaf' && data && typeof data === 'object' && !Array.isArray(data)) {
|
||||
return { ...entry, data: renameLeaf(data as Record<string, unknown>) }
|
||||
}
|
||||
return { ...entry, data: renameNode(data) }
|
||||
}
|
||||
// The grid wrapper (and anything else shaped like it) keys its tree on `root`.
|
||||
if (entry.root !== undefined) return { ...entry, root: renameNode(entry.root) }
|
||||
return node
|
||||
}
|
||||
next.grid = renameNode(raw.grid)
|
||||
return { layout: next, renames }
|
||||
}
|
||||
|
||||
/** The dockview api that owns a given workspace mode. */
|
||||
@@ -92,11 +189,12 @@ function createTabActions(mode: WorkspaceMode): (props: IDockviewHeaderActionsPr
|
||||
}
|
||||
void (async (): Promise<void> => {
|
||||
const result = await window.api.createPty()
|
||||
setSessionCwd(result.id, result.cwd)
|
||||
containerApi.addPanel<TerminalParams>({
|
||||
id: result.id,
|
||||
id: panelId(),
|
||||
component: TERMINAL_COMPONENT,
|
||||
tabComponent: TERMINAL_TAB_COMPONENT,
|
||||
title: nextTerminalTitle(),
|
||||
title: nextTerminalTitle(containerApi.panels.map((p) => p.title)),
|
||||
params: { sessionId: result.id, sessionKind: 'local' },
|
||||
position: { referenceGroup: group.id, direction: 'within' }
|
||||
})
|
||||
@@ -107,7 +205,6 @@ function createTabActions(mode: WorkspaceMode): (props: IDockviewHeaderActionsPr
|
||||
<div className="workspace-tabbar-actions">
|
||||
<AddTerminalGroupButton onClick={handleAdd} />
|
||||
<BroadcastToggleButton />
|
||||
<QuickInputToggleButton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -146,7 +243,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
/** Per-workspace active panel used as the split anchor. */
|
||||
const activeTerminalPanelRef = useRef<IDockviewPanel | undefined>(undefined)
|
||||
const activeSshPanelRef = useRef<IDockviewPanel | undefined>(undefined)
|
||||
/** The currently-visible mode's active panel (broadcast / quick-input target). */
|
||||
/** The currently-visible mode's active panel (broadcast target). */
|
||||
const activePanelRef = useRef<IDockviewPanel | undefined>(undefined)
|
||||
|
||||
/** Sessions we already asked to kill — kill once, never twice. */
|
||||
@@ -185,6 +282,54 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
/** B's sidebar handle; refresh() after a successful SSH connect */
|
||||
const sidebarRef = useRef<ConnectionSidebarHandle>(null)
|
||||
|
||||
// ---- session memory (restore last layout + per-pane cwd) ----
|
||||
/** Set once the boot pass has read the snapshot; saves stay quiet until then. */
|
||||
const bootDoneRef = useRef(false)
|
||||
/** Debounce handle for the snapshot write. */
|
||||
const saveTimerRef = useRef(0)
|
||||
/** Newest local directory seen — seeds brand-new terminals. */
|
||||
const lastLocalCwdRef = useRef<string | null>(null)
|
||||
|
||||
/** Assemble what the next launch needs: both layouts, every pane's kind and
|
||||
* directory, and the workspace the user was in. */
|
||||
const buildSnapshot = useCallback((): SessionSnapshot => {
|
||||
const collect = (api: DockviewApi | undefined): SessionPanelState[] => {
|
||||
if (!api) return []
|
||||
return api.panels.map((panel) => {
|
||||
const params = panel.params as TerminalParams | undefined
|
||||
return {
|
||||
panelId: panel.id,
|
||||
title: panel.title ?? '',
|
||||
kind: params?.sessionKind === 'ssh' ? 'ssh' : 'local',
|
||||
cwd: getSessionCwd(params?.sessionId),
|
||||
hostLabel: params?.hostLabel
|
||||
}
|
||||
})
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
savedAt: Date.now(),
|
||||
mode: useWorkspaceModeStore.getState().mode,
|
||||
layouts: {
|
||||
terminal: terminalApiRef.current?.toJSON(),
|
||||
ssh: sshApiRef.current?.toJSON()
|
||||
},
|
||||
panels: [...collect(terminalApiRef.current), ...collect(sshApiRef.current)],
|
||||
lastLocalCwd: lastLocalCwdRef.current ?? undefined
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** Debounced write (layout changes stream during window drags). */
|
||||
const scheduleSave = useCallback((): void => {
|
||||
// Never overwrite the snapshot before the boot pass has read it — an empty
|
||||
// layout would otherwise erase the very thing we are about to restore.
|
||||
if (!bootDoneRef.current) return
|
||||
window.clearTimeout(saveTimerRef.current)
|
||||
saveTimerRef.current = window.setTimeout(() => {
|
||||
void window.api.saveSessionState(buildSnapshot()).catch(() => undefined)
|
||||
}, 500)
|
||||
}, [buildSnapshot])
|
||||
|
||||
/** Idempotently kill a pty session. */
|
||||
const killSession = useCallback((sessionId: string | undefined): void => {
|
||||
if (!sessionId) return
|
||||
@@ -198,8 +343,13 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
deadSessionsRef.current.add(sessionId)
|
||||
}, [])
|
||||
|
||||
const createSession = useCallback(async (): Promise<string> => {
|
||||
const result = await window.api.createPty()
|
||||
const createSession = useCallback(async (cwd?: string): Promise<string> => {
|
||||
// `cwd` is the memory hook: a restored pane and a freshly opened one both
|
||||
// start where the user left off. The main process validates the path and
|
||||
// answers with the directory it actually used, which seeds the cwd store so
|
||||
// an immediate snapshot save already reflects it.
|
||||
const result = await window.api.createPty(cwd ? { cwd } : {})
|
||||
setSessionCwd(result.id, result.cwd)
|
||||
return result.id
|
||||
}, [])
|
||||
|
||||
@@ -243,11 +393,41 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
if (mode === 'terminal') terminalApiRef.current = api
|
||||
else sshApiRef.current = api
|
||||
|
||||
// One pty/ssh session can back several panels — an SSH split mirrors a
|
||||
// single session on purpose. So closing a panel must not kill a session
|
||||
// another panel is still showing; count the panels per session (seeding
|
||||
// from the panels already present, e.g. a restored layout) and kill only
|
||||
// when the last one goes away.
|
||||
const useCount = new Map<string, number>()
|
||||
for (const existing of api.panels) {
|
||||
const sid = sessionIdOf(existing)
|
||||
if (sid) useCount.set(sid, (useCount.get(sid) ?? 0) + 1)
|
||||
}
|
||||
const countPanel = (panel: IDockviewPanel): void => {
|
||||
const sid = sessionIdOf(panel)
|
||||
if (sid) useCount.set(sid, (useCount.get(sid) ?? 0) + 1)
|
||||
}
|
||||
const releaseSession = (panel: IDockviewPanel): void => {
|
||||
const sid = sessionIdOf(panel)
|
||||
if (!sid) return
|
||||
const left = (useCount.get(sid) ?? 1) - 1
|
||||
if (left > 0) {
|
||||
useCount.set(sid, left)
|
||||
return
|
||||
}
|
||||
useCount.delete(sid)
|
||||
killSession(sid)
|
||||
}
|
||||
|
||||
const cleanups = [
|
||||
api.onDidRemovePanel((panel: IDockviewPanel) => {
|
||||
killSession(sessionIdOf(panel))
|
||||
// Closing a pane keeps its directory as the seed for the next one.
|
||||
const closing = getSessionCwd(sessionIdOf(panel))
|
||||
if (closing) lastLocalCwdRef.current = closing
|
||||
releaseSession(panel)
|
||||
recomputeLocalPanels()
|
||||
recountAll()
|
||||
scheduleSave()
|
||||
}),
|
||||
api.onDidActivePanelChange((event) => {
|
||||
if (mode === 'terminal') activeTerminalPanelRef.current = event.panel ?? undefined
|
||||
@@ -260,12 +440,17 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
api.onDidLayoutChange(() => {
|
||||
recomputeLocalPanels()
|
||||
recountAll()
|
||||
scheduleSave()
|
||||
}),
|
||||
api.onDidAddPanel(() => recountAll())
|
||||
api.onDidAddPanel((panel: IDockviewPanel) => {
|
||||
countPanel(panel)
|
||||
recountAll()
|
||||
scheduleSave()
|
||||
})
|
||||
]
|
||||
disposablesRef.current.push(...cleanups)
|
||||
},
|
||||
[killSession, recomputeLocalPanels, recountAll]
|
||||
[killSession, recomputeLocalPanels, recountAll, scheduleSave]
|
||||
)
|
||||
|
||||
const handleTerminalReady = useCallback(
|
||||
@@ -309,14 +494,46 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
return useWorkspaceModeStore.subscribe(sync)
|
||||
}, [])
|
||||
|
||||
/** First run: seed one local terminal so the terminal workspace is never
|
||||
* empty on launch. Runs once, after the terminal dockview is ready. */
|
||||
/** Session memory follows the directories the panes report and the workspace
|
||||
* the user is in — both feed the debounced snapshot write. */
|
||||
useEffect(() => {
|
||||
const offCwd = onSessionCwdChange(() => {
|
||||
const active = getSessionCwd(sessionIdOf(activeTerminalPanelRef.current))
|
||||
if (active) lastLocalCwdRef.current = active
|
||||
scheduleSave()
|
||||
})
|
||||
const offMode = useWorkspaceModeStore.subscribe(() => scheduleSave())
|
||||
return () => {
|
||||
offCwd()
|
||||
offMode()
|
||||
}
|
||||
}, [scheduleSave])
|
||||
|
||||
/**
|
||||
* Boot pass: pick up where the user left off — layout plus each pane's
|
||||
* directory — when 设置 → 系统 → 启动时恢复上次会话 is on. Otherwise (or when no
|
||||
* snapshot exists) seed one terminal. Either way the workspace is never empty.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!terminalReady) return
|
||||
const current = terminalApiRef.current
|
||||
if (current && !current.panels.some((p) => sessionIdOf(p))) void addTerminal()
|
||||
// seed the terminal workspace exactly once, on first ready
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
let cancelled = false
|
||||
void (async (): Promise<void> => {
|
||||
const current = terminalApiRef.current
|
||||
if (!current) return
|
||||
const restoreOn = useSettingsStore.getState().settings.system.restoreSession !== false
|
||||
const snapshot = restoreOn ? await window.api.getSessionState().catch(() => null) : null
|
||||
if (cancelled) return
|
||||
const restorable = Boolean(snapshot && (snapshot.layouts.terminal || snapshot.panels.length > 0))
|
||||
const restored = snapshot && restorable ? await restoreFromSnapshot(snapshot) : false
|
||||
if (cancelled) return
|
||||
if (!restored && !current.panels.some((p) => sessionIdOf(p))) void addTerminal()
|
||||
bootDoneRef.current = true
|
||||
scheduleSave()
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- boot exactly once, on first ready
|
||||
}, [terminalReady])
|
||||
|
||||
/** Kill remaining sessions on window close. */
|
||||
@@ -396,14 +613,18 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
const current = terminalApiRef.current
|
||||
if (!current) return
|
||||
useWorkspaceModeStore.getState().setMode('terminal')
|
||||
const sessionId = await createSession()
|
||||
// Start where the user already is: the active pane's directory, else the
|
||||
// most recent one we saw (e.g. the pane they just closed).
|
||||
const inherit =
|
||||
getSessionCwd(sessionIdOf(activeTerminalPanelRef.current)) ?? lastLocalCwdRef.current ?? undefined
|
||||
const sessionId = await createSession(inherit)
|
||||
const reference = activeTerminalPanelRef.current
|
||||
|
||||
current.addPanel<TerminalParams>({
|
||||
id: sessionId,
|
||||
id: panelId(),
|
||||
component: TERMINAL_COMPONENT,
|
||||
tabComponent: TERMINAL_TAB_COMPONENT,
|
||||
title: nextTerminalTitle(),
|
||||
title: nextTerminalTitle(current.panels.map((p) => p.title)),
|
||||
params: { sessionId, sessionKind: 'local' },
|
||||
position:
|
||||
reference && direction
|
||||
@@ -435,7 +656,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
// is visible once added.
|
||||
useWorkspaceModeStore.getState().setMode('ssh')
|
||||
current.addPanel<TerminalParams>({
|
||||
id: result.id,
|
||||
id: panelId(),
|
||||
component: TERMINAL_COMPONENT,
|
||||
tabComponent: TERMINAL_TAB_COMPONENT,
|
||||
title: conn.name,
|
||||
@@ -520,7 +741,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
if (!sessionId) return
|
||||
const refParams = reference.params as TerminalParams | undefined
|
||||
current.addPanel<TerminalParams>({
|
||||
id: `ssh-${sessionId}-${window.crypto.randomUUID().slice(0, 8)}`,
|
||||
id: panelId(),
|
||||
component: TERMINAL_COMPONENT,
|
||||
tabComponent: TERMINAL_TAB_COMPONENT,
|
||||
title: reference.title,
|
||||
@@ -546,23 +767,126 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Rebind every restored session panel to a fresh local pty. Templates cannot
|
||||
* carry live session state (SSH needs credentials not stored in the layout),
|
||||
* so sessions are recreated as local terminals in whichever workspace the
|
||||
* layout put them.
|
||||
* Replace the pty behind an existing panel, in place.
|
||||
*
|
||||
* Panel identity and session identity are deliberately separate: a panel keeps
|
||||
* its parameters object across `updateParameters`, so React re-renders the
|
||||
* panel's `<TerminalView>` with the new `sessionId` prop exactly the way it
|
||||
* does when the user switches terminals — its bind effect tears the old xterm
|
||||
* down and replays the new session's output.
|
||||
*/
|
||||
const rebindRestoredPanels = useCallback(
|
||||
async (api: DockviewApi): Promise<void> => {
|
||||
const bindPanelSession = useCallback(
|
||||
async (panel: DockviewPanelApi | undefined, currentSessionId: string, cwd?: string): Promise<void> => {
|
||||
if (!panel) return
|
||||
const nextSessionId = await createSession(cwd)
|
||||
panel.updateParameters({ sessionId: nextSessionId, sessionKind: 'local' })
|
||||
deadSessionsRef.current.delete(currentSessionId)
|
||||
forgetSessionCwd(currentSessionId)
|
||||
},
|
||||
[createSession]
|
||||
)
|
||||
|
||||
/**
|
||||
* Rebind restored panels to fresh ptys.
|
||||
*
|
||||
* Templates cannot carry live session state (SSH needs credentials that are
|
||||
* never stored in a layout), so their panels are recreated as local terminals
|
||||
* in whichever workspace the layout put them.
|
||||
*
|
||||
* The automatic snapshot additionally carries each pane's last working
|
||||
* directory, and `dropSsh` closes ssh panes rather than faking them as local
|
||||
* terminals — a restored ssh pane has no session to show yet (the reconnect
|
||||
* placeholder is a follow-up).
|
||||
*
|
||||
* `renames` bridges the id rewrite the caller applied to the layout: the
|
||||
* snapshot is keyed by the panel ids *as they were written*, but the panels
|
||||
* now carry fresh ids, so a lookup by the new id would always miss and the
|
||||
* pane would silently lose its remembered directory.
|
||||
*/
|
||||
const rebindSessionPanels = useCallback(
|
||||
async (
|
||||
api: DockviewApi | undefined,
|
||||
snapshot?: SessionSnapshot,
|
||||
dropSsh = false,
|
||||
renames?: Map<string, string>
|
||||
): Promise<void> => {
|
||||
if (!api) return
|
||||
const recorded = new Map((snapshot?.panels ?? []).map((p) => [p.panelId, p]))
|
||||
const lookup = (panelId: string): SessionPanelState | undefined => {
|
||||
const direct = recorded.get(panelId)
|
||||
if (direct) return direct
|
||||
for (const [oldId, newId] of renames ?? []) {
|
||||
if (newId === panelId) return recorded.get(oldId)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
for (const panel of api.panels) {
|
||||
const id = sessionIdOf(panel)
|
||||
if (id && panel.view.contentComponent === TERMINAL_COMPONENT) {
|
||||
const newSessionId = (await window.api.createPty()).id
|
||||
panel.api.updateParameters({ sessionId: newSessionId, sessionKind: 'local' })
|
||||
deadSessionsRef.current.delete(id)
|
||||
if (!id || panel.view.contentComponent !== TERMINAL_COMPONENT) continue
|
||||
if (dropSsh && (panel.params as TerminalParams | undefined)?.sessionKind === 'ssh') {
|
||||
panel.api.close()
|
||||
continue
|
||||
}
|
||||
await bindPanelSession(panel.api, id, lookup(panel.id)?.cwd)
|
||||
}
|
||||
},
|
||||
[]
|
||||
[bindPanelSession]
|
||||
)
|
||||
|
||||
const rebindRestoredPanels = useCallback(
|
||||
async (api: DockviewApi): Promise<void> => {
|
||||
await rebindSessionPanels(api)
|
||||
},
|
||||
[rebindSessionPanels]
|
||||
)
|
||||
|
||||
/**
|
||||
* Restore the previous layout and drop every pane back into the directory it
|
||||
* was left in. Returns false when the snapshot cannot be applied (an
|
||||
* incompatible dockview layout), so the caller falls back to a fresh terminal.
|
||||
*
|
||||
* The layout is rewritten before `fromJSON` sees it: a snapshot written by an
|
||||
* older build has `panelId === sessionId` for every pane (panel ids used to be
|
||||
* session ids), which is a state the restore can no longer represent — it
|
||||
* would reinstate a panel whose id names a session that does not exist, and
|
||||
* the pane stays dead. `breakIdCoincidence` gives those panels fresh ids up
|
||||
* front, so the reapplied layout always satisfies panel id ≠ session id.
|
||||
*/
|
||||
const restoreFromSnapshot = useCallback(
|
||||
async (snapshot: SessionSnapshot): Promise<boolean> => {
|
||||
const terminal = terminalApiRef.current
|
||||
const ssh = sshApiRef.current
|
||||
const terminalLayout = breakIdCoincidence(snapshot.layouts.terminal)
|
||||
const sshLayout = breakIdCoincidence(snapshot.layouts.ssh)
|
||||
try {
|
||||
if (terminalLayout.layout) terminal?.fromJSON(terminalLayout.layout as never)
|
||||
if (sshLayout.layout) ssh?.fromJSON(sshLayout.layout as never)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
const renames = new Map([...terminalLayout.renames, ...sshLayout.renames])
|
||||
await rebindSessionPanels(terminal, snapshot, true, renames)
|
||||
await rebindSessionPanels(ssh, snapshot, true, renames)
|
||||
// Retitle duplicates left over from the old counter bug (the snapshot it
|
||||
// wrote keeps colliding titles forever otherwise): the first panel keeps
|
||||
// the title, later ones get the lowest free number.
|
||||
const seenTitles = new Set<string>()
|
||||
for (const p of [...(terminal?.panels ?? []), ...(ssh?.panels ?? [])]) {
|
||||
const t = p.title ?? ''
|
||||
if (!t) continue
|
||||
if (seenTitles.has(t)) {
|
||||
const fresh = nextTerminalTitle(seenTitles)
|
||||
p.setTitle(fresh)
|
||||
seenTitles.add(fresh)
|
||||
} else seenTitles.add(t)
|
||||
}
|
||||
lastLocalCwdRef.current = snapshot.lastLocalCwd ?? lastLocalCwdRef.current
|
||||
useWorkspaceModeStore.getState().setMode(snapshot.mode)
|
||||
recountAll()
|
||||
recomputeLocalPanels()
|
||||
return true
|
||||
},
|
||||
[rebindSessionPanels, recountAll, recomputeLocalPanels]
|
||||
)
|
||||
|
||||
const handleApplyTemplate = useCallback(
|
||||
@@ -653,13 +977,6 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
}
|
||||
}
|
||||
|
||||
/** Session id of the currently active, visible terminal panel ('' when none). */
|
||||
const resolveActiveSessionId = useCallback((): string => {
|
||||
const panel = activePanelRef.current
|
||||
if (!panel) return ''
|
||||
return sessionIdOf(panel) ?? ''
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="workspace-root">
|
||||
<IconRail
|
||||
@@ -748,11 +1065,6 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* M6: floating quick-input panel (bottom-right). Sends to the active
|
||||
session; broadcast target → fan-out. Sits above the sftp transfer
|
||||
overlay's 14px bottom offset so the two never overlap. */}
|
||||
<QuickInputPanel onResolveActiveSession={resolveActiveSessionId} />
|
||||
|
||||
{/* SSH connect flow: secret prompt (cancellable) → connecting modal (not). */}
|
||||
{requestedConn != null && (
|
||||
<ConnectFlow
|
||||
@@ -898,25 +1210,6 @@ function WorkspaceBroadcastPopover({
|
||||
)
|
||||
}
|
||||
|
||||
/** 快捷输入 panel toggle (ThunderboltOutlined) — state lives in the shared store. */
|
||||
function QuickInputToggleButton(): React.JSX.Element {
|
||||
const toggle = useQuickInputStore((s) => s.toggle)
|
||||
const open = useQuickInputStore((s) => s.open)
|
||||
return (
|
||||
<Tooltip title={open ? '收起快捷输入面板' : '打开快捷输入面板'}>
|
||||
<button
|
||||
type="button"
|
||||
className={['workspace-tabbar-btn2', open ? ' is-active' : ''].join('')}
|
||||
aria-label="快捷输入面板"
|
||||
aria-pressed={open}
|
||||
onClick={toggle}
|
||||
>
|
||||
<ThunderboltOutlined />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* XTerminal-style slim icon rail on the left edge — replaces the top toolbar.
|
||||
* Top: workspace actions; bottom: settings.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Last known working directory per pty session.
|
||||
*
|
||||
* Filled by <TerminalView> (typed `cd` commands and shell OSC reports) and read
|
||||
* by the workspace when it builds a session snapshot, so a restored pane opens
|
||||
* in the directory it was left in. Keyed by sessionId, which is regenerated on
|
||||
* every launch — the snapshot maps panelId → cwd, and this map is the live
|
||||
* lookup behind it.
|
||||
*/
|
||||
|
||||
const cwds = new Map<string, string>()
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
export function setSessionCwd(sessionId: string, cwd: string): void {
|
||||
if (!sessionId || !cwd) return
|
||||
if (cwds.get(sessionId) === cwd) return
|
||||
cwds.set(sessionId, cwd)
|
||||
for (const listener of listeners) listener()
|
||||
}
|
||||
|
||||
export function getSessionCwd(sessionId: string | undefined): string | undefined {
|
||||
return sessionId ? cwds.get(sessionId) : undefined
|
||||
}
|
||||
|
||||
export function forgetSessionCwd(sessionId: string | undefined): void {
|
||||
if (!sessionId) return
|
||||
if (cwds.delete(sessionId)) {
|
||||
for (const listener of listeners) listener()
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscribe to any change; returns an unsubscribe function. */
|
||||
export function onSessionCwdChange(listener: () => void): () => void {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
@@ -35,14 +35,14 @@
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: #9a9a9a;
|
||||
color: var(--chrome-tab-fg, #9a9a9a);
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.workspace-rail-btn:hover {
|
||||
background: var(--chrome-hover);
|
||||
color: #ffffff;
|
||||
color: var(--chrome-fg, #ffffff);
|
||||
}
|
||||
|
||||
.workspace-rail-divider {
|
||||
@@ -54,7 +54,7 @@
|
||||
|
||||
/* ---- M6.1 rail active state (mode entries) ---- */
|
||||
.workspace-rail-btn.is-active {
|
||||
color: var(--dv-active-tab-foreground-color, #e8e8e8);
|
||||
color: var(--tab-accent-fg, #e8e8e8);
|
||||
box-shadow: inset 2px 0 0 var(--tab-accent, #3fb950);
|
||||
background: color-mix(in srgb, var(--tab-accent, #3fb950) 10%, transparent);
|
||||
}
|
||||
@@ -179,6 +179,18 @@
|
||||
/* Pane split dividers: soft slate band at rest, accent blue on hover/drag. */
|
||||
--dv-sash-color: rgba(148, 163, 184, 0.28);
|
||||
--dv-active-sash-color: rgba(59, 142, 234, 0.75);
|
||||
/* Tab surfaces. Dockview paints tabs through group-scoped rules that
|
||||
outspecify our .dv-tab rules, so theme the tabs via the vars those rules
|
||||
consume — otherwise the abyss defaults (dark navy) leak onto light themes. */
|
||||
--dv-activegroup-visiblepanel-tab-background-color: color-mix(
|
||||
in srgb, var(--tab-accent, #3fb950) 10%, transparent);
|
||||
--dv-activegroup-visiblepanel-tab-color: var(--tab-accent-fg, #3fd06a);
|
||||
--dv-activegroup-hiddenpanel-tab-background-color: var(--chrome-tab-bg, rgba(255, 255, 255, 0.04));
|
||||
--dv-activegroup-hiddenpanel-tab-color: var(--chrome-tab-fg, #98a2ab);
|
||||
--dv-inactivegroup-visiblepanel-tab-background-color: var(--chrome-tab-bg, rgba(255, 255, 255, 0.04));
|
||||
--dv-inactivegroup-visiblepanel-tab-color: var(--chrome-tab-fg, #98a2ab);
|
||||
--dv-inactivegroup-hiddenpanel-tab-background-color: var(--chrome-tab-bg, rgba(255, 255, 255, 0.04));
|
||||
--dv-inactivegroup-hiddenpanel-tab-color: var(--chrome-tab-fg, #98a2ab);
|
||||
}
|
||||
|
||||
/* ---- tab polish: low rectangular tabs, quiet inactive, green active ---- */
|
||||
@@ -191,16 +203,18 @@
|
||||
border-radius: 3px;
|
||||
padding: 0 8px;
|
||||
margin-right: 4px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.10);
|
||||
color: #98a2ab;
|
||||
background: var(--chrome-tab-bg, rgba(255, 255, 255, 0.04));
|
||||
box-shadow: inset 0 0 0 1px var(--chrome-tab-ring, rgba(255, 255, 255, 0.1));
|
||||
color: var(--chrome-tab-fg, #98a2ab);
|
||||
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
|
||||
.workspace-dockview .dv-tab.dv-inactive-tab:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.20);
|
||||
color: #d0d0d0;
|
||||
/* Group-scoped chain matches dockview's own specificity so the hover tint
|
||||
wins over its group-level background rules. */
|
||||
.workspace-dockview .dv-groupview .dv-tabs-container .dv-tab.dv-inactive-tab:hover {
|
||||
background: var(--chrome-tab-bg-hover, rgba(255, 255, 255, 0.07));
|
||||
box-shadow: inset 0 0 0 1px var(--chrome-tab-ring-hover, rgba(255, 255, 255, 0.2));
|
||||
color: var(--chrome-tab-fg-hover, #d0d0d0);
|
||||
}
|
||||
|
||||
/* XTerminal-style active tab: green outline + tinted bg + green title. */
|
||||
@@ -229,7 +243,7 @@
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 2px;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
|
||||
box-shadow: inset 0 0 0 1px var(--chrome-tab-ring-hover, rgba(255, 255, 255, 0.16));
|
||||
opacity: 0.8;
|
||||
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
@@ -432,9 +446,9 @@ body.ssh-resizing .ssh-bottom-divider {
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
|
||||
box-shadow: inset 0 0 0 1px var(--chrome-tab-ring, rgba(255, 255, 255, 0.16));
|
||||
background: transparent;
|
||||
color: #98a2ab;
|
||||
color: var(--chrome-tab-fg, #98a2ab);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
|
||||
@@ -446,7 +460,7 @@ body.ssh-resizing .ssh-bottom-divider {
|
||||
color: var(--tab-accent-fg, #3fd06a);
|
||||
}
|
||||
|
||||
/* ---- M6 tab-bar workspace actions (broadcast + quick-input) ---- */
|
||||
/* ---- M6 tab-bar workspace actions (broadcast) ---- */
|
||||
.workspace-tabbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -464,18 +478,18 @@ body.ssh-resizing .ssh-bottom-divider {
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
|
||||
box-shadow: inset 0 0 0 1px var(--chrome-tab-ring, rgba(255, 255, 255, 0.16));
|
||||
background: transparent;
|
||||
color: #98a2ab;
|
||||
color: var(--chrome-tab-fg, #98a2ab);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
|
||||
.workspace-tabbar-btn2:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.24);
|
||||
color: #ffffff;
|
||||
background: var(--chrome-tab-bg-hover, rgba(255, 255, 255, 0.07));
|
||||
box-shadow: inset 0 0 0 1px var(--chrome-tab-ring-hover, rgba(255, 255, 255, 0.24));
|
||||
color: var(--chrome-tab-fg-hover, #ffffff);
|
||||
}
|
||||
|
||||
.workspace-tabbar-btn2.is-active {
|
||||
@@ -574,147 +588,6 @@ body.ssh-resizing .ssh-bottom-divider {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ---- M6 quick-input floating panel (bottom-right) ----
|
||||
bottom: 44px so it clears the sftp transfer overlay (right/bottom 14px). */
|
||||
.quick-input-panel {
|
||||
position: fixed;
|
||||
right: 14px;
|
||||
bottom: 44px;
|
||||
z-index: 950;
|
||||
width: 320px;
|
||||
max-height: 60vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: #1f1f1f;
|
||||
border: 1px solid var(--chrome-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
|
||||
.quick-input-headbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.quick-input-headbar-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
|
||||
.quick-input-close {
|
||||
border: none;
|
||||
background: none;
|
||||
color: #8a8a8a;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.quick-input-close:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.quick-input-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.quick-input-field .ant-input {
|
||||
background: var(--chrome-bg);
|
||||
border-color: var(--chrome-border);
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
|
||||
.quick-input-autoenter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
color: #9a9a9a;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.quick-input-autoenter input {
|
||||
accent-color: var(--chrome-accent);
|
||||
}
|
||||
|
||||
.quick-input-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.quick-input-head {
|
||||
font-size: 11px;
|
||||
color: #8a8a8a;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.quick-input-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.quick-input-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--chrome-fg);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
padding: 3px 6px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quick-input-item:hover {
|
||||
background: rgba(59, 142, 234, 0.18);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.quick-input-item-cmd {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: 'Cascadia Mono', 'Consolas', 'SFMono-Regular', monospace;
|
||||
}
|
||||
|
||||
.quick-input-item-tag {
|
||||
flex: 0 0 auto;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
color: #9a9a9a;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.quick-input-empty {
|
||||
padding: 8px 4px;
|
||||
color: #8a8a8a;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ---- custom tab (title + close + context menu) ---- */
|
||||
.workspace-terminal-tab {
|
||||
position: relative;
|
||||
|
||||
+11
-2
@@ -5,7 +5,8 @@ import type {
|
||||
PtyCreateResult,
|
||||
PtyDataEvent,
|
||||
PtyExitEvent,
|
||||
SessionOpenResult
|
||||
SessionOpenResult,
|
||||
SessionSnapshot
|
||||
} from './ipc'
|
||||
import type { AppSettings } from './settings'
|
||||
import type { ReleaseNote, UpdateState } from './ipc'
|
||||
@@ -110,7 +111,15 @@ export interface AppApi {
|
||||
// ---- updater (domestic feed first, GitHub fallback) ----
|
||||
updateCheck(): Promise<UpdateState>
|
||||
updateDownload(): Promise<void>
|
||||
updateInstall(): void
|
||||
updateInstall(): Promise<void>
|
||||
updateChangelog(): Promise<ReleaseNote[]>
|
||||
onUpdateState(cb: (s: UpdateState) => void): () => void
|
||||
|
||||
// ---- session snapshot (restore last layout + per-pane cwd) ----
|
||||
getSessionState(): Promise<SessionSnapshot | null>
|
||||
saveSessionState(snapshot: SessionSnapshot): Promise<SessionSnapshot | null>
|
||||
/** resolve a cd-style argument against the current cwd (platform-aware) */
|
||||
resolveCwd(current: string | undefined, arg: string): Promise<string | null>
|
||||
/** normalize a shell-reported cwd payload (OSC 7 / OSC 9;9) */
|
||||
reportCwd(payload: string): Promise<string | null>
|
||||
}
|
||||
+38
-1
@@ -98,13 +98,23 @@ export const Ipc = {
|
||||
UPDATE_INSTALL: 'update:install',
|
||||
UPDATE_CHANGELOG: 'update:changelog',
|
||||
/** main -> renderer broadcast: UpdateState */
|
||||
UPDATE_STATE: 'update:state'
|
||||
UPDATE_STATE: 'update:state',
|
||||
|
||||
// ---- session snapshot (last layout + per-pane cwd) ----
|
||||
SESSION_STATE_GET: 'session:stateGet',
|
||||
SESSION_STATE_SET: 'session:stateSet',
|
||||
/** 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'
|
||||
} as const
|
||||
|
||||
export interface PtyCreateOptions {
|
||||
cwd?: string
|
||||
/** executable; omit for platform default shell */
|
||||
shell?: string
|
||||
/** extra argv for the shell (shell-integration adapters use this) */
|
||||
shellArgs?: string[]
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
@@ -114,6 +124,33 @@ export interface PtyCreateResult {
|
||||
cwd: string
|
||||
}
|
||||
|
||||
/** One dockview panel as remembered between runs. */
|
||||
export interface SessionPanelState {
|
||||
panelId: string
|
||||
title: string
|
||||
kind: 'local' | 'ssh'
|
||||
/** last known working directory (local panels; also recorded for ssh panes
|
||||
* so a reconnected pane starts somewhere sensible) */
|
||||
cwd?: string
|
||||
/** ssh only: bookmark to offer a reconnect for */
|
||||
connectionId?: string
|
||||
hostLabel?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The automatic "pick up where you left off" snapshot: the dockview layouts
|
||||
* plus a per-panel record. Distinct from the user-managed layout templates.
|
||||
*/
|
||||
export interface SessionSnapshot {
|
||||
version: 1
|
||||
savedAt: number
|
||||
mode: 'terminal' | 'ssh'
|
||||
layouts: { terminal?: unknown; ssh?: unknown }
|
||||
panels: SessionPanelState[]
|
||||
/** newest cwd among local panes — seeds brand-new terminals */
|
||||
lastLocalCwd?: string
|
||||
}
|
||||
|
||||
export interface PtyDataEvent {
|
||||
id: string
|
||||
data: string
|
||||
|
||||
+19
-2
@@ -36,6 +36,10 @@ export interface TerminalSettings {
|
||||
pasteRiskConfirm: boolean
|
||||
/** inline command suggestion popup while typing (history + library) */
|
||||
suggestEnabled: boolean
|
||||
/** record executed commands into the history panel */
|
||||
historyEnabled: boolean
|
||||
/** max entries kept in command history */
|
||||
historyLimit: number
|
||||
/** accent color for the active tab outline, rail indicator and SSH badges */
|
||||
tabAccentColor: string
|
||||
}
|
||||
@@ -51,6 +55,17 @@ export interface SystemSettings {
|
||||
closeAction?: 'ask' | 'tray' | 'exit'
|
||||
/** check for updates shortly after startup (manual check always available) */
|
||||
autoCheckUpdate?: boolean
|
||||
/**
|
||||
* Restore the previous layout and each pane's working directory on launch
|
||||
* (see the session snapshot). On by default — that is the point of it.
|
||||
*/
|
||||
restoreSession?: boolean
|
||||
/**
|
||||
* Let the shell announce its cwd over OSC 7 (shell integration). Exact, but
|
||||
* it wraps the user's prompt, so it is opt-in; without it the app falls back
|
||||
* to parsing typed `cd` commands.
|
||||
*/
|
||||
shellIntegration?: boolean
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
@@ -82,7 +97,7 @@ export const DEFAULT_HIGHLIGHT_RULES: HighlightRule[] = [
|
||||
RULE('ipv4', '\\b(?:\\d{1,3}\\.){3}\\d{1,3}(?::\\d{1,5})?\\b', 15, '#58a6ff', '网络与 IP 地址'),
|
||||
RULE('datetime', '\\b\\d{4}[-/]\\d{1,2}[-/]\\d{1,2}(?:[ T]\\d{1,2}:\\d{2}(?::\\d{2})?)?\\b|\\b\\d{1,2}:\\d{2}(?::\\d{2})?\\b', 20, '#bc8cff', '时间与日期'),
|
||||
RULE('numbers', '\\b\\d+(?:\\.\\d+)?(?:%)?\\b', 25, '#f2cc60', '数字与计数'),
|
||||
RULE('url', 'https?://[^\\s]+', 30, '#58a6ff', '网址链接')
|
||||
RULE('url', 'https?://[^\\s]+|ftp://[^\\s]+|www\\.[A-Za-z0-9-]+\\.[A-Za-z]{2,}(?::\\d+)?(?:/[^\\s]*)?', 30, '#58a6ff', '网址链接')
|
||||
]
|
||||
|
||||
export const DEFAULT_SETTINGS: AppSettings = {
|
||||
@@ -103,9 +118,11 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
||||
copyOnSelect: false,
|
||||
pasteRiskConfirm: true,
|
||||
suggestEnabled: true,
|
||||
historyEnabled: true,
|
||||
historyLimit: 100,
|
||||
tabAccentColor: '#3fb950'
|
||||
},
|
||||
customThemes: [],
|
||||
highlightRules: DEFAULT_HIGHLIGHT_RULES,
|
||||
system: { launchAtLogin: false, preventSleep: false, globalShowHide: '', closeAction: 'tray', autoCheckUpdate: true }
|
||||
system: { launchAtLogin: false, preventSleep: false, globalShowHide: '', closeAction: 'tray', autoCheckUpdate: true, restoreSession: true, shellIntegration: false }
|
||||
}
|
||||
+96
-15
@@ -6,10 +6,11 @@
|
||||
* session log write/read/index behavior against a temp userData dir.
|
||||
*
|
||||
* Build: npx esbuild src/main/commands.ts --bundle --platform=node --format=cjs \
|
||||
* --outfile=tests/.commands-store.cjs --alias:electron=./tests/electron-stub.cjs
|
||||
* --outfile=tests/.commands-store.cjs --alias:electron=./tests/electron-stub.cjs \
|
||||
* --alias:@shared=./src/shared
|
||||
* Run: node tests/commands-store.mjs (must exit 0)
|
||||
*/
|
||||
import { mkdtempSync, readFileSync } from 'fs'
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { createRequire } from 'module'
|
||||
@@ -25,16 +26,23 @@ const ok = (cond, msg) => {
|
||||
}
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
// ---- 1. Bundle the store once (assumes tests/.commands-store.cjs exists) -------
|
||||
// ---- 1. Temp userData must exist before the bundle loads ------------------------
|
||||
// The settings store resolves its own path via app.getPath('userData'). The stub
|
||||
// is bundled *into* the module under test, so requiring it here gives a different
|
||||
// instance — `__setUserData` would not reach the bundle. The env var is read when
|
||||
// the stub loads, so it has to be set before the bundle is required below.
|
||||
const userData = mkdtempSync(join(tmpdir(), 'm5-cmd-'))
|
||||
process.env.OT_STUB_USERDATA = userData
|
||||
|
||||
// ---- 1b. Bundle the store once (assumes tests/.commands-store.cjs exists) ------
|
||||
let commandsMod
|
||||
try {
|
||||
commandsMod = require_('./.commands-store.cjs')
|
||||
} catch {
|
||||
fail('bundle not found — run: npx esbuild src/main/commands.ts --bundle --platform=node --format=cjs --outfile=tests/.commands-store.cjs --alias:electron=./tests/electron-stub.cjs')
|
||||
fail('bundle not found — run: npx esbuild src/main/commands.ts --bundle --platform=node --format=cjs --outfile=tests/.commands-store.cjs --alias:electron=./tests/electron-stub.cjs --alias:@shared=./src/shared')
|
||||
}
|
||||
|
||||
// ---- 2. Temp userData; capture openDir target ----------------------------------
|
||||
const userData = mkdtempSync(join(tmpdir(), 'm5-cmd-'))
|
||||
// ---- 2. Store over the temp userData; capture openDir target -------------------
|
||||
let openedPath = null
|
||||
const store = new commandsMod.CommandsStore(userData, async (p) => {
|
||||
openedPath = p
|
||||
@@ -53,23 +61,78 @@ store.recordCommand('ps aux')
|
||||
hist = store.listHistory()
|
||||
ok(hist.length === 3, 're-run of newest command is deduped (no new entry)')
|
||||
|
||||
// Re-run an OLDER command -> it is NOT the newest, so it becomes a fresh front
|
||||
// entry (spec dedupes only against the newest entry) with blank-recency guard.
|
||||
// Re-run an OLDER command -> dedupe is over the whole history, so it moves the
|
||||
// existing entry to the front instead of adding a copy.
|
||||
store.recordCommand('echo hello')
|
||||
hist = store.listHistory()
|
||||
ok(hist.length === 4, 'older command re-run is a new front entry (dedupe is newest-only)')
|
||||
ok(hist[0].command === 'echo hello', 'older re-run lands at front')
|
||||
ok(hist.length === 3, 'older command re-run does NOT add a copy (whole-history dedupe)')
|
||||
ok(hist[0].command === 'echo hello', 'older re-run is hoisted to the front')
|
||||
// The hoisted entry keeps its original createdAt (it is the same command, not a new one).
|
||||
ok(new Set(hist.map((h) => h.command)).size === 3, 'history holds 3 distinct commands')
|
||||
|
||||
// Cap: fill until well above 500 keeps only the newest 500.
|
||||
for (let i = 0; i < 520; i++) store.recordCommand(`cmd-${i}`)
|
||||
// A command used long ago stays single even after many others in between.
|
||||
for (let i = 0; i < 20; i++) store.recordCommand(`noise-${i}`)
|
||||
store.recordCommand('ls -la')
|
||||
hist = store.listHistory()
|
||||
ok(hist.length === 500, `history capped at 500 (got ${hist.length})`)
|
||||
ok(hist[0].command === 'cmd-519', 'newest filler at front')
|
||||
ok(hist.filter((h) => h.command === 'ls -la').length === 1, 'a command seen 20 entries ago is not duplicated')
|
||||
ok(hist[0].command === 'ls -la', 'and it is hoisted to the front')
|
||||
|
||||
// Clear.
|
||||
// Clear before the cap test so the assertions below are exact.
|
||||
store.clearHistory()
|
||||
ok(store.listHistory().length === 0, 'clearHistory empties history')
|
||||
|
||||
// ---- 3b. History limit + the recording switch -----------------------------------
|
||||
const writeSettings = (terminal) =>
|
||||
writeFileSync(join(userData, 'settings.json'), JSON.stringify({ terminal, system: {} }), 'utf8')
|
||||
const DEFAULT_LIMIT = 100
|
||||
|
||||
// The bundled default raises past the old hard cap of 500 only when configured;
|
||||
// out of the box the list is trimmed to the default limit.
|
||||
for (let i = 0; i < 150; i++) store.recordCommand(`filler-${i}`)
|
||||
hist = store.listHistory()
|
||||
ok(hist.length === DEFAULT_LIMIT, `history trimmed to the default limit of ${DEFAULT_LIMIT} (got ${hist.length})`)
|
||||
ok(hist[0].command === 'filler-149', 'newest filler at front')
|
||||
|
||||
// Lower the limit -> takes effect immediately, without needing a new command.
|
||||
writeSettings({ historyLimit: 20 })
|
||||
hist = store.listHistory()
|
||||
ok(hist.length === 20, `lowered limit applies on read (got ${hist.length})`)
|
||||
|
||||
// Raise it -> entries that survived the *disk* cap (the configured limit at write
|
||||
// time) become visible again, up to the new limit. Nothing was resurrected from
|
||||
// beyond the write-time cap; the write cap is the real bound on disk growth.
|
||||
writeSettings({ historyLimit: 50 })
|
||||
store.recordCommand('after-raise')
|
||||
hist = store.listHistory()
|
||||
ok(hist.length === 50, `raising the limit reveals still-stored entries (got ${hist.length})`)
|
||||
ok(hist[0].command === 'after-raise', 'new command recorded after raising the limit')
|
||||
|
||||
// Nonsense / out-of-range limits fall back to something sane instead of throwing.
|
||||
writeSettings({ historyLimit: 0 })
|
||||
ok(store.listHistory().length === 1, 'limit 0 clamps to 1 rather than emptying history')
|
||||
writeSettings({ historyLimit: 99999 })
|
||||
ok(store.listHistory().length === 50, 'absurd limit is clamped to the hard ceiling')
|
||||
|
||||
// The switch: recording stops, and existing history is preserved (not cleared).
|
||||
writeSettings({ historyLimit: 50, historyEnabled: false })
|
||||
const before = store.listHistory()
|
||||
store.recordCommand('must-not-be-recorded')
|
||||
store.recordCommand('must-not-be-recorded')
|
||||
hist = store.listHistory()
|
||||
ok(!hist.some((h) => h.command === 'must-not-be-recorded'), 'historyEnabled=false records nothing')
|
||||
ok(hist.length === before.length, 'turning the switch off keeps existing history intact')
|
||||
|
||||
// Turning it back on resumes recording.
|
||||
writeSettings({ historyLimit: 50, historyEnabled: true })
|
||||
store.recordCommand('recorded-again')
|
||||
ok(store.listHistory()[0].command === 'recorded-again', 'historyEnabled=true resumes recording')
|
||||
|
||||
// Default (keys absent from settings.json) records, matching a fresh install.
|
||||
writeSettings({})
|
||||
store.recordCommand('default-on')
|
||||
ok(store.listHistory()[0].command === 'default-on', 'absent switch defaults to recording')
|
||||
store.clearHistory()
|
||||
|
||||
// ---- 4. Library CRUD + ordering ------------------------------------------------
|
||||
const a = store.saveLibraryItem({ command: 'ps aux', name: 'procs', note: 'show processes' })
|
||||
await wait(2) // createdAt has ms resolution; avoid a timestamp tie breaking the order assertion
|
||||
@@ -135,5 +198,23 @@ ok(logs.find((x) => x.file === start.file).endedAt !== undefined, 'old log final
|
||||
store.openLogsDir()
|
||||
ok(openedPath === join(userData, 'logs'), 'openLogsDir resolves to the logs dir')
|
||||
|
||||
// ---- 6. Burst ordering + stop tail (per-file buffer + single drain) -------------
|
||||
const sid2 = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
const startB = store.logStart(sid2)
|
||||
// Burst 1: 300 writes in one tick must coalesce into ordered appends.
|
||||
for (let i = 0; i < 300; i++) store.logWrite(sid2, `a${String(i).padStart(3, '0')}\n`)
|
||||
await wait(10)
|
||||
// Burst 2 after a gap: may land while burst 1's drain is still in flight.
|
||||
for (let i = 0; i < 100; i++) store.logWrite(sid2, `b${String(i).padStart(3, '0')}\n`)
|
||||
// Trailing partial line must ride the same buffer via logStop.
|
||||
store.logWrite(sid2, 'partial-tail')
|
||||
store.logStop(sid2)
|
||||
await wait(200)
|
||||
const expected =
|
||||
Array.from({ length: 300 }, (_, i) => `a${String(i).padStart(3, '0')}\n`).join('') +
|
||||
Array.from({ length: 100 }, (_, i) => `b${String(i).padStart(3, '0')}\n`).join('') +
|
||||
'partial-tail'
|
||||
ok(readFileSync(startB.file, 'utf8') === expected, 'burst writes + stop tail land in order')
|
||||
|
||||
console.log('\n[commands] ALL CHECKS PASSED')
|
||||
process.exit(0)
|
||||
+57
-4
@@ -1,12 +1,65 @@
|
||||
// Minimal electron stub for bundling src/main/pty.ts under plain Node
|
||||
// (tests/ssh-session-e2e.mjs). Only what broadcast.ts touches.
|
||||
// Minimal electron stub for bundling src/main/*.ts under plain Node
|
||||
// (tests/ssh-session-e2e.mjs, tests/commands-store.mjs). Only what the bundled
|
||||
// modules touch.
|
||||
//
|
||||
// `app.getPath('userData')` is configurable via `__setUserData` so a test can
|
||||
// point the settings store at a temp dir and exercise settings-driven behavior.
|
||||
let userDataPath = process.env.OT_STUB_USERDATA || ''
|
||||
module.exports = {
|
||||
BrowserWindow: {
|
||||
getAllWindows: () => []
|
||||
},
|
||||
app: {},
|
||||
ipcMain: {},
|
||||
app: {
|
||||
getPath: (name) => {
|
||||
if (name === 'userData' && userDataPath) return userDataPath
|
||||
throw new Error(`electron stub: app.getPath(${name}) is not configured`)
|
||||
},
|
||||
setLoginItemSettings: () => {},
|
||||
isPackaged: false
|
||||
},
|
||||
ipcMain: {
|
||||
handle: () => {},
|
||||
on: () => {}
|
||||
},
|
||||
globalShortcut: {
|
||||
register: () => true,
|
||||
unregister: () => {},
|
||||
unregisterAll: () => {},
|
||||
isRegistered: () => false
|
||||
},
|
||||
webContents: {},
|
||||
powerSaveBlocker: {
|
||||
start: () => 1,
|
||||
stop: () => {},
|
||||
isStarted: () => false
|
||||
},
|
||||
shell: {
|
||||
openPath: async () => ''
|
||||
},
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => false
|
||||
},
|
||||
nativeTheme: {
|
||||
shouldUseDarkColors: true,
|
||||
on: () => {}
|
||||
},
|
||||
Menu: {
|
||||
buildFromTemplate: () => ({ popup: () => {} }),
|
||||
setApplicationMenu: () => {}
|
||||
},
|
||||
Tray: class {
|
||||
setToolTip() {}
|
||||
setContextMenu() {}
|
||||
on() {}
|
||||
destroy() {}
|
||||
static getBounds() {
|
||||
return { x: 0, y: 0, width: 0, height: 0 }
|
||||
}
|
||||
},
|
||||
nativeImage: {
|
||||
createFromPath: () => ({ isEmpty: () => true, resize: () => ({}) })
|
||||
},
|
||||
__setUserData: (p) => {
|
||||
userDataPath = p
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user