Compare commits

..
41 Commits
Author SHA1 Message Date
Bill 9e453e272c chore: bump version to 1.0.11 2026-09-15 15:08:13 +08:00
Bill 237265e6ba feat: clickable URLs, left-rail settings nav, proportional resizable dialog
URLs in terminal output (http/https/ftp with ports, www. hosts, bare
localhost:port dev-server forms) are detected via a link provider: hover
underlines and shows the pointer, Ctrl/Cmd+Click opens the system browser.
Wrapped URLs spanning buffer rows resolve as one link. The 网址链接 highlight
preset grows to cover ftp:// and www. forms.

The settings dialog moves to a left navigation rail with the content column
scrolling on its own, sizes itself at ~70% of the main window and follows
main-window resizes in real time (until the user drags the corner grip),
stays centred while resizing, and keeps a stable height.
2026-09-15 15:07:47 +08:00
Bill 3b2fe06f71 docs: publish domestically only
From the 2026-09-15 decision, releases go to the Gitea release + the
domestic update channel with --skip-github; the GitHub release assets are no
longer synced per version (the code/tag mirror stays).
2026-09-15 13:37:29 +08:00
Bill 97fc7171cc chore: bump version to 1.0.10 2026-09-15 13:17:28 +08:00
Bill d3d029fe4f fix: wire plain Ctrl/Cmd+V to paste and split the check by line count
Plain Ctrl+V was never the terminal's: it went to the pty as a literal ^V
(0x16) — the command history even recorded 'cd \u0016' — so nothing pasted
and the confirm dialog never saw it. It is now handled on the terminal host
in the capture phase, with preventDefault, feeding the same path as
Ctrl+Shift+V.

The paste check is now shape-based rather than length-based: two or more
lines confirm (a stray newline executes an unreviewed command), a single
line pastes straight through unless it is unusually long.
2026-09-15 11:45:17 +08:00
Bill 77fd241b43 chore(dev): give dev builds their own userData and single-instance lock
A dev instance shared the installed build's userData directory and lock, so
starting it demanded killing the real app and it wrote test settings and
session snapshots into the live profile. Dev now uses OpenTerminal-dev and
tags its window title '(dev)'; both instances run side by side.
2026-09-15 01:02:10 +08:00
Bill 4b336360f8 fix(release): scope the proxy to GitHub so the domestic channel stays direct
setGlobalDispatcher routed every request — including the Gitea release and
channel PUTs — through the local proxy, which reset mid-upload once and left
the channel half-written. The proxy agent is now passed explicitly on the
GitHub requests only.
2026-09-15 00:34:30 +08:00
Bill d7abbaad9c fix: paste through xterm so large multi-line blocks insert instead of executing
confirmPaste wrote the raw text to the pty, so a large paste bypassed
bracketed paste and PowerShell ran it line by line; it also skipped
onData, which is why history/cwd tracking needed a separate mirror. Now the
text goes through term.paste(): xterm adds the \x1b[200~ markers when the
shell enabled bracketed paste (one edit, no execution) and the normal
onData path restores tracking for free.

The risky-paste bar becomes a proper dialog (确认粘贴 / 本次会话不再提示 /
关闭后续粘贴检测), matching the behaviour users expect from other
terminals.
2026-09-15 00:27:19 +08:00
Bill cbf4c224fb fix: allocate terminal titles by lowest free number instead of a counter
Closing 终端 6/7 then opening a new one produced 终端 8 rather than
refilling the gap. nextTerminalTitle now scans live panel titles and takes
the smallest unused N, which also subsumes the restore-time counter
seeding (syncTitleSeq is gone) — duplicate retitling after a restore fills
the lowest free number too.
2026-09-14 22:24:36 +08:00
Bill 03aa79a691 chore: bump version to 1.0.8 2026-09-14 21:41:46 +08:00
Bill 458dd9c88b fix: write session logs as plain text instead of raw PTY soup
Recorded logs carried the raw stream: SGR colors, cursor moves,
synchronized-output markers, and every TUI redraw frame — unreadable in an
editor and far larger than the visible output (a short kimi session logged
21.5KB of which 4.4KB is text). A per-session sanitizer now strips ANSI
statefully across chunk boundaries, collapses carriage-return overwrites
(progress bars keep only their final text), and suppresses alternate-screen
frames with a marker line. Ink-style inline TUIs redraw in the normal
buffer and cannot be frame-collapsed without screen emulation; their
committed lines are preserved.
2026-09-14 21:17:45 +08:00
Bill 28d8af6982 fix: keep terminal titles unique across session restore
The 终端 N counter lives only in memory, so after a restore it restarted at
zero and the next new terminal duplicated a restored title. Broadcast keys
targets by session id so duplicate titles never broke the fan-out itself,
but the target list became indistinguishable. Seed the counter past the
restored maximum on session restore / template apply, and retitle
duplicates already baked into existing snapshots.
2026-09-14 20:56:02 +08:00
Bill f09bed06b5 remove: drop the quick-input panel
The toggle state was a global singleton while the bolt button renders in
every pane's tab bar, so all panes showed the same open/close state and
the single floating panel (window bottom-right, sends to the *active*
session) never corresponded to the pane whose button was clicked. A true
per-pane rework is a medium refactor for a feature that overlaps with the
inline completion and the sidebar commands panel — removing instead.
2026-09-14 20:47:21 +08:00
Bill fcb2781694 chore: bump version to 1.0.7 2026-09-14 20:12:04 +08:00
Bill 0b4a87f1e7 fix: feed pasted text into the line buffer so history and cwd memory see it
App-driven paste (context menu / Ctrl+Shift+V) writes straight to the pty,
bypassing xterm's onData, and xterm-native paste arrives wrapped in
bracketed-paste markers that the buffer guards dropped. Either way a pasted
'cd D:\path' was recorded as the bare 'cd' typed before it, so the pane's
directory memory silently stayed home. Track submitted lines from pasted
text and strip bracketed-paste markers in the line buffer.
2026-09-14 20:01:23 +08:00
Bill 01827bf70b fix: resolve drive-relative cd (cd d:) in cwd memory
path.isAbsolute('d:') is false on Windows, so a drive-relative cd resolved
against the current base, produced a nonexistent path, and the pane kept
its creation directory in the snapshot. Map a bare drive-letter argument
to the drive root (the fresh-shell answer; we cannot know the drive's
remembered directory).
2026-09-14 19:52:32 +08:00
Bill 9a330499de fix: track bare drive switches (d:) for cwd memory
PowerShell/cmd users hop drives with a bare 'd:' — no cd keyword — so the
typed-command tracker never saw it, the pane's cwd stayed at its creation
directory, and the session snapshot restored it to home. Treat a bare
drive-letter line as a cd to the drive root; the main-process existence
check drops it on platforms without drive letters.
2026-09-14 19:27:20 +08:00
Bill 3c193a48a7 chore: bump version to 1.0.6 2026-09-14 17:31:00 +08:00
Bill 9467b78560 chore: name installed NSIS shortcuts OTerminal 2026-09-14 17:22:15 +08:00
Bill be7332db17 perf: route PTY stream through a single shared IPC listener
Every pane registered its own global onPtyData/onPtyExit listener, so each
output chunk woke N listeners and N-1 discarded it after an id compare.
With many terminals under heavy output that fan-out is pure overhead.
Now one IPC listener dispatches through a Map keyed by sessionId: one
lookup per chunk, only the owning pane runs.
2026-09-14 16:59:57 +08:00
Bill bbee1d642d polish: about-page logo now uses the app icon instead of an OT text mark 2026-09-14 15:06:19 +08:00
Bill 3aa8b28f89 chore: bump version to 1.0.5 2026-09-14 14:00:55 +08:00
Bill 277d8bb117 feat(commands): whole-history dedupe + switchable, capped history
历史命令去重从「只跟最新一条比」改为「全历史去重」:重敲任意一条已有命令
时把原条目提到最前(刷新 lastUsedAt)而不是新插一条,历史里每个命令只出现一次。

新增两个设置(设置 → 终端):
- 记录命令历史:关闭后不再记录新命令;已有历史保留,不清空
- 历史条数上限:默认 100,可配 1..500;写入按上限截断,读取也按当前
  上限截断(调低立即生效)

顺修一个暴露的既有 bug:logWrite 用 async appendFile,两次快速追加会
乱序落盘(line two 先于 line one)。改为按文件串行化追加。

测试:tests/commands-store.mjs 全历史去重 / 提到最前 / 上限生效 /
开关关闭不记录 / 关闭保留已有 / 恢复记录 / 越界上限夹紧,全部通过。
2026-09-14 12:01:23 +08:00
Bill beeb32a76b fix(session): keep panel ids distinct from session ids so restore rebinds panes
面板 id 曾直接复用 pty 会话 id(`addPanel({ id: sessionId })`),而
`updateParameters` 只能换会话、不能改面板 id。于是恢复时面板 id 指向一个
已不存在的会话:分屏少一个、剩下的 pane 背后没有 pty,界面看起来是空白,
也无法输入。

- 新增 `panelId()`,面板 id 与会话 id 彻底分离,并用于所有建面板处
  (新建终端、SSH 连接、分屏复制)
- 新增 `breakIdCoincidence()`:旧快照仍带 `panelId === sessionId`,
  在 `fromJSON` 之前重写 grid/panels/views/activeView 中的 id,
  返回 old→new 映射
- `rebindSessionPanels` 用该映射回查快照,恢复每个 pane 自己的目录
  (否则新 id 查不到记录,cwd 会静默退回 home)

验证(重启恢复 E2E,legacy 快照 + 2 分屏):
  2/2 pane 挂载、2 个独立存活会话、输入可回环、
  cwd 分别回到 D:/AIGC/OpenTerminal 与 C:/Windows、回写快照无 id 冲突;
  restoreSession=false 时正确忽略快照只建 1 个终端。
2026-09-14 11:29:56 +08:00
Bill 8eacd34338 polish: frame the sidebar with top and bottom borders to match the right edge 2026-09-14 02:26:36 +08:00
Bill 6c100542c5 fix: batch of review findings — dead install button, history pollution, TUI completion interference, ssh split session kill, scrollback live apply, zmodem second transfer, sftp shell quoting, replay buffer leak, release guards 2026-09-14 02:20:40 +08:00
Bill e6fc021903 fix: drop leaked NO_COLOR/FORCE_COLOR from the pty environment (Claude Code rendered uncolored) 2026-09-14 01:39:11 +08:00
Bill c4fb6fe7e2 polish: tray balloon title no longer repeats the app name 2026-09-14 01:19:14 +08:00
Bill f63ee379a4 fix: send the first pty size immediately, debounce only subsequent resizes 2026-09-14 01:17:04 +08:00
Bill ce89dcb1e4 chore: ignore tmp-test scratch scripts 2026-09-14 01:12:41 +08:00
Bill acdf955918 fix: debounce pty resize and skip no-op resizes (kills duplicated TUI frames on maximize/restore) 2026-09-14 01:12:15 +08:00
Bill ab5f8c2f77 chore: ignore tmp-test scratch dir 2026-09-08 21:40:54 +08:00
Bill 70c33a5572 fix: register update IPC handlers (dead check/download buttons since 1.0.1); bump 1.0.4 2026-09-08 21:40:25 +08:00
Bill 7f2e4c1110 feat: terminal right-click context menu (copy/paste/find/select-all/clear) 2026-09-08 20:50:59 +08:00
Bill 7d3057ee5d fix: drop TS annotation in release.cjs 2026-09-08 17:12:52 +08:00
Bill 2b3fefafd0 feat: close-to-tray default + system settings entry; bump 1.0.3 2026-09-08 17:00:09 +08:00
Bill 8f686f56ca docs: updater proxy strategy + channel changelog in AGENTS.md 2026-09-08 09:45:29 +08:00
Bill c3baac3f05 fix: updater proxy strategy (Gitea direct, GitHub system proxy) + changelog via channel release-notes.md 2026-09-08 09:44:34 +08:00
Bill dc3d54fa07 chore: release script in repo + AGENTS.md dev/release docs 2026-09-08 01:15:02 +08:00
Bill 8cb193fa6a feat: theme-driven chrome, customizable tab accent, UI polish
- Sidebar/tab bars/dividers/settings dialog follow the terminal theme
- Tab accent color customizable in theme settings (color picker)
- Title bar shows the app icon; Material Dark is the default theme
- Slimmer rectangular tabs (28px), slate scrollbars, visible pane dividers
- Fix pure-black xterm viewport gaps after pane resize
2026-09-08 00:57:56 +08:00
Bill 0de0eee58d chore: bump version to 1.0.1 2026-09-07 17:55:41 +08:00
46 changed files with 2675 additions and 610 deletions

No files matched your search

+6
View File
@@ -32,3 +32,9 @@ release/
# stray local test artifacts
.min-test.bin
.min2-test.bin
# release notes draft (consumed by scripts/release.cjs)
RELEASE_NOTES.md
tmp-test/
tmp-test*
+49
View File
@@ -0,0 +1,49 @@
# OpenTerminal 开发备忘
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`
## 仓库与远程
- `origin` = Gitea `https://git.codingplan.site/admin/OpenTerminal.git`(主仓库 + 更新通道)
- `github` = `https://github.com/billowliu2/OpenTerminal.git`(镜像 + 备用更新源)
- ⚠️ Gitea 的 upload-pack 有故障,`git fetch origin` 会报 `bad pack header`;查询远端状态用 API 或 `git ls-remote`。推送正常。
- 2026-09:远端 main 旧历史(M3–M8,至 v0.8.0 d3a628f)被 force-push 覆盖为当前历史,旧提交仍由 tag v0.6.0 / v0.7.0 / v0.8.0 保留。
## 凭据
- `.env`(已 gitignore):`GIT_TOKEN`(Gitea)、`GH_TOKEN`(GitHub)等。git 推送通过 credential helper 使用该令牌。
## 发布新版本
1. `package.json` 版本号 +1,写 `RELEASE_NOTES.md`(仓库根,已 gitignore)
2. `npm run dist` 构建
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<版本号>` 并推送两个远程(代码/tag 的镜像保持同步,仅 release 资产不发 GitHub)
## 更新机制
- 检查更新:先 Gitea 更新通道(强制直连,不走系统代理),失败回退 GitHub(走系统代理)。 electron-updater 用独立 session(partition `electron-updater`),代理模式在 `useFeed` 里按源切换
- 更新日志:Gitea 仓库是私有的(匿名 API 404),改为从更新通道的 `release-notes.md` 读取(直连 session `openterminal-update-direct`),再回退 Gitea/GitHub releases API
- electron-updater 不支持 MSI 自动更新,自动更新只走 NSIS exe
## 主题机制
- 终端主题由 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
View File
@@ -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 待用户复测)
+3
View File
@@ -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}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "open-terminal",
"productName": "OpenTerminal",
"version": "0.1.0",
"version": "1.0.11",
"description": "Open-source terminal with SSH, split panes, themes and fonts",
"main": "out/main/index.js",
"author": "Bill",
+159
View File
@@ -0,0 +1,159 @@
// OpenTerminal release publisher
// Usage: node scripts/release.cjs <version> [--skip-github] [--skip-gitea]
// node scripts/release.cjs 1.0.2
// Reads release notes from RELEASE_NOTES.md (repo root, gitignored).
// Credentials from .env (repo root, gitignored): GIT_TOKEN, GH_TOKEN.
// If HTTPS_PROXY / HTTP_PROXY (env or .env) is set, traffic goes through it
// (needed for api.github.com / uploads.github.com in some networks).
const fs = require('node:fs')
const path = require('node:path')
const ROOT = path.join(__dirname, '..')
const V = process.argv[2]
if (!V || !/^\d+\.\d+\.\d+$/.test(V)) {
console.error('usage: node scripts/release.cjs <x.y.z> [--skip-github] [--skip-gitea]')
process.exit(1)
}
const SKIP_GH = process.argv.includes('--skip-github')
const SKIP_GITEA = process.argv.includes('--skip-gitea')
const env = Object.fromEntries(
fs.readFileSync(path.join(ROOT, '.env'), 'utf8').split('\n')
.filter((l) => l.includes('=') && !l.startsWith('#'))
.map((l) => { const i = l.indexOf('='); return [l.slice(0, i).trim(), l.slice(i + 1).trim()] })
)
const notes = fs.readFileSync(path.join(ROOT, 'RELEASE_NOTES.md'), 'utf8')
const R = path.join(ROOT, 'release')
const files = [
path.join(R, `OpenTerminal-${V}-setup.msi`),
path.join(R, `OpenTerminal-${V}-setup.exe`)
]
for (const f of files) {
if (!fs.existsSync(f)) { console.error('missing build artifact:', f); process.exit(1) }
}
// 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)
}
// 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)}`, {
method: 'POST',
headers: {
Authorization: `${authScheme} ${token}`,
'Content-Type': 'application/octet-stream',
'Content-Length': String(stat.size)
},
body: fs.createReadStream(file),
duplex: 'half',
...(viaProxy && proxyAgent ? { dispatcher: proxyAgent } : {})
})
console.log(` upload ${name}: HTTP ${resp.status}`)
if (!resp.ok) {
const body = (await resp.text()).slice(0, 300)
throw new Error(`upload failed (${resp.status}) for ${name}: ${body}`)
}
}
async function gitea() {
console.log('=== Gitea release ===')
const base = 'https://git.codingplan.site/api/v1/repos/admin/OpenTerminal'
const resp = await fetch(`${base}/releases`, {
method: 'POST',
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) { 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')
}
async function giteaChannel() {
console.log('=== Gitea update channel ===')
const base = 'https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable'
const channelFiles = [
[path.join(R, 'latest.yml'), 'latest.yml'],
[path.join(R, `OpenTerminal-${V}-setup.exe.blockmap`), `OpenTerminal-${V}-setup.exe.blockmap`],
[path.join(R, `OpenTerminal-${V}-setup.exe`), `OpenTerminal-${V}-setup.exe`],
// Changelog served from the channel: the Gitea repo is private (anonymous
// releases API 404s), but the generic package is publicly readable.
[path.join(ROOT, 'RELEASE_NOTES.md'), 'release-notes.md']
]
// 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',
headers: { Authorization: `token ${env.GIT_TOKEN}`, 'Content-Type': 'application/octet-stream', 'Content-Length': String(stat.size) },
body: fs.createReadStream(f),
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)}`)
}
}
async function github() {
console.log('=== GitHub release ===')
const resp = await fetch('https://api.github.com/repos/billowliu2/OpenTerminal/releases', {
method: 'POST',
headers: {
Authorization: `Bearer ${env.GH_TOKEN}`,
'Content-Type': 'application/json',
'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 }),
...(proxyAgent ? { dispatcher: proxyAgent } : {})
})
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', true)
}
}
async function main() {
if (!SKIP_GITEA) { await gitea(); await giteaChannel() }
if (!SKIP_GH) await github()
console.log('done')
}
main().catch((e) => { console.error(e); process.exit(1) })
+104 -10
View File
@@ -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,14 @@ export class CommandsStore {
private readonly metasByFile = new Map<string, SessionLogMeta>()
/** Logs currently accumulating output, keyed by sessionId. */
private readonly activeBySession = new Map<string, SessionLogMeta>()
/**
* Pending append per log file. appendFile is async, so two rapid logWrite
* calls would otherwise race and land out of order. Chaining each new append
* onto the previous one keeps the file append-only in call order.
*/
private readonly pendingAppends = 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 +113,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 +146,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 +272,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,8 +284,21 @@ 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
// Chain onto any in-flight append so rapid writes land in call order.
const prev = this.pendingAppends.get(meta.file) ?? Promise.resolve()
const next = prev.then(() => appendFile(meta.file, clean, 'utf8')).then(
() => undefined,
() => {
// file may have been removed after stop -> ignore
}
)
this.pendingAppends.set(meta.file, next)
// Avoid unbounded growth of the chain map once the file settles.
next.finally(() => {
if (this.pendingAppends.get(meta.file) === next) this.pendingAppends.delete(meta.file)
})
}
@@ -255,6 +306,17 @@ export class CommandsStore {
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.
const prev = this.pendingAppends.get(meta.file) ?? Promise.resolve()
const next = prev.then(() => appendFile(meta.file, tail, 'utf8')).then(
() => undefined,
() => undefined
)
this.pendingAppends.set(meta.file, next)
}
this.finishLog(meta)
}
@@ -285,4 +347,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
View File
@@ -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
}
}
+19
View File
@@ -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)
@@ -98,6 +116,7 @@ process.on('uncaughtException', (err) => {
app.whenReady().then(() => {
registerIpc()
registerUpdateIpc()
// OS-level effects (login item, sleep blocker) must apply even if the
// settings dialog is never opened this run.
applyStartupSystemSettings(loadSettings())
+10
View File
@@ -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))
}
+147
View File
@@ -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
View File
@@ -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
+82
View File
@@ -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))
}
+12 -5
View File
@@ -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
}
}
}
+12 -2
View File
@@ -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. */
+114
View File
@@ -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()
+63
View File
@@ -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)
}
+6 -4
View File
@@ -34,7 +34,7 @@ function showMainWindow(showOrCreate: () => void): void {
/** Rebuild the context menu so the close-action radio items reflect settings. */
function refreshContextMenu(showOrCreate: () => void): void {
if (!tray) return
const action = loadSettings().system.closeAction ?? 'ask'
const action = loadSettings().system.closeAction ?? 'tray'
const setAction = (value: CloseAction) => (): void => {
persistCloseAction(value)
refreshContextMenu(showOrCreate)
@@ -74,8 +74,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: '终端会话仍在后台运行,点击托盘图标可恢复窗口。'
})
}
}
@@ -86,7 +88,7 @@ function hideToTray(win: BrowserWindow): void {
*/
export async function onMainWindowClose(win: BrowserWindow, e: Electron.Event, showOrCreate: () => void): Promise<void> {
if (quitting) return
const action = loadSettings().system.closeAction ?? 'ask'
const action = loadSettings().system.closeAction ?? 'tray'
if (action === 'tray') {
e.preventDefault()
hideToTray(win)
+40 -3
View File
@@ -1,4 +1,4 @@
import { app, ipcMain, net } from 'electron'
import { app, ipcMain, net, session } from 'electron'
import { autoUpdater } from 'electron-updater'
import { Ipc, type ReleaseNote, type UpdateState } from '../shared/ipc'
import { broadcast } from './broadcast'
@@ -30,6 +30,8 @@ function setState(patch: Partial<UpdateState>): void {
function useFeed(feed: 'gitea' | 'github'): void {
activeFeed = feed
if (feed === 'gitea') {
// Domestic feed: always direct — a system proxy only breaks it.
void autoUpdater.netSession.setProxy({ mode: 'direct' })
const token = process.env.OT_UPDATE_TOKEN
autoUpdater.setFeedURL({
provider: 'generic',
@@ -37,6 +39,8 @@ function useFeed(feed: 'gitea' | 'github'): void {
...(token ? { requestHeaders: { Authorization: `token ${token}` } } : {})
})
} else {
// GitHub usually needs the system proxy (when one is enabled).
void autoUpdater.netSession.setProxy({ mode: 'system' })
autoUpdater.setFeedURL({ provider: 'github', ...GITHUB_REPO })
}
}
@@ -67,8 +71,14 @@ async function checkWithFallback(): Promise<void> {
} catch (err) {
if (activeFeed !== 'gitea') throw err
console.warn('[updater] gitea feed failed, falling back to github:', err)
const giteaErr = err instanceof Error ? err.message : String(err)
useFeed('github')
await autoUpdater.checkForUpdates()
try {
await autoUpdater.checkForUpdates()
} catch (err2) {
const ghErr = err2 instanceof Error ? err2.message : String(err2)
throw new Error(`国内源: ${giteaErr};GitHub: ${ghErr}`)
}
}
}
@@ -96,8 +106,35 @@ async function handleDownload(): Promise<void> {
}
}
/** Gitea releases first, GitHub releases as fallback; both are public reads. */
/** Direct-connection fetch for the domestic update channel (ignores system proxy). */
async function directFetch(url: string): Promise<Response> {
const s = session.fromPartition('openterminal-update-direct', { cache: false })
await s.setProxy({ mode: 'direct' })
return s.fetch(url, { headers: { 'User-Agent': 'OpenTerminal' } })
}
/**
* Changelog sources, in order:
* 1. update channel's release-notes.md — the Gitea repo itself is private
* (anonymous API reads 404), but the generic package is publicly readable.
* 2. Gitea / GitHub releases APIs (full history when reachable).
*/
async function fetchChangelog(): Promise<ReleaseNote[]> {
try {
const [notesResp, ymlResp] = await Promise.all([
directFetch(`${GITEA_FEED}release-notes.md`),
directFetch(`${GITEA_FEED}latest.yml`)
])
if (notesResp.ok) {
const body = await notesResp.text()
const yml = ymlResp.ok ? await ymlResp.text() : ''
const version = yml.match(/^version:\s*(\S+)/m)?.[1] ?? ''
const date = yml.match(/^releaseDate:\s*'?(\S+?)'?$/m)?.[1]?.slice(0, 10) ?? ''
if (body.trim()) return [{ version: version ? `v${version}` : 'latest', date, body }]
}
} catch {
// fall through to releases APIs
}
for (const url of [GITEA_RELEASES_API, GITHUB_RELEASES_API]) {
try {
const resp = await net.fetch(url, { headers: { 'User-Agent': 'OpenTerminal' } })
+11
View File
@@ -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 {
+8 -3
View File
@@ -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)
+16
View File
@@ -4,6 +4,8 @@ import { SettingsDialog } from '@renderer/settings/SettingsDialog'
import { TransferPanel } from '@renderer/sftp/TransferPanel'
import { useSettingsStore } from '@renderer/settings/store'
import { getThemeById } from '@shared/theme'
import { applyChromeTheme, applyTabAccent } from '@renderer/theme/chrome'
import appIconUrl from '../../../build/icon.png'
/** Custom window title bar: draggable strip themed with the active terminal
* theme; native min/max/close buttons come from BrowserWindow titleBarOverlay. */
@@ -15,6 +17,7 @@ function TitleBar(): React.JSX.Element {
className="app-titlebar"
style={{ background: theme.colors.background, color: theme.colors.foreground }}
>
<img className="app-titlebar-icon" src={appIconUrl} alt="" draggable={false} />
<span className="app-titlebar-title">OpenTerminal</span>
</div>
)
@@ -22,12 +25,25 @@ function TitleBar(): React.JSX.Element {
export default function App(): React.JSX.Element {
const hydrate = useSettingsStore((s) => s.hydrate)
const themeId = useSettingsStore((s) => s.settings.terminal.themeId)
const customThemes = useSettingsStore((s) => s.settings.customThemes)
const tabAccentColor = useSettingsStore((s) => s.settings.terminal.tabAccentColor)
const [settingsOpen, setSettingsOpen] = useState(false)
useEffect(() => {
void hydrate()
}, [hydrate])
// Sidebar / tab bars / dividers follow the active terminal theme.
useEffect(() => {
applyChromeTheme(getThemeById(themeId, customThemes).colors)
}, [themeId, customThemes])
// Active-tab accent color (rail indicator, tab outline, SSH badges).
useEffect(() => {
applyTabAccent(tabAccentColor)
}, [tabAccentColor])
return (
<div className="app-root">
<TitleBar />
+6 -1
View File
@@ -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;
}
@@ -346,7 +351,7 @@
}
.connections-local-item.is-active {
background: rgba(63, 185, 80, 0.12);
background: color-mix(in srgb, var(--tab-accent, #3fb950) 12%, transparent);
}
.connections-local-name {
+2
View File
@@ -1,3 +1,5 @@
/// <reference types="vite/client" />
import type { AppApi } from '../shared/api'
declare global {
+20 -7
View File
@@ -20,16 +20,16 @@ body,
font-size: 13px;
}
/* App-wide slim dark scrollbars: the native Windows style renders light
/* App-wide slim slate scrollbars: the native Windows style renders light
tracks + arrow buttons that look broken on the dark surface. */
* {
scrollbar-width: thin;
scrollbar-color: rgba(121, 121, 121, 0.45) transparent;
scrollbar-color: rgba(125, 145, 170, 0.5) transparent;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
width: 12px;
height: 12px;
}
*::-webkit-scrollbar-track {
@@ -37,12 +37,17 @@ body,
}
*::-webkit-scrollbar-thumb {
background: rgba(121, 121, 121, 0.45);
border-radius: 5px;
background: rgba(125, 145, 170, 0.5);
border: 3px solid transparent;
border-radius: 6px;
background-clip: padding-box;
min-height: 28px;
}
*::-webkit-scrollbar-thumb:hover {
background: rgba(160, 160, 160, 0.6);
background: rgba(150, 172, 200, 0.7);
border: 3px solid transparent;
background-clip: padding-box;
}
*::-webkit-scrollbar-button,
@@ -80,6 +85,14 @@ button {
letter-spacing: 0.2px;
}
.app-titlebar-icon {
width: 16px;
height: 16px;
margin-right: 7px;
border-radius: 4px;
pointer-events: none;
}
.app-titlebar-title {
opacity: 0.75;
font-weight: 500;
+36 -10
View File
@@ -1,12 +1,14 @@
import ReactDOM from 'react-dom/client'
import { useEffect } from 'react'
import { useEffect, type ReactNode } from 'react'
import { App as AntdApp, ConfigProvider, theme as antdTheme } from 'antd'
import type { AppApi } from '@shared/api'
import { DEFAULT_SETTINGS } from '@shared/settings'
import { getThemeById } from '@shared/theme'
import App from './App'
import { ErrorBoundary } from './ErrorBoundary'
import ZmodemOffers from './workspace/ZmodemOffers'
import { useSettingsStore } from './settings/store'
import { mix } from './theme/chrome'
import './global.css'
// Dev convenience: allow opening the renderer in a plain browser (vite page)
@@ -87,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
}
@@ -148,13 +154,33 @@ function FontHotkeyListener(): null {
// antd <App> provides the context that App.useApp() consumers (workspace
// message/notification) rely on; without it they get empty stubs and crash.
// ErrorBoundary keeps any render crash from black-screening the whole window.
/** antd surfaces (settings dialog, menus, popovers) follow the terminal theme:
* base/container/elevated backgrounds derive from the theme's background. */
function ThemedConfigProvider({ children }: { children: ReactNode }): React.JSX.Element {
const themeId = useSettingsStore((s) => s.settings.terminal.themeId)
const customThemes = useSettingsStore((s) => s.settings.customThemes)
const colors = getThemeById(themeId, customThemes).colors
return (
<ConfigProvider
theme={{
algorithm: antdTheme.darkAlgorithm,
token: {
colorBgBase: colors.background,
colorBgContainer: mix(colors.background, colors.foreground, 0.06) ?? '#1f1f1f',
colorBgElevated: mix(colors.background, colors.foreground, 0.1) ?? '#252525',
colorTextBase: colors.foreground,
borderRadius: 6
}
}}
>
{children}
</ConfigProvider>
)
}
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<ConfigProvider
theme={{
algorithm: antdTheme.darkAlgorithm,
token: { colorBgContainer: '#1f1f1f', colorBgElevated: '#252525', borderRadius: 6 }
}}
>
<ThemedConfigProvider>
<AntdApp>
<ErrorBoundary>
<App />
@@ -162,5 +188,5 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<FontHotkeyListener />
</ErrorBoundary>
</AntdApp>
</ConfigProvider>
</ThemedConfigProvider>
)
+3 -2
View File
@@ -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>
)}
+60 -3
View File
@@ -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}
+64 -1
View File
@@ -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,42 @@ 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="点击窗口关闭按钮时的动作;最小化到托盘后可在托盘图标右键菜单中退出"
control={
<Select
className="settings-select"
value={settings.system.closeAction ?? 'tray'}
onChange={(value) => void updateSystem({ closeAction: value })}
options={[
{ value: 'tray', label: '最小化到托盘' },
{ value: 'exit', label: '直接退出' },
{ value: 'ask', label: '每次询问' }
]}
/>
}
/>
<SettingRow
label="全局唤起快捷键"
desc="Control+Shift+Alt+T 等,留空禁用"
+22 -2
View File
@@ -1,5 +1,5 @@
import { useMemo } from 'react'
import { Button, Popconfirm } from 'antd'
import { Button, ColorPicker, Popconfirm } from 'antd'
import type { TerminalSettings } from '@shared/settings'
import { DEFAULT_SETTINGS } from '@shared/settings'
import type { TerminalTheme, ThemeColors } from '@shared/theme'
@@ -46,7 +46,26 @@ export function ThemeSettingsTab({
}
return (
<div className="theme-gallery">
<div className="theme-pane">
<div className="theme-accent-row">
<div>
<div className="settings-block-label">标签强调色</div>
<div className="settings-block-hint">活动标签边框、侧栏指示条与 SSH 徽章的强调颜色</div>
</div>
<div className="theme-accent-controls">
<ColorPicker
value={settings.terminal.tabAccentColor}
onChange={(color) => void updateTerminal({ tabAccentColor: color.toHexString() })}
/>
<Button
size="small"
onClick={() => void updateTerminal({ tabAccentColor: DEFAULT_SETTINGS.terminal.tabAccentColor })}
>
恢复默认
</Button>
</div>
</div>
<div className="theme-gallery">
{themeCards.map((t) => {
const selected = t.id === activeTheme.id
const isCustom = t.builtin === false
@@ -116,6 +135,7 @@ export function ThemeSettingsTab({
<div className="theme-card-new-icon">+</div>
<div className="theme-card-new-text">新建主题</div>
</div>
</div>
</div>
)
}
+99 -11
View File
@@ -5,13 +5,82 @@
/* ---------- settings dialog scaffolding ---------- */
.ant-modal-header {
border-bottom: 1px solid #2d2d2d;
border-bottom: 1px solid var(--chrome-border);
padding-bottom: 14px;
}
.ant-modal .ant-tabs-nav {
margin-bottom: 8px;
border-bottom: 1px solid #2d2d2d;
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 {
@@ -116,7 +185,7 @@
margin-top: 14px;
border-radius: 6px;
padding: 12px 14px;
border: 1px solid #2d2d2d;
border: 1px solid var(--chrome-border);
overflow: hidden;
}
@@ -140,6 +209,30 @@
/* ---------- theme gallery ---------- */
/* ---- theme tab: accent picker + gallery ---- */
.theme-pane {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
}
.theme-accent-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--chrome-border);
border-radius: 8px;
}
.theme-accent-controls {
display: flex;
align-items: center;
gap: 8px;
}
.theme-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
@@ -333,7 +426,7 @@
.theme-editor-preview-box {
border-radius: 6px;
padding: 10px 14px;
border: 1px solid #2d2d2d;
border: 1px solid var(--chrome-border);
font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace;
font-size: 12px;
}
@@ -356,13 +449,8 @@
width: 48px;
height: 48px;
border-radius: 12px;
background: #1e2531;
color: #3fd06a;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 18px;
object-fit: cover;
display: block;
}
.about-name {
+380 -41
View File
@@ -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 { FolderOpenOutlined, PauseOutlined, SoundOutlined } from '@ant-design/icons'
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',
@@ -44,6 +63,13 @@ const SEARCH_DECORATIONS: NonNullable<NonNullable<Parameters<SearchAddon['findNe
activeMatchColorOverviewRuler: '#c77800'
}
// xterm's default overviewRulerBorder (#7f7f7f) renders as a light vertical line
// on the dark surface once scrollback exists. Force it transparent; search-match
// marks in the ruler keep their own colors.
function withChromeColors(colors: ITheme): ITheme {
return { ...colors, overviewRulerBorder: '#00000000' }
}
export interface TerminalHandle {
focus(): void
clear(): void
@@ -143,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 }
@@ -161,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: '' }
}
@@ -219,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('')
@@ -273,7 +319,11 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
const [searchOpen, setSearchOpen] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const [resultInfo, setResultInfo] = useState('')
const [paste, setPaste] = useState<string | null>(null)
// Right-click context menu: position + whether a selection existed at open
// time (decides if 复制 is enabled).
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null)
const [menuHasSel, setMenuHasSel] = useState(false)
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)
@@ -326,12 +376,66 @@ 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])
const openSearch = useCallback(() => {
searchOpenRef.current = true
setSearchOpen(true)
}, [])
const openContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault()
setMenuHasSel(Boolean(termRef.current?.getSelection()))
// Clamp so the menu never overflows the window edge.
const x = Math.min(e.clientX, window.innerWidth - 216)
const y = Math.min(e.clientY, window.innerHeight - 200)
setMenuPos({ x: Math.max(x, 4), y: Math.max(y, 4) })
}, [])
// Global dismiss: Escape / scroll / window blur close the context menu.
useEffect(() => {
if (!menuPos) return
const close = (): void => setMenuPos(null)
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') {
e.stopPropagation()
close()
}
}
window.addEventListener('keydown', onKey, true)
window.addEventListener('wheel', close, true)
window.addEventListener('blur', close)
return () => {
window.removeEventListener('keydown', onKey, true)
window.removeEventListener('wheel', close, true)
window.removeEventListener('blur', close)
}
}, [menuPos])
const closeSearch = useCallback(() => {
searchOpenRef.current = false
searchRef.current?.clearDecorations()
@@ -351,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(() => {
@@ -392,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
@@ -463,8 +603,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
}
const mod = navigator.platform.toLowerCase().includes('mac') ? e.metaKey : e.ctrlKey
if ((e.key === 'f' || e.key === 'F') && mod && !e.altKey) {
searchOpenRef.current = true
setSearchOpen(true)
openSearch()
return false
}
if (isModShiftChord(e, 'c')) {
@@ -514,7 +653,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
}
return true
},
[closeSearch, copySelection, pasteFromClipboard, moveSelection, handleTab]
[closeSearch, openSearch, copySelection, pasteFromClipboard, moveSelection, handleTab]
)
// ---- terminal instance: built once per session ----
@@ -530,7 +669,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
cursorBlink: tSettings.cursorBlink,
cursorStyle: tSettings.cursorStyle,
cursorInactiveStyle: tSettings.cursorInactiveStyle,
theme: getThemeById(tSettings.themeId, settings.customThemes).colors as ITheme,
theme: withChromeColors(getThemeById(tSettings.themeId, settings.customThemes).colors as ITheme),
allowProposedApi: true,
overviewRuler: { width: 9, showTopBorder: false, showBottomBorder: false },
drawBoldTextInBrightColors: true,
@@ -563,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.
@@ -572,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) {
@@ -605,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 = []
@@ -664,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)
})
]
@@ -700,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()
@@ -737,7 +992,10 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
term.options.cursorBlink = tSettings.cursorBlink
term.options.cursorStyle = tSettings.cursorStyle
term.options.cursorInactiveStyle = tSettings.cursorInactiveStyle
term.options.theme = { ...(getThemeById(tSettings.themeId, settings.customThemes).colors as ITheme) }
// 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])
@@ -827,8 +1085,73 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
searchTermRef.current = value
}, [])
const runMenuAction = useCallback(
(action: () => void) => {
setMenuPos(null)
action()
termRef.current?.focus()
},
[]
)
return (
<div className={terminalClassName} onMouseDown={() => termRef.current?.focus()}>
<div className={terminalClassName} onMouseDown={() => termRef.current?.focus()} onContextMenu={openContextMenu}>
{menuPos && (
<>
<div
className="term-menu-overlay"
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
setMenuPos(null)
termRef.current?.focus()
}}
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
setMenuPos(null)
}}
/>
<div className="term-menu" style={{ left: menuPos.x, top: menuPos.y }}>
<button
type="button"
className="term-menu-item"
disabled={!menuHasSel}
onClick={() => runMenuAction(() => void copySelection())}
>
<CopyOutlined />
<span className="term-menu-label">复制</span>
<kbd>Ctrl+Shift+C</kbd>
</button>
<button
type="button"
className="term-menu-item"
disabled={dead}
onClick={() => runMenuAction(() => void pasteFromClipboard())}
>
<SnippetsOutlined />
<span className="term-menu-label">粘贴</span>
<kbd>Ctrl+Shift+V</kbd>
</button>
<div className="term-menu-sep" />
<button type="button" className="term-menu-item" onClick={() => runMenuAction(openSearch)}>
<SearchOutlined />
<span className="term-menu-label">查找</span>
<kbd>Ctrl+F</kbd>
</button>
<button type="button" className="term-menu-item" onClick={() => runMenuAction(() => termRef.current?.selectAll())}>
<SelectOutlined />
<span className="term-menu-label">全选</span>
<kbd />
</button>
<button type="button" className="term-menu-item" onClick={() => runMenuAction(() => termRef.current?.clear())}>
<ClearOutlined />
<span className="term-menu-label">清屏</span>
<kbd />
</button>
</div>
</>
)}
{searchOpen && (
<SearchBar
value={searchTerm}
@@ -863,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). */}
+52
View File
@@ -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)
}
}
+100 -50
View File
@@ -13,15 +13,24 @@
height: 100%;
}
/* Dark slim scrollbar for the xterm viewport — the native Windows one renders
light track + arrow buttons that look broken on the dark surface. */
/* xterm.css defaults .xterm-viewport to #000; when the pane height isn't a
multiple of the cell height the leftover bottom strip shows that black.
Pin it to the (theme-derived) chrome background so the gap is invisible. */
.terminal-view .xterm .xterm-viewport {
background-color: var(--chrome-bg);
}
/* Slim slate scrollbar for the xterm viewport — the native Windows one renders
light track + arrow buttons that look broken on the dark surface. The thumb
sits inset inside a wider grab lane (transparent border + padding-box clip)
so it reads as a soft slate bar, never a white line. */
.terminal-view .xterm-viewport {
scrollbar-width: thin;
scrollbar-color: rgba(121, 121, 121, 0.45) transparent;
scrollbar-color: rgba(125, 145, 170, 0.5) transparent;
}
.terminal-view .xterm-viewport::-webkit-scrollbar {
width: 10px;
width: 12px;
}
.terminal-view .xterm-viewport::-webkit-scrollbar-track {
@@ -29,12 +38,17 @@
}
.terminal-view .xterm-viewport::-webkit-scrollbar-thumb {
background: rgba(121, 121, 121, 0.45);
border-radius: 5px;
background: rgba(125, 145, 170, 0.5);
border: 3px solid transparent;
border-radius: 6px;
background-clip: padding-box;
min-height: 28px;
}
.terminal-view .xterm-viewport::-webkit-scrollbar-thumb:hover {
background: rgba(160, 160, 160, 0.6);
background: rgba(150, 172, 200, 0.7);
border: 3px solid transparent;
background-clip: padding-box;
}
.terminal-view .xterm-viewport::-webkit-scrollbar-button,
@@ -104,6 +118,73 @@
white-space: nowrap;
}
/* ---- right-click context menu ---- */
.term-menu-overlay {
position: fixed;
inset: 0;
z-index: 40;
}
.term-menu {
position: fixed;
z-index: 41;
min-width: 200px;
padding: 4px;
border: 1px solid var(--chrome-border);
border-radius: 8px;
background: var(--chrome-bg-deep);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
font-size: 12px;
color: var(--chrome-fg);
user-select: none;
}
.term-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 10px;
border: none;
border-radius: 5px;
background: transparent;
color: var(--chrome-fg);
font: inherit;
cursor: pointer;
text-align: left;
}
.term-menu-item:hover:not(:disabled),
.term-menu-item:focus-visible {
background: var(--chrome-hover);
color: var(--chrome-accent);
}
.term-menu-item:disabled {
opacity: 0.4;
cursor: default;
}
.term-menu-label {
flex: 1;
text-align: left;
}
.term-menu-item > kbd {
border: none;
background: transparent;
color: var(--chrome-fg);
opacity: 0.55;
font: inherit;
font-size: 11px;
}
.term-menu-sep {
height: 1px;
margin: 4px 8px;
background: var(--chrome-border);
}
/* ---- session log record / open-dir buttons (floating, top-left) ---- */
.term-recbar {
position: absolute;
@@ -247,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) ---- */
+67
View File
@@ -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
}
+51
View File
@@ -0,0 +1,51 @@
import type { ThemeColors } from '@shared/theme'
function parseHex(color: string): [number, number, number] | null {
const m = /^#([0-9a-f]{6})$/i.exec(color.trim())
if (!m) return null
const n = parseInt(m[1], 16)
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}
function toHex(v: number): string {
return Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
}
/** t=0 → a, t=1 → b */
export function mix(a: string, b: string, t: number): string | null {
const ca = parseHex(a)
const cb = parseHex(b)
if (!ca || !cb) return 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)}`
}
/** 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
* can't provide keeps the static fallback in global.css. */
export function applyChromeTheme(colors: ThemeColors): void {
const style = document.documentElement.style
const set = (key: string, value: string | null): void => {
if (value) style.setProperty(key, value)
}
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))
}
/** Tab accent: base color + a lightened foreground variant for text on dark
* tinted backgrounds. 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
// vars so the CSS fallbacks apply instead of an invalid "undefined" value.
if (!color) {
style.removeProperty('--tab-accent')
style.removeProperty('--tab-accent-fg')
return
}
style.setProperty('--tab-accent', color)
style.setProperty('--tab-accent-fg', mix(color, '#ffffff', 0.18) ?? 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>
)
}
+367 -74
View File
@@ -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)
}
+40 -178
View File
@@ -55,8 +55,8 @@
/* ---- M6.1 rail active state (mode entries) ---- */
.workspace-rail-btn.is-active {
color: var(--dv-active-tab-foreground-color, #e8e8e8);
box-shadow: inset 2px 0 0 #3fb950;
background: rgba(63, 185, 80, 0.10);
box-shadow: inset 2px 0 0 var(--tab-accent, #3fb950);
background: color-mix(in srgb, var(--tab-accent, #3fb950) 10%, transparent);
}
.workspace-rail-spacer {
@@ -173,19 +173,22 @@
/* Align terminal chrome with the app chrome. */
.dockview-theme-abyss {
--dv-group-view-background-color: var(--chrome-bg);
--dv-tabs-and-actions-container-background-color: #10141a;
--dv-tabs-and-actions-container-height: 32px;
--dv-tabs-and-actions-container-background-color: var(--chrome-bg-deep, #10141a);
--dv-tabs-and-actions-container-height: 28px;
--dv-tab-divider-color: transparent;
/* 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 polish: rounded pills, quiet inactive, green active ---- */
/* ---- tab polish: low rectangular tabs, quiet inactive, green active ---- */
.workspace-dockview .dv-tabs-and-actions-container {
padding: 4px 5px 0;
box-sizing: border-box;
}
.workspace-dockview .dv-tab {
border-radius: 9px;
border-radius: 3px;
padding: 0 8px;
margin-right: 4px;
background: rgba(255, 255, 255, 0.04);
@@ -202,14 +205,14 @@
/* XTerminal-style active tab: green outline + tinted bg + green title. */
.workspace-dockview .dv-tab.dv-active-tab {
background: rgba(63, 185, 80, 0.10);
box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.75);
color: #3fd06a;
background: color-mix(in srgb, var(--tab-accent, #3fb950) 10%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tab-accent, #3fb950) 75%, transparent);
color: var(--tab-accent-fg, #3fd06a);
}
.workspace-dockview .dv-tab.dv-active-tab:hover {
box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.95);
color: #4ad476;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tab-accent, #3fb950) 95%, transparent);
color: var(--tab-accent-fg, #4ad476);
}
.workspace-dockview .dv-tab .workspace-terminal-tab {
@@ -217,15 +220,15 @@
}
.workspace-dockview .dv-tab .workspace-terminal-tab-title {
font-size: 12.5px;
font-size: 12px;
line-height: 1;
}
/* Close glyph as its own mini outlined pill, mirroring the tab border. */
.workspace-dockview .dv-tab .workspace-terminal-tab-close {
width: 17px;
height: 17px;
border-radius: 4px;
width: 15px;
height: 15px;
border-radius: 2px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
opacity: 0.8;
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
@@ -233,9 +236,9 @@
.workspace-dockview .dv-tab .workspace-terminal-tab-close:hover {
opacity: 1;
color: #3fd06a;
background: rgba(63, 185, 80, 0.08);
box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.65);
color: var(--tab-accent-fg, #3fd06a);
background: color-mix(in srgb, var(--tab-accent, #3fb950) 8%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tab-accent, #3fb950) 65%, transparent);
}
/* ---- empty state overlay ---- */
@@ -377,11 +380,11 @@ body.ssh-resizing .ssh-bottom-divider {
.workspace-terminal-tab-icon {
flex: 0 0 auto;
font-size: 12px;
color: #3fb950;
color: var(--tab-accent, #3fb950);
}
.workspace-dockview .dv-tab .workspace-terminal-tab-ssh {
border-left: 2px solid #3fb950;
border-left: 2px solid var(--tab-accent, #3fb950);
padding-left: 5px !important;
gap: 5px;
}
@@ -397,9 +400,9 @@ body.ssh-resizing .ssh-bottom-divider {
padding: 1px 6px 2px;
box-sizing: border-box;
border-radius: 9px;
border: 1px solid rgba(63, 185, 80, 0.38);
background: rgba(63, 185, 80, 0.08);
color: #3fb950;
border: 1px solid color-mix(in srgb, var(--tab-accent, #3fb950) 38%, transparent);
background: color-mix(in srgb, var(--tab-accent, #3fb950) 8%, transparent);
color: var(--tab-accent, #3fb950);
font-family: 'Cascadia Mono', 'Consolas', 'SFMono-Regular', monospace;
font-size: 10.5px;
line-height: 1;
@@ -407,9 +410,9 @@ body.ssh-resizing .ssh-bottom-divider {
}
.workspace-dockview .dv-tab.dv-active-tab .workspace-terminal-tab-ssh .ssh-host-badge {
border-color: rgba(63, 185, 80, 0.6);
background: rgba(63, 185, 80, 0.14);
color: #46c96a;
border-color: color-mix(in srgb, var(--tab-accent, #3fb950) 60%, transparent);
background: color-mix(in srgb, var(--tab-accent, #3fb950) 14%, transparent);
color: var(--tab-accent-fg, #46c96a);
}
/* ---- per-group "+" right after the last tab ----
@@ -438,12 +441,12 @@ body.ssh-resizing .ssh-bottom-divider {
}
.workspace-tabbar-add:hover {
background: rgba(63, 185, 80, 0.08);
box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.65);
color: #3fd06a;
background: color-mix(in srgb, var(--tab-accent, #3fb950) 8%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tab-accent, #3fb950) 65%, transparent);
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;
@@ -476,15 +479,15 @@ body.ssh-resizing .ssh-bottom-divider {
}
.workspace-tabbar-btn2.is-active {
color: #3fd06a;
background: rgba(63, 185, 80, 0.12);
box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.55);
color: var(--tab-accent-fg, #3fd06a);
background: color-mix(in srgb, var(--tab-accent, #3fb950) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tab-accent, #3fb950) 55%, transparent);
}
.workspace-tabbar-bcast-count {
font-size: 10px;
font-weight: 600;
color: #3fb950;
color: var(--tab-accent, #3fb950);
line-height: 1;
}
@@ -565,153 +568,12 @@ body.ssh-resizing .ssh-bottom-divider {
font-size: 10px;
font-weight: 600;
line-height: 1;
color: #3fb950;
background: rgba(63, 185, 80, 0.1);
box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.35);
color: var(--tab-accent, #3fb950);
background: color-mix(in srgb, var(--tab-accent, #3fb950) 10%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tab-accent, #3fb950) 35%, transparent);
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
View File
@@ -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
View File
@@ -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
+24 -4
View File
@@ -36,6 +36,12 @@ 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
}
export interface SystemSettings {
@@ -49,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 {
@@ -80,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 = {
@@ -95,14 +112,17 @@ export const DEFAULT_SETTINGS: AppSettings = {
cursorBlink: true,
cursorStyle: 'block',
cursorInactiveStyle: 'outline',
themeId: 'default-dark',
themeId: 'material-dark',
rendererMode: 'auto',
autoWrap: true,
copyOnSelect: false,
pasteRiskConfirm: true,
suggestEnabled: true
suggestEnabled: true,
historyEnabled: true,
historyLimit: 100,
tabAccentColor: '#3fb950'
},
customThemes: [],
highlightRules: DEFAULT_HIGHLIGHT_RULES,
system: { launchAtLogin: false, preventSleep: false, globalShowHide: '', closeAction: 'ask', autoCheckUpdate: true }
system: { launchAtLogin: false, preventSleep: false, globalShowHide: '', closeAction: 'tray', autoCheckUpdate: true, restoreSession: true, shellIntegration: false }
}
+78 -15
View File
@@ -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
+57 -4
View File
@@ -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
}
}