Compare commits

..
11 Commits
Author SHA1 Message Date
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
Bill f25a1627f3 feat: about tab with update check/download (Gitea feed first, GitHub fallback)
- updater: dual-feed state machine, manual check/download/install IPC,
  changelog from Gitea releases API with GitHub fallback
- settings: new About tab (version, channel, auto-check toggle, progress)
- system.autoCheckUpdate setting gates the startup check
2026-09-07 17:18:15 +08:00
Bill a9acdbe500 Initial commit: OpenTerminal v0.1.0
Open-source terminal app (local + SSH): split panes, themes, SFTP,
server monitoring, broadcast input, ZMODEM, system tray, single instance.
Electron + React + TypeScript. MIT License.
2026-09-07 16:15:50 +08:00
45 changed files with 1598 additions and 470 deletions

No files matched your search

+10
View File
@@ -15,6 +15,9 @@ research/
*.log
.DS_Store
# credentials (do not commit)
.env
# ts build info
*.tsbuildinfo
.sftp-test-*.bin
@@ -25,3 +28,10 @@ tests/.session-e2e.cjs
tests/.hl-split-smoke.cjs
tests/.zmodem-e2e.cjs
release/
# stray local test artifacts
.min-test.bin
.min2-test.bin
# release notes draft (consumed by scripts/release.cjs)
RELEASE_NOTES.md
-1
View File
@@ -1 +0,0 @@
Line truncated
-1
View File
@@ -1 +0,0 @@
Line truncated
+43
View File
@@ -0,0 +1,43 @@
# OpenTerminal 开发备忘
Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
## 常用命令
- 开发:`npm run 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 <版本号>`,例如 `node scripts/release.cjs 1.0.2`
- 依次完成:Gitea release(含 msi/exe 资产)→ Gitea 更新通道(`api/packages/admin/generic/openterminal-update/stable`,更新 latest.yml/blockmap/exe/release-notes.md)→ GitHub release
- 可用 `--skip-github` / `--skip-gitea` 跳过某步
- 访问 GitHub API 不通时,设 `HTTPS_PROXY=http://127.0.0.1:7897` 再走代理
4. 验证更新通道:`curl https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/latest.yml` 应返回新版本号
5. `git tag v<版本号>` 并推送两个远程
## 更新机制
- 检查更新:先 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 变量生效
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Bill
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+28 -13
View File
@@ -1,8 +1,6 @@
# OpenTerminal
一个受 XTerminal 启发的开源终端应用:本地终端 + 分屏管理 + 字体/主题定制。Electron + React + TypeScript。
对应调研背景与逆向分析记录见 `research/`(XTerminal 5.7.17 解包产物与功能清单)。
开源的现代化终端应用:本地终端 + SSH 远程会话 + 分屏管理 + 主题/字体定制。Electron + React + TypeScript,从 0 到 1 独立开发。
## 快速开始
@@ -15,34 +13,46 @@ npm run typecheck # 全量类型检查
> Electron 二进制下载失败时:`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ node node_modules/electron/install.js`
## 已实现功能(M1+M2)
## 功能
**终端核心**
- 本地终端(node-pty,Windows PowerShell / macOS zsh / Linux bash 自动探测)
- 本地终端(node-pty,Windows PowerShell / macOS zsh / Linux bash 自动探测),自动声明 256 色与真彩能力
- xterm.js 渲染:WebGL 高性能模式,加载失败/上下文丢失自动降级 DOM
- 会话生命周期:退出遮罩显示退出码、关闭按钮;窗口关闭时回收全部会话
- 会话生命周期:退出遮罩显示退出码;窗口关闭时回收全部会话
- 搜索(Ctrl+F 内嵌搜索条、全部高亮、n/N 计数)
- 剪贴板(Ctrl+Shift+C/V、可选选中即复制)
- 粘贴风险确认(多行/长文本需手动确认,可关闭)
- 字体/字号/字重/字距/行高/回滚行数/光标样式实时生效
- 关键词高亮:11 条内置规则(权限、路径、错误状态、IP、URL 等),支持自定义正则规则
**SSH 远程会话(M2)**
**SSH 远程会话**
- ssh2 实现:密码 / 私钥(文件路径或粘贴内容)/ SSH Agent 三种认证
- 连接管理器:左侧书签栏(分组手风琴)、双击连接、新建/编辑/删除对话框
- 凭据安全:密码/私钥/口令经 Electron safeStorage(DPAPI)加密后落盘,渲染层永远接触不到明文密钥;支持「连接时询问密码」模式
- 主机指纹校验(known hosts 固定):首次连接确认、指纹变更红色告警、30s 无响应自动拒绝
- 连接中状态展示、失败原因提示、lastConnectedAt 记录
- 服务器硬件监控:CPU / 内存 / 磁盘 / 网络实时面板(无需在服务器部署任何脚本)
- SFTP 文件管理:双栏浏览、拖拽传输、队列/进度/取消、权限与属主修改
- ZMODEM(rz/sz)文件传输
- 会话数据面与本地终端完全统一(写/缩放/关闭/退出事件同通道)
**分屏与布局**
- dockview 标签系统:水平/垂直分屏、拖拽重排、标签右键菜单(关闭/关闭其他/关闭右侧)
- dockview 标签系统:水平/垂直分屏、拖拽重排、标签右键菜单
- 布局模板:把当前分屏保存为模板、一键应用(自动重建会话)、可删除
- 空态引导页
- Terminal / SSH 双工作区,互不混排
**设置与主题**
- 12 款内置主题(Dracula / One Half / Solarized / Gruvbox / Nord / Monokai / GitHub / Afterglow / Material 等)
**效率工具**
- 广播输入:勾选 ≥2 个终端后同步键入,标签带广播标记
- 快捷输入面板:右下角浮层,命令历史 + 命令库(支持 `{{param}}` 变量)+ 一键执行
- 输入建议:历史 + 命令库来源,Tab 接受、回车始终直接执行(可在设置中整体关闭)
- 会话日志:手动启停、纯文本落盘
- 快捷键:Ctrl+=/-/0 字号、Ctrl+PgUp/PgDn 切换面板、全局唤起/隐藏(可配)
**外观与窗口**
- 12 款内置主题(Dracula / One Half / Solarized / Gruvbox / Nord / Monokai / GitHub 等)
- 自定义主题编辑器:16 色 ANSI 调色板 + 前景/背景/光标/选区色,实时预览
- 系统字体枚举(font-list)+ 内置默认字体栈
- 窗口标题栏与背景跟随终端主题
- 系统托盘:关闭按钮可设「每次询问 / 最小化到托盘 / 直接退出」,托盘菜单可切换
- 单实例运行:重复启动唤出已有窗口
- 设置持久化(userData/settings.json,原子写入)+ 多窗口实时同步
**测试**
@@ -62,6 +72,7 @@ src/
│ ├── pty.ts # PTY 会话池(node-pty)→ 广播 PTY_DATA/PTY_EXIT
│ ├── settingsStore.ts # JSON 持久化 + 深合并兜底
│ ├── layouts.ts # 布局模板存储(userData/layouts/*.json)
│ ├── tray.ts # 系统托盘 + 关闭行为
│ └── ipc.ts # 全部 ipcMain 注册
├── preload/index.ts # contextBridge → window.api
└── renderer/src/
@@ -79,3 +90,7 @@ src/
- 布局模板:`%APPDATA%/OpenTerminal/layouts/*.json`
- SSH 连接书签:`%APPDATA%/OpenTerminal/connections.json`(密钥字段 DPAPI 加密)
- 主机指纹:`%APPDATA%/OpenTerminal/ssh_known_hosts.json`
## License
[MIT](LICENSE)
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+24 -13
View File
@@ -1,6 +1,6 @@
# OpenTerminal 长期路线图
目标:以开源组件组合复刻 XTerminal 的核心终端能力(调研依据见 `research/i18n-extracted.txt` 功能清单),按里程碑推进,每个里程碑可独立交付使用。
目标:以开源组件组合构建现代化的本地 + SSH 终端应用,按里程碑推进,每个里程碑可独立交付使用。
## 里程碑总览
@@ -15,6 +15,7 @@
| M6 | 广播输入 + ZMODEM + 快捷输入面板 + 快捷键系统 | ✅ 已完成(v0.6) |
| M7 | 打包分发(MSI + NSIS + 自动更新) | ✅ 已完成(v0.7,Release 已发布) |
| M8 | 工作区分离(Terminal/SSH 双工作区)+ SSH 底部文件面板 + 右键菜单 + 监控美化 + 补全修复 | ✅ 已完成(v0.8) |
| M9 | 体验打磨:系统托盘 + 关闭行为 + 单实例 + 主题联动标题栏 + 布局菜单重做 + 补全交互修正 + 应用图标 | ✅ 已完成 |
## M2 — SSH 远程会话 ✅
@@ -40,17 +41,17 @@
### B. 服务器硬件监控(参考 Wave Terminal 的 sysinfo 块)
技术可行性:✅ 高。XTerminal 逆向已证实路线 1 的完整链路(ensureSetupServerStatusFiles / getServerStatsByQuery / getServerProcessList)。
技术可行性:✅ 高,零部署方案已在真实服务器上验证。
- **路线 2 起步(零部署)**:ssh2 `exec` 读 Linux 标准接口——CPU `/proc/stat`(双采样算占用率)、内存 `/proc/meminfo`、负载 `/proc/loadavg`、磁盘 `df -kP`、网络 `/proc/net/dev`(双采样算速率)、运行时间 `/proc/uptime`
- **路线 1 增强(部署脚本)**:首次连接部署 `~/.openterminal/sysinfo.sh`(纯 shell 输出 JSON,Linux/macOS 通用),解锁进程 Top 列表
- **零部署路线**:ssh2 `exec` 读 Linux 标准接口——CPU `/proc/stat`(双采样算占用率)、内存 `/proc/meminfo`、负载 `/proc/loadavg`、磁盘 `df -kP`、网络 `/proc/net/dev`(双采样算速率)、运行时间 `/proc/uptime`
- **增强路线(部署脚本)**:首次连接部署 `~/.openterminal/sysinfo.sh`(纯 shell 输出 JSON,Linux/macOS 通用),解锁进程 Top 列表
数据面:ssh2 `conn.exec` → stdout JSON → 主进程解析 → `STATS:{id, stats}` 广播 → 渲染层
- [ ] `src/main/sysinfo.ts`:按会话轮询(默认 3s,可配置),会话关闭/断线自动停止;解析容错
- [ ] 契约:`shared/sysinfo.ts`(SysinfoSample + STATS 通道 + 面板开关事件)
- [ ] UI:SSH 会话右侧可折叠监控面板——CPU 多核条形、内存/磁盘用量条、网络上下行、负载/运行时间
- [ ] (路线 1)进程 Top 列表 + 结束进程
- [ ] (增强路线)进程 Top 列表 + 结束进程
- 验收:连接后 ≤3s 看到实时 CPU/内存;断开会话监控自动停止;服务器跑 `stress` 曲线明显上升
## M4 — SFTP 文件管理 ✅
@@ -64,10 +65,10 @@
- [ ] 命令历史(sqlite + better-sqlite3,按会话去重)
- [ ] 命令库(分组 + 参数变量 `{{param}}` + 一键运行/粘贴)
- [ ] 终端补全:历史 + 命令库 + 内置命令表(XTerminal 内置 607 个 Linux 命令文档,可从 tldr 页面生成)
- [ ] 终端补全:历史 + 命令库 + 内置命令表(内置命令文档可从 tldr 页面生成)
- [ ] 会话日志:手动启停、纯文本落盘、断线暂停指示
- [x] ~~关键词高亮~~(✅ 已提前完成:highlightEngine 流式 ANSI 注入 + 11 条预设 + 设置页管理,见 M2.5)
- 验收:补全 ↑↓/Tab/Esc 交互与 XTerminal 一致
- 验收:补全 ↑↓ 选择 / Tab 接受 / Esc 关闭 / 回车直接执行,交互流畅
## M6 — 多终端协同 ✅
@@ -82,15 +83,25 @@
- [x] electron-builder:**MSI**(固定 upgradeCode,手动/企业部署)+ NSIS(x64),`npm run dist` / `dist:dir`
- [x] 原生模块:@lydell/node-pty 预编译 + asarUnpack(node-pty/ssh2),npmRebuild=false,win-unpacked 冒烟通过
- [x] 自动更新(electron-updater,generic feed = Gitea generic package 稳定地址,公网可读;仅 packaged 启用,OT_UPDATE_URL/OT_UPDATE_TOKEN 可覆盖)
- [ ] 代码签名(可选,未做);应用图标(默认 Electron 图标,MSI 快捷方式暂禁用待图标补齐)
- [x] 应用图标(build/icon.png)已补齐,MSI/NSIS 桌面与开始菜单快捷方式已启用
- [ ] 代码签名(可选,未做)
## M9 — 体验打磨 ✅
- [x] 系统托盘:托盘图标 + 右键菜单(显示窗口/关闭行为/退出);关闭按钮行为可设「每次询问/最小化到托盘/直接退出」,询问对话框支持记住选择(settings.closeAction)
- [x] 单实例运行:requestSingleInstanceLock,二次启动唤出已有窗口(托盘隐藏也可恢复)
- [x] 主题联动标题栏:titleBarOverlay 自定义标题栏,颜色跟随终端主题实时刷新;窗口背景同色
- [x] 布局菜单重做:弃用 antd Dropdown 传送门(与 dockview 全局指针处理冲突导致闪退),改为侧栏内联弹出面板
- [x] 补全交互修正:回车不再接受建议(此前会把建议拼接到已输入内容后,如 kimi → kimikimi);与已输入完全相同的建议自动过滤;新增「输入建议」设置开关
- [x] 标签页视觉打磨:圆角胶囊 + 描边 + 独立关闭按钮
- [x] 本地 PTY 声明终端能力(TERM/COLORTERM/TERM_PROGRAM),TUI 程序不再降级配色
## 工程约定(贯穿各阶段)
1. **契约先行**:新增功能先在 `src/shared/` 定义通道与模型,再实现两端
2. **原生依赖**:优先选带预编译二进制的包(@lydell/node-pty 模式),避免用户机器装构建链
3. **渲染层加密不可取**:不做代码混淆/加密(XTerminal 加密渲染层的做法牺牲了可调试性,收益仅是防抄)
4. **测试**:main 进程服务(pty/ssh/sftp)用 vitest 单测;渲染层交互靠 dev 手测
5. **里程碑验收后打 git tag**(引入 git 后)
3. **测试**:main 进程服务(pty/ssh/sftp)用 vitest 单测;渲染层交互靠 dev 手测
4. **里程碑验收后打 git tag**
## 已知待办(M1 遗留)
@@ -99,7 +110,7 @@
- [x] ~~首启动空屏~~(v0.2.1:启动自动打开一个终端)
- [ ] autoWrap=false 的固定列宽模式(xterm.js 无原生 wrapMode,需自定义列数控制)
- [ ] 终端标题跟随 OSC 序列(窗格标题显示当前目录/命令)
- [ ] 内置等宽字体打包(OFL 许可:JetBrains Mono / Fira Code 等,XTerminal 内置 19 款可作清单参考)
- [ ] rendererMode webgl 的终端数量上限自动降级(XTerminal:7 个后强制 DOM)
- [ ] 内置等宽字体打包(OFL 许可:JetBrains Mono / Fira Code 等)
- [ ] rendererMode webgl 的终端数量上限自动降级(大量终端实例时回退 DOM 渲染)
- [ ] 会话数据在面板间拖拽迁移(dockview 原生支持面板拖动,已具备)
- [ ] 界面语言切换(中/英):需先把全应用文案抽为 i18n 资源目录(一次完整重构),设置页预留「系统」分区
+5 -3
View File
@@ -4,7 +4,8 @@
## 当前版本与仓库
- v0.8.0,远程 `git.codingplan.site/admin/OpenTerminal.git`(令牌已存于 .git/config)
- v0.1.0(版本重置:作为全新开源项目从 0.1.0 起步),远程 `git.codingplan.site/admin/OpenTerminal.git`(国内仓)+ `github.com/billowliu2/OpenTerminal.git`(GitHub 镜像仓);凭据存于 `.env`(已 git 忽略),凭据助手按 host 自动读取
- 开源协议:MIT(LICENSE)
- 更新通道 = `https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/`(公网可读,含 latest.yml/exe/blockmap)
- 技术栈:Electron + electron-vite + React 19 + TS strict + antd 6(全局深色)+ zustand + dockview-react 8 + @xterm/xterm 6 + @lydell/node-pty + ssh2 + zmodem.js + electron-updater + electron-builder
- 契约先行约定:所有跨进程接口定义在 `src/shared/`,主/预载/渲染各自实现;新功能先改 shared 再派两端
@@ -22,6 +23,7 @@
| M6 广播输入+ZMODEM+快捷输入面板+快捷键系统 | ✅(v0.6.0,UI 活体验收;ZMODEM 双向 e2e) |
| M7 打包分发(MSI+NSIS+自动更新) | ✅(v0.7.0 已发布 Release) |
| M8 工作区分离+底部文件面板+右键菜单+监控美化+补全修复 | ✅(v0.8.0) |
| M9 体验打磨(托盘+关闭行为+单实例+主题联动标题栏+布局菜单重做+补全修正+图标) | ✅ |
## M8 实现要点(v0.8.0,已落地)
@@ -77,7 +79,7 @@ node tests/zmodem-e2e.mjs
4. **antd Modal 必须包在 `<App>`+darkAlgorithm 内**,`.ant-app` 需 height:100%(高度链)
5. **xterm 清理**:IDisposable 只能 .dispose(),不可当函数调用
6. **zmodem.js 0.1.10**:Node 可 require 核心(Sentry/Session);`on_input` 交付的是普通 Array 非 Uint8Array(需防御转换);send_files 在 zmodem_browser.js:58(FileReader 版,已移植 fs 版)
7. **MSI 无图标会链接失败**(WiX LGHT0094:Shortcut 引用不存在的 Icon)→ electron-builder.yml 已设 msi createDesktop/StartMenuShortcut:false;补 build/icon.ico 后可恢复
7. ~~**MSI 无图标会链接失败**~~(已修复:build/icon.png 由程序生成,electron-builder 自动转 ico,MSI/NSIS 快捷方式已启用)
8. **electron-updater 不支持 MSI 自动更新** → 更新通道走 NSIS exe;MSI 仅分发
9. **命令库测试时间戳竞态**:saveLibraryItem 连续保存同一毫秒 createdAt 相同排序不稳 → 测试保存间 wait(2)
10. **JD 服务器测试凭据已过期**;凭据一律环境变量且不落盘
@@ -90,6 +92,6 @@ node tests/zmodem-e2e.mjs
## 待办(按优先级)
1. 应用图标(build/icon.ico)——MSI 快捷方式随图标恢复
1. ~~应用图标~~(已完成:build/icon.png,程序生成的原创图标)
2. 用户实测项:Ctrl+PgUp/PgDn 真实键盘(合成键盘无法验证修饰键)、真实服务器 rz/sz 一轮、全局唤起快捷键
3. 小项:autoWrap=false 固定列宽、OSC 标题跟随、内置 OFL 字体打包、WebGL 终端数上限降级、i18n(中英切换)、最近命令历史出现两条命令拼接的记录(广播键入时行捕获合并,低优先级修)
+6 -6
View File
@@ -28,17 +28,17 @@ msi:
# Fixed upgrade code keeps the upgrade chain consistent across releases.
upgradeCode: "{5ab9f79e-e4eb-4052-9df6-3af3a301ab0a}"
artifactName: ${productName}-${version}-setup.${ext}
# No build/icon.ico exists. The WiX template only emits <Icon> when an icon is
# set, but the shortcut elements still reference Icon=<iconId>, so MSI linking
# fails with LGHT0094. Disabling MSI shortcuts drops the dangling reference.
# NSIS still creates its shortcuts independently.
createDesktopShortcut: false
createStartMenuShortcut: false
# Shortcuts need an <Icon>; build/icon.png (auto-converted to .ico) provides it.
# Without an icon the WiX shortcut Icon reference fails to link (LGHT0094).
createDesktopShortcut: true
createStartMenuShortcut: true
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
perMachine: false
createDesktopShortcut: true
createStartMenuShortcut: true
artifactName: ${productName}-${version}-setup.${ext}
# Declaring a publish provider makes electron-builder emit the update-info file
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "open-terminal",
"version": "0.1.0",
"version": "0.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-terminal",
"version": "0.1.0",
"version": "0.8.0",
"dependencies": {
"@lydell/node-pty": "^1.2.0-beta.15",
"@xterm/addon-fit": "^0.11.0",
+3 -2
View File
@@ -1,10 +1,11 @@
{
"name": "open-terminal",
"productName": "OpenTerminal",
"version": "0.8.0",
"description": "Terminal with split panes, themes and fonts (XTerminal-inspired)",
"version": "1.0.4",
"description": "Open-source terminal with SSH, split panes, themes and fonts",
"main": "out/main/index.js",
"author": "Bill",
"license": "MIT",
"private": true,
"scripts": {
"dev": "electron-vite dev",
+124
View File
@@ -0,0 +1,124 @@
// 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) }
}
const proxy = process.env.HTTPS_PROXY || process.env.https_proxy || env.HTTPS_PROXY || env.PROXY
if (proxy) {
const { ProxyAgent, setGlobalDispatcher } = require('undici')
setGlobalDispatcher(new ProxyAgent(proxy))
console.log('using proxy:', proxy)
}
async function upload(url, file, token, authScheme) {
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'
})
console.log(` upload ${name}: HTTP ${resp.status}`)
if (!resp.ok) console.log(' ', (await resp.text()).slice(0, 300))
}
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) { console.log('create failed:', resp.status, (await resp.text()).slice(0, 300)); return }
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 del = await fetch(base, { method: 'DELETE', headers: { Authorization: `token ${env.GIT_TOKEN}` } })
console.log(' delete old stable:', del.status)
const channelFiles = [
[path.join(R, 'latest.yml'), 'latest.yml'],
[path.join(R, `OpenTerminal-${V}-setup.exe.blockmap`), `OpenTerminal-${V}-setup.exe.blockmap`],
[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']
]
for (const [f, name] of channelFiles) {
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}`)
}
}
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 })
})
if (!resp.ok) { console.log('create failed:', resp.status, (await resp.text()).slice(0, 300)); return }
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')
}
}
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) })
Binary file not shown.

After

Width:  |  Height:  |  Size: 131 B

+54 -3
View File
@@ -1,19 +1,61 @@
import { app, BrowserWindow, globalShortcut, shell } from 'electron'
import { app, BrowserWindow, globalShortcut, nativeImage, shell } from 'electron'
import { existsSync } from 'fs'
import { join } from 'path'
import { registerIpc } from './ipc'
import { killAllPtys } from './pty'
import { applyStartupSystemSettings, loadSettings } from './settingsStore'
import { configureAutoUpdater } from './updater'
import { initTray, markQuitting, onMainWindowClose } from './tray'
import { configureAutoUpdater, registerUpdateIpc } from './updater'
import { applyWindowChrome } from './windowChrome'
import { getThemeById } from '@shared/theme'
/** Re-create the main window (tray restore path after all windows are gone). */
function showOrCreate(): void {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
else {
const win = BrowserWindow.getAllWindows()[0]
if (win.isMinimized()) win.restore()
win.show()
win.focus()
}
}
// 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()
if (!gotSingleInstanceLock) {
app.quit()
} else {
app.on('second-instance', () => showOrCreate())
}
function createWindow(): void {
// Dev-mode window/taskbar icon; packaged builds inherit the exe icon
// (electron-builder embeds build/icon.png), so undefined is fine there.
const devIcon = join(__dirname, '../../build/icon.png')
const settings = loadSettings()
const theme = getThemeById(settings.terminal.themeId, settings.customThemes)
const win = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 720,
minHeight: 480,
show: false,
backgroundColor: '#1e1e1e',
backgroundColor: theme.colors.background,
autoHideMenuBar: true,
// Custom title bar: the renderer draws the strip, Windows draws the
// min/max/close overlay — colors follow the active terminal theme.
...(process.platform === 'win32'
? {
titleBarStyle: 'hidden' as const,
titleBarOverlay: {
color: theme.colors.background,
symbolColor: theme.colors.foreground,
height: 36
}
}
: {}),
...(existsSync(devIcon) ? { icon: nativeImage.createFromPath(devIcon) } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false
@@ -22,6 +64,11 @@ function createWindow(): void {
win.on('ready-to-show', () => win.show())
// Close button → tray / exit per the closeAction setting (ask by default).
win.on('close', (e) => {
void onMainWindowClose(win, e, showOrCreate)
})
// Surface renderer console errors in the dev terminal for diagnosis.
win.webContents.on('console-message', (event) => {
if (event.level === 'error') {
@@ -51,10 +98,12 @@ 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())
createWindow()
initTray(showOrCreate)
configureAutoUpdater()
app.on('activate', () => {
@@ -63,6 +112,8 @@ app.whenReady().then(() => {
})
app.on('before-quit', () => {
// Let window 'close' events pass through so teardown completes.
markQuitting()
killAllPtys()
globalShortcut.unregisterAll()
})
+9 -1
View File
@@ -139,7 +139,15 @@ export function createPty(opts: PtyCreateOptions = {}): PtyCreateResult {
const id = randomUUID()
const shell = opts.shell ?? defaultShell()
const cwd = opts.cwd ?? homedir()
const env = { ...process.env, ...opts.env } as Record<string, string>
// 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 = {
...process.env,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
TERM_PROGRAM: 'OpenTerminal',
...opts.env
} as Record<string, string>
const pty = spawn(shell, [], { name: 'xterm-256color', cols: 80, rows: 24, cwd, env })
sessions.set(id, { kind: 'local', pty })
+16 -2
View File
@@ -13,10 +13,17 @@ import {
import type { TerminalTheme } from '../shared/theme'
import { broadcast } from './broadcast'
import { applyGlobalShortcut } from './globalShortcuts'
import { applyWindowChrome } from './windowChrome'
const settingsPath = (): string => join(app.getPath('userData'), 'settings.json')
const DEFAULT_SYSTEM: SystemSettings = { launchAtLogin: false, preventSleep: false, globalShowHide: '' }
const DEFAULT_SYSTEM: SystemSettings = {
launchAtLogin: false,
preventSleep: false,
globalShowHide: '',
closeAction: 'ask',
autoCheckUpdate: true
}
const TERMINAL_KEYS = new Set(Object.keys(DEFAULT_SETTINGS.terminal) as (keyof TerminalSettings)[])
@@ -77,10 +84,16 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
const candidate = sys as Record<string, unknown>
const globalShowHide =
typeof candidate.globalShowHide === 'string' ? candidate.globalShowHide : ''
const closeAction =
candidate.closeAction === 'tray' || candidate.closeAction === 'exit'
? candidate.closeAction
: 'ask'
system = {
launchAtLogin: candidate.launchAtLogin === true,
preventSleep: candidate.preventSleep === true,
globalShowHide
globalShowHide,
closeAction,
autoCheckUpdate: candidate.autoCheckUpdate !== false
}
}
}
@@ -147,6 +160,7 @@ export function saveSettings(next: AppSettings): AppSettings {
system: next.system ?? DEFAULT_SYSTEM
}).settings
applySystemSettings(merged.system)
applyWindowChrome(merged)
const pretty = JSON.stringify(merged, null, 2)
mkdirSync(app.getPath('userData'), { recursive: true })
+118
View File
@@ -0,0 +1,118 @@
import { app, BrowserWindow, dialog, Menu, nativeImage, Tray } from 'electron'
import type { SystemSettings } from '@shared/settings'
import { loadSettings, saveSettings } from './settingsStore'
import trayIconPath from './assets/tray.png?asset'
export type CloseAction = NonNullable<SystemSettings['closeAction']>
let tray: Tray | null = null
let quitting = false
let balloonShown = false
/** Once true, window close events pass through and the app really quits. */
export function markQuitting(): void {
quitting = true
}
function persistCloseAction(action: CloseAction): void {
const next = loadSettings()
next.system.closeAction = action
saveSettings(next)
}
function showMainWindow(showOrCreate: () => void): void {
const win = BrowserWindow.getAllWindows()[0]
if (win) {
if (win.isMinimized()) win.restore()
win.show()
win.focus()
} else {
showOrCreate()
}
}
/** 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 ?? 'tray'
const setAction = (value: CloseAction) => (): void => {
persistCloseAction(value)
refreshContextMenu(showOrCreate)
}
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: '显示主窗口', click: (): void => showMainWindow(showOrCreate) },
{ type: 'separator' },
{ label: '关闭按钮行为', enabled: false },
{ label: '每次询问', type: 'radio', checked: action === 'ask', click: setAction('ask') },
{ label: '最小化到托盘', type: 'radio', checked: action === 'tray', click: setAction('tray') },
{ label: '直接退出', type: 'radio', checked: action === 'exit', click: setAction('exit') },
{ type: 'separator' },
{
label: '退出',
click: (): void => {
markQuitting()
app.quit()
}
}
])
)
}
/** Create the tray icon and wire left-click to window restore. */
export function initTray(showOrCreate: () => void): void {
const icon = nativeImage.createFromPath(trayIconPath)
tray = new Tray(icon)
tray.setToolTip('OpenTerminal')
tray.on('click', (): void => showMainWindow(showOrCreate))
refreshContextMenu(showOrCreate)
}
function hideToTray(win: BrowserWindow): void {
win.hide()
if (process.platform === 'win32' && tray && !balloonShown) {
balloonShown = true
tray.displayBalloon({
iconType: 'info',
title: 'OpenTerminal 仍在运行',
content: '已最小化到系统托盘,终端会话保持运行。点击托盘图标可恢复窗口。'
})
}
}
/**
* Window close interception. Must be called synchronously from the 'close'
* event: preventDefault happens before any await on every branch.
*/
export async function onMainWindowClose(win: BrowserWindow, e: Electron.Event, showOrCreate: () => void): Promise<void> {
if (quitting) return
const action = loadSettings().system.closeAction ?? 'tray'
if (action === 'tray') {
e.preventDefault()
hideToTray(win)
return
}
if (action === 'exit') return // fall through: close proceeds, app quits
e.preventDefault()
const { response, checkboxChecked } = await dialog.showMessageBox(win, {
type: 'question',
title: '关闭 OpenTerminal',
message: '最小化到托盘,还是直接退出?',
detail: '最小化到托盘时终端会话保持运行。',
buttons: ['最小化到托盘', '直接退出'],
defaultId: 0,
cancelId: 1,
checkboxLabel: '记住我的选择,不再询问',
checkboxChecked: false,
noLink: true
})
if (response === 0) {
if (checkboxChecked) persistCloseAction('tray')
hideToTray(win)
} else {
if (checkboxChecked) persistCloseAction('exit')
markQuitting()
app.quit()
}
refreshContextMenu(showOrCreate)
}
+172 -36
View File
@@ -1,54 +1,190 @@
import { app } 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'
import { loadSettings } from './settingsStore'
import { markQuitting } from './tray'
/**
* Default update feed (generic provider). The main agent uploads latest.yml and
* the NSIS installer to this stable path.
*
* KNOWN LIMITATION: electron-updater does NOT support MSI auto-updates. The
* update channel always runs through the NSIS .exe (latest.yml + .exe are
* uploaded together). MSI is manual/enterprise distribution only.
* Update feeds: domestic Gitea generic package is tried first (fast in
* China); GitHub releases is the fallback. Both feeds carry the NSIS .exe —
* electron-updater does NOT support MSI auto-updates, MSI is manual only.
*/
const DEFAULT_FEED_URL =
const GITEA_FEED =
'https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/'
const GITEA_RELEASES_API =
'https://git.codingplan.site/api/v1/repos/admin/OpenTerminal/releases?limit=10'
const GITHUB_REPO = { owner: 'billowliu2', repo: 'OpenTerminal' }
const GITHUB_RELEASES_API =
'https://api.github.com/repos/billowliu2/OpenTerminal/releases?per_page=10'
let state: UpdateState = { status: 'idle', currentVersion: app.getVersion() }
let activeFeed: 'gitea' | 'github' = 'gitea'
let eventsWired = false
function setState(patch: Partial<UpdateState>): void {
state = { ...state, ...patch, currentVersion: app.getVersion() }
broadcast(Ipc.UPDATE_STATE, state)
}
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',
url: process.env.OT_UPDATE_URL || GITEA_FEED,
...(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 })
}
}
function wireEvents(): void {
if (eventsWired) return
eventsWired = true
autoUpdater.on('checking-for-update', () => setState({ status: 'checking', error: undefined }))
autoUpdater.on('update-available', (info) =>
setState({ status: 'available', version: info.version, feed: activeFeed, percent: undefined })
)
autoUpdater.on('update-not-available', () => setState({ status: 'latest', version: undefined }))
autoUpdater.on('download-progress', (p) =>
setState({ status: 'downloading', percent: Math.round(p.percent) })
)
autoUpdater.on('update-downloaded', (info) =>
setState({ status: 'downloaded', version: info.version, percent: undefined })
)
autoUpdater.on('error', (err) =>
setState({ status: 'error', error: err instanceof Error ? err.message : String(err) })
)
}
/** Check the active feed; on failure switch Gitea → GitHub and retry once. */
async function checkWithFallback(): Promise<void> {
try {
await autoUpdater.checkForUpdates()
} 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')
try {
await autoUpdater.checkForUpdates()
} catch (err2) {
const ghErr = err2 instanceof Error ? err2.message : String(err2)
throw new Error(`国内源: ${giteaErr};GitHub: ${ghErr}`)
}
}
}
async function handleCheck(): Promise<UpdateState> {
if (!app.isPackaged) {
setState({ status: 'dev' })
return state
}
setState({ status: 'checking', error: undefined, percent: undefined })
try {
await checkWithFallback()
} catch (err) {
setState({ status: 'error', error: err instanceof Error ? err.message : String(err) })
}
return state
}
async function handleDownload(): Promise<void> {
if (!app.isPackaged) return
setState({ status: 'downloading', percent: 0 })
try {
await autoUpdater.downloadUpdate()
} catch (err) {
setState({ status: 'error', error: err instanceof Error ? err.message : String(err) })
}
}
/** 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' } })
if (!resp.ok) continue
const data = (await resp.json()) as Array<{
tag_name?: string
name?: string
published_at?: string
body?: string
}>
return data
.map((r) => ({
version: r.tag_name ?? r.name ?? '',
date: (r.published_at ?? '').slice(0, 10),
body: r.body ?? ''
}))
.filter((r) => r.version)
} catch {
// try next source
}
}
return []
}
export function registerUpdateIpc(): void {
ipcMain.handle(Ipc.UPDATE_CHECK, handleCheck)
ipcMain.handle(Ipc.UPDATE_DOWNLOAD, handleDownload)
ipcMain.handle(Ipc.UPDATE_INSTALL, () => {
// The close interceptor (tray flow) would swallow this quit — mark first.
markQuitting()
autoUpdater.quitAndInstall()
})
ipcMain.handle(Ipc.UPDATE_CHANGELOG, fetchChangelog)
}
export function configureAutoUpdater(): void {
// Update checks only run in packaged builds; no-op during development.
if (!app.isPackaged) {
return
}
const url = process.env.OT_UPDATE_URL || DEFAULT_FEED_URL
const token = process.env.OT_UPDATE_TOKEN
autoUpdater.setFeedURL({
provider: 'generic',
url,
...(token ? { requestHeaders: { Authorization: `token ${token}` } } : {})
})
useFeed('gitea')
wireEvents()
autoUpdater.logger = console
autoUpdater.autoDownload = true
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true
// Check shortly after startup; log errors without crashing the app.
// Startup check only when the user left it enabled (设置 → 关于).
if (loadSettings().system.autoCheckUpdate === false) return
const timer = setTimeout(() => {
autoUpdater
.checkForUpdatesAndNotify()
.catch((err) => console.warn('[updater] checkForUpdates failed:', err))
void handleCheck()
}, 5000)
timer.unref?.()
}
/** Public entry point for later UI wiring (not connected this milestone). */
export function checkForUpdates(): Promise<void> {
if (!app.isPackaged) {
return Promise.resolve()
}
return autoUpdater
.checkForUpdates()
.then(() => {})
.catch((err) => {
console.warn('[updater] checkForUpdates failed:', err)
})
}
+16
View File
@@ -0,0 +1,16 @@
import { BrowserWindow } from 'electron'
import type { AppSettings } from '@shared/settings'
import { getThemeById } from '@shared/theme'
/** Title bar overlay + window background follow the active terminal theme. */
export function applyWindowChrome(settings: AppSettings): void {
const theme = getThemeById(settings.terminal.themeId, settings.customThemes)
const bg = theme.colors.background
const fg = theme.colors.foreground
for (const win of BrowserWindow.getAllWindows()) {
win.setBackgroundColor(bg)
if (process.platform === 'win32') {
win.setTitleBarOverlay({ color: bg, symbolColor: fg, height: 36 })
}
}
}
+12 -2
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, ZmodemOfferEvent, ZmodemResponse, ZmodemDoneEvent } from '../shared/ipc'
import type { LayoutMeta, PtyCreateOptions, PtyDataEvent, PtyExitEvent, ReleaseNote, UpdateState, ZmodemOfferEvent, ZmodemResponse, ZmodemDoneEvent } from '../shared/ipc'
import type {
HostKeyAction,
HostKeyPromptEvent,
@@ -117,7 +117,17 @@ const api: AppApi = {
listLayouts: () => ipcRenderer.invoke(Ipc.LAYOUTS_LIST),
getLayout: (id: string) => ipcRenderer.invoke(Ipc.LAYOUTS_GET, id),
saveLayout: (meta: LayoutMeta, json: string) => ipcRenderer.invoke(Ipc.LAYOUTS_SAVE, meta, json),
deleteLayout: (id: string) => ipcRenderer.invoke(Ipc.LAYOUTS_DELETE, id)
deleteLayout: (id: string) => ipcRenderer.invoke(Ipc.LAYOUTS_DELETE, id),
updateCheck: () => ipcRenderer.invoke(Ipc.UPDATE_CHECK),
updateDownload: () => ipcRenderer.invoke(Ipc.UPDATE_DOWNLOAD),
updateInstall: () => ipcRenderer.send(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)
}
}
contextBridge.exposeInMainWorld('api', api)
+33
View File
@@ -3,17 +3,50 @@ import Workspace from '@renderer/workspace/Workspace'
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. */
function TitleBar(): React.JSX.Element {
const settings = useSettingsStore((s) => s.settings)
const theme = getThemeById(settings.terminal.themeId, settings.customThemes)
return (
<div
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>
)
}
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 />
<Workspace onOpenSettings={() => setSettingsOpen(true)} />
<SettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} />
<TransferPanel />
+1 -1
View File
@@ -346,7 +346,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 {
+38 -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,
@@ -66,3 +71,29 @@ button {
display: flex;
flex-direction: column;
}
/* ---- custom window title bar (native buttons via titleBarOverlay) ---- */
.app-titlebar {
flex: 0 0 36px;
display: flex;
align-items: center;
/* right padding clears the native min/max/close overlay buttons */
padding: 0 148px 0 14px;
-webkit-app-region: drag;
user-select: none;
font-size: 12px;
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 -9
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)
@@ -84,7 +86,12 @@ if (typeof window !== 'undefined' && !window.api) {
listLayouts: async () => [],
getLayout: async () => null,
saveLayout: async () => undefined,
deleteLayout: async () => undefined
deleteLayout: async () => undefined,
updateCheck: async () => ({ status: 'dev' as const, currentVersion: 'dev' }),
updateDownload: async () => undefined,
updateInstall: noop,
updateChangelog: async () => [],
onUpdateState: () => noop
}
;(window as unknown as { api: AppApi }).api = api
}
@@ -143,13 +150,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 />
@@ -157,5 +184,5 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<FontHotkeyListener />
</ErrorBoundary>
</AntdApp>
</ConfigProvider>
</ThemedConfigProvider>
)
+105
View File
@@ -0,0 +1,105 @@
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'
const STATUS_TEXT: Record<UpdateStatus, string> = {
idle: '点击检查更新',
checking: '正在检查更新…',
available: '发现新版本',
latest: '已经是最新版本',
downloading: '正在下载…',
downloaded: '下载完成,可重启安装',
error: '检查失败',
dev: '开发模式不支持更新检查'
}
/** 关于页:版本信息 + 更新检查/下载/安装 + 更新日志(国内源优先,GitHub 兜底)。 */
export function AboutTab(): React.JSX.Element {
const [version, setVersion] = useState('')
const [state, setState] = useState<UpdateState | null>(null)
const [notes, setNotes] = useState<ReleaseNote[]>([])
const updateSystem = useSettingsStore((s) => s.updateSystem)
const autoCheck = useSettingsStore((s) => s.settings.system.autoCheckUpdate !== false)
useEffect(() => {
void window.api.appInfo().then((i: AppInfo) => setVersion(i.appVersion))
void window.api.updateChangelog().then(setNotes).catch(() => undefined)
return window.api.onUpdateState(setState)
}, [])
const status = state?.status ?? 'idle'
const statusText = STATUS_TEXT[status] + (state?.version ? ` v${state.version}` : '')
return (
<div className="about-tab">
<div className="about-header">
<div className="about-logo">OT</div>
<div>
<div className="about-name">OpenTerminal</div>
<div className="about-version">
当前版本: {version || '…'}{' '}
<span className={'about-status' + (status === 'available' ? ' is-new' : '')}>
{statusText}
</span>
{state?.error && <div className="about-error">{state.error}</div>}
</div>
</div>
</div>
<div className="about-controls">
<Select
value="stable"
options={[{ value: 'stable', label: '稳定更新通道' }]}
style={{ width: 150 }}
/>
<label className="about-autocheck">
<Switch
size="small"
checked={autoCheck}
onChange={(checked) => void updateSystem({ autoCheckUpdate: checked })}
/>
启动时自动检查
</label>
{status === 'available' && (
<Button type="primary" onClick={() => void window.api.updateDownload()}>
下载更新{state?.feed === 'github' ? '(GitHub 源)' : ''}
</Button>
)}
{status === 'downloaded' && (
<Button type="primary" onClick={() => window.api.updateInstall()}>
重启安装
</Button>
)}
{(status === 'idle' || status === 'latest' || status === 'error') && (
<Button type="primary" onClick={() => void window.api.updateCheck()}>
检查更新
</Button>
)}
{status === 'checking' && (
<Button type="primary" loading disabled>
检查中
</Button>
)}
</div>
{status === 'downloading' && (
<Progress percent={state?.percent ?? 0} size="small" status="active" />
)}
<div className="about-changelog">
<div className="about-changelog-title">更新日志</div>
{notes.length === 0 && <div className="about-changelog-empty">暂无更新日志</div>}
{notes.map((n) => (
<div key={n.version} className="about-release">
<div className="about-release-head">
<Tag color="green">{n.version}</Tag>
<span className="about-release-date">{n.date}</span>
</div>
<div className="about-release-body">{n.body}</div>
</div>
))}
</div>
</div>
)
}
+3 -1
View File
@@ -6,6 +6,7 @@ import { useSettingsStore } from './store'
import { CursorSettingsTab, FontSettingsTab, RenderSettingsTab, SystemSettingsTab } from './SettingsTabs'
import { ThemeSettingsTab } from './ThemeSettingsTab'
import { HighlightTab } from './HighlightTab'
import { AboutTab } from './AboutTab'
import { ThemeEditor } from '../theme/editor/ThemeEditor'
import './settings.css'
@@ -35,7 +36,8 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS
/>
)
},
{ key: 'system', label: '系统', children: <SystemSettingsTab /> }
{ key: 'system', label: '系统', children: <SystemSettingsTab /> },
{ key: 'about', label: '关于', children: <AboutTab /> }
]
const closeEditor = (): void => setEditorState((prev) => (prev.open ? { ...prev, open: false } : prev))
@@ -278,6 +278,16 @@ export function RenderSettingsTab(): React.JSX.Element {
/>
}
/>
<SettingRow
label="输入建议"
desc="输入时弹出命令建议;Tab 接受,回车始终直接执行"
control={
<Switch
checked={settings.terminal.suggestEnabled}
onChange={(checked) => void updateTerminal({ suggestEnabled: checked })}
/>
}
/>
</div>
)
}
@@ -307,6 +317,22 @@ export function SystemSettingsTab(): React.JSX.Element {
/>
}
/>
<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>
)
}
+128 -5
View File
@@ -5,13 +5,13 @@
/* ---------- 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);
}
.settings-footer {
@@ -116,7 +116,7 @@
margin-top: 14px;
border-radius: 6px;
padding: 12px 14px;
border: 1px solid #2d2d2d;
border: 1px solid var(--chrome-border);
overflow: hidden;
}
@@ -140,6 +140,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 +357,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;
}
@@ -343,4 +367,103 @@
line-height: 1.6;
overflow: hidden;
text-overflow: ellipsis;
}
}
/* ---- about / updater tab ---- */
.about-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 18px;
}
.about-logo {
width: 48px;
height: 48px;
border-radius: 12px;
background: var(--chrome-hover);
color: #3fd06a;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 18px;
}
.about-name {
font-size: 16px;
font-weight: 600;
}
.about-version {
font-size: 12px;
opacity: 0.7;
margin-top: 3px;
}
.about-status {
margin-left: 10px;
}
.about-status.is-new {
color: #3fd06a;
}
.about-error {
color: #f85149;
margin-top: 4px;
font-size: 12px;
word-break: break-all;
}
.about-controls {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 18px;
}
.about-autocheck {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
opacity: 0.85;
}
.about-changelog {
border-top: 1px solid rgba(255, 255, 255, 0.08);
padding-top: 12px;
}
.about-changelog-title {
font-weight: 600;
margin-bottom: 10px;
}
.about-changelog-empty {
opacity: 0.5;
font-size: 12px;
}
.about-release {
margin-bottom: 14px;
}
.about-release-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.about-release-date {
font-size: 12px;
opacity: 0.6;
}
.about-release-body {
font-size: 12px;
opacity: 0.85;
white-space: pre-wrap;
line-height: 1.6;
}
+137 -14
View File
@@ -6,7 +6,7 @@ import { FitAddon } from '@xterm/addon-fit'
import { WebglAddon } from '@xterm/addon-webgl'
import { SearchAddon } from '@xterm/addon-search'
import '@xterm/xterm/css/xterm.css'
import { FolderOpenOutlined, PauseOutlined, SoundOutlined } from '@ant-design/icons'
import { ClearOutlined, CopyOutlined, FolderOpenOutlined, PauseOutlined, SearchOutlined, SelectOutlined, SnippetsOutlined, SoundOutlined } from '@ant-design/icons'
import { getThemeById } from '@shared/theme'
import type { CommandItem } from '@shared/commands'
import type { PtyDataEvent, PtyExitEvent } from '@shared/ipc'
@@ -44,6 +44,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
@@ -273,6 +280,10 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
const [searchOpen, setSearchOpen] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const [resultInfo, setResultInfo] = useState('')
// 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<string | null>(null)
const [dead, setDead] = useState(false)
const [exitCode, setExitCode] = useState(0)
@@ -332,6 +343,40 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
})
}, [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()
@@ -463,8 +508,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')) {
@@ -494,20 +538,27 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
moveSelection(-1)
return false
}
if (e.key === 'Tab' || e.key === 'Enter') {
// Tab and Enter both accept the highlighted suggestion: rewrite the
// whole line. Without the rewrite-on-Enter path, pressing Enter with
// the popup open submitted the raw pre-completion line (dropping the
// suggested text) — this keeps the keyboard affordances symmetric
// with the popup's Hint (↑/↓ 选择 · Tab 接受 · Esc 关闭).
if (e.key === 'Tab') {
// Tab accepts the highlighted suggestion: rewrite the whole line.
// Enter deliberately falls through — it must always execute the
// typed line (the old enter-accepts path resubmitted with the
// suggestion appended, e.g. `kimi` → `kimikimi`).
const selected = list[selIndexRef.current] ?? list[0]
if (selected) handleTab(selected)
return false
}
if (e.key === 'Enter') {
// Never let the popup swallow Enter: dismiss it and submit as typed.
suggestionsRef.current = []
selIndexRef.current = 0
setSuggestions([])
setSuggestionIndex(0)
return true
}
}
return true
},
[closeSearch, copySelection, pasteFromClipboard, moveSelection, handleTab]
[closeSearch, openSearch, copySelection, pasteFromClipboard, moveSelection, handleTab]
)
// ---- terminal instance: built once per session ----
@@ -523,7 +574,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,
@@ -617,7 +668,14 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
setSuggestionIndex(0)
} else if (step.line) {
ensureCompletionCache()
const list = buildSuggestions(completionCacheRef.current, step.line)
// Suggest only when enabled, and drop entries identical to the line
// already typed — a popup repeating the full input is pure noise.
const suggestOn = useSettingsStore.getState().settings.terminal.suggestEnabled
const list = suggestOn
? buildSuggestions(completionCacheRef.current, step.line).filter(
(s) => s.command !== step.line
)
: []
suggestionsRef.current = list
selIndexRef.current = Math.min(selIndexRef.current, Math.max(list.length - 1, 0))
if (list.length === 0) selIndexRef.current = 0
@@ -723,7 +781,7 @@ 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) }
term.options.theme = withChromeColors(getThemeById(tSettings.themeId, settings.customThemes).colors as ITheme)
scheduleFit()
}, [tSettings, settings.customThemes, scheduleFit])
@@ -813,8 +871,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}
+88 -7
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;
+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)
}
+78 -14
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { App, Button, Checkbox, Dropdown, Popover, Tooltip } from 'antd'
import { App, Button, Checkbox, Popover, Tooltip } from 'antd'
import type { MenuProps } from 'antd'
import {
CloudServerOutlined,
@@ -947,17 +947,7 @@ function IconRail({
/>
<div className="workspace-rail-divider" />
<RailButton title="新建终端" icon={<PlusOutlined />} onClick={onNewTerminal} />
{/* getPopupContainer=body: dockview's render overlay creates its own
stacking context (contain:layout paint + isolation), which would clip
the dropdown under the sidebar otherwise. */}
<Dropdown
menu={layoutMenu}
trigger={['click']}
placement="rightTop"
getPopupContainer={() => document.body}
>
<RailButton title="布局" icon={<LayoutOutlined />} />
</Dropdown>
<LayoutFlyout menu={layoutMenu} />
<div className="workspace-rail-divider" />
<ModeRailButtons />
<div className="workspace-rail-spacer" />
@@ -993,19 +983,93 @@ function ModeRailButtons(): React.JSX.Element {
)
}
/**
* Layout menu as an in-layout flyout panel. antd Dropdown portals to
* document.body, where dockview's global pointer handling races it: the menu
* painted over the rail and dismissed itself on open. This panel lives in the
* rail's own stacking context, opens to the right of the rail, and closes only
* on item pick, outside pointerdown, or Escape.
*/
function LayoutFlyout({ menu }: { menu: MenuProps }): React.JSX.Element {
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onPointerDown = (e: PointerEvent): void => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false)
}
const onKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('pointerdown', onPointerDown, true)
document.addEventListener('keydown', onKeyDown)
return (): void => {
document.removeEventListener('pointerdown', onPointerDown, true)
document.removeEventListener('keydown', onKeyDown)
}
}, [open])
const pick = (key: string): void => {
setOpen(false)
menu.onClick?.({ key, keyPath: [key] } as Parameters<NonNullable<MenuProps['onClick']>>[0])
}
return (
<div className="workspace-flyout-anchor" ref={rootRef}>
<Tooltip title={open ? '' : '布局'} placement="right">
<button
type="button"
className={'workspace-rail-btn' + (open ? ' is-active' : '')}
aria-label="布局"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<LayoutOutlined />
</button>
</Tooltip>
{open && (
<div className="workspace-flyout" role="menu">
{(menu.items ?? []).map((item, i) => {
if (!item) return null
if (typeof item === 'string' || (item as { type?: unknown }).type === 'divider') {
return <div key={`divider-${i}`} className="workspace-flyout-divider" />
}
const entry = item as { key?: React.Key; label?: ReactNode }
if (entry.key === undefined) return null
return (
<button
key={String(entry.key)}
type="button"
role="menuitem"
className="workspace-flyout-item"
onClick={() => pick(String(entry.key))}
>
{entry.label}
</button>
)
})}
</div>
)}
</div>
)
}
function RailButton({
title,
icon,
onClick,
active
active,
hideTooltip
}: {
title: string
icon: ReactNode
onClick?: () => void
active?: boolean
hideTooltip?: boolean
}): React.JSX.Element {
return (
<Tooltip title={title} placement="right">
<Tooltip title={hideTooltip ? '' : title} placement="right">
<button
type="button"
className={['workspace-rail-btn', active ? ' is-active' : ''].join('')}
+125 -51
View File
@@ -3,12 +3,16 @@
.workspace-root {
display: flex;
flex-direction: row;
height: 100%;
/* flex child of .app-root below the custom title bar */
flex: 1 1 auto;
min-height: 0;
background: var(--chrome-bg);
}
/* ---- left icon rail (XTerminal-style collapsed menu) ---- */
.workspace-rail {
position: relative;
z-index: 960;
display: flex;
flex-direction: column;
align-items: center;
@@ -51,14 +55,62 @@
/* ---- 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 {
flex: 1 1 auto;
}
/* ---- layout flyout (custom panel, dockview-safe) ---- */
.workspace-flyout-anchor {
position: relative;
display: flex;
}
.workspace-flyout {
position: absolute;
left: calc(100% + 6px);
top: 0;
z-index: 960;
min-width: 148px;
padding: 6px;
display: flex;
flex-direction: column;
gap: 2px;
background: #1f1f1f;
border: 1px solid var(--chrome-border);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
.workspace-flyout-item {
display: flex;
align-items: center;
height: 32px;
padding: 0 12px;
border: none;
border-radius: 5px;
background: transparent;
color: rgba(255, 255, 255, 0.85);
font-size: 13px;
text-align: left;
white-space: nowrap;
cursor: pointer;
}
.workspace-flyout-item:hover {
background: var(--chrome-hover);
color: #ffffff;
}
.workspace-flyout-divider {
height: 1px;
margin: 4px 6px;
background: var(--chrome-border);
}
.workspace-rail-busy {
width: 8px;
height: 8px;
@@ -121,40 +173,46 @@
/* 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: 3px 4px 0;
padding: 4px 5px 0;
box-sizing: border-box;
}
.workspace-dockview .dv-tab {
border-radius: 6px;
padding: 0 9px;
margin-right: 2px;
background: transparent;
color: #8a8a8a;
transition: background-color 0.12s ease, color 0.12s ease;
border-radius: 3px;
padding: 0 8px;
margin-right: 4px;
background: rgba(255, 255, 255, 0.04);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.10);
color: #98a2ab;
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
}
.workspace-dockview .dv-tab.dv-inactive-tab:hover {
background: rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.07);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.20);
color: #d0d0d0;
}
/* XTerminal-style active tab: green outline + tinted bg + green title. */
.workspace-dockview .dv-tab.dv-active-tab {
background: rgba(63, 185, 80, 0.08);
box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.7);
color: #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) 75%, transparent);
color: var(--tab-accent-fg, #3fd06a);
}
.workspace-dockview .dv-tab.dv-active-tab:hover {
color: #46c96a;
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 {
@@ -162,18 +220,25 @@
}
.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: 18px;
height: 18px;
opacity: 0.6;
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;
}
.workspace-dockview .dv-tab .workspace-terminal-tab-close:hover {
opacity: 1;
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 ---- */
@@ -315,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;
}
@@ -335,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;
@@ -345,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 ----
@@ -361,21 +426,24 @@ body.ssh-resizing .ssh-bottom-divider {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
width: 22px;
height: 22px;
margin: 0 6px;
padding: 0;
border: none;
border-radius: 3px;
border-radius: 6px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
background: transparent;
color: #8a8a8a;
font-size: 11px;
color: #98a2ab;
font-size: 10px;
cursor: pointer;
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
}
.workspace-tabbar-add:hover {
background: var(--dv-icon-hover-background-color, rgba(90, 93, 94, 0.31));
color: #ffffff;
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) ---- */
@@ -390,32 +458,36 @@ body.ssh-resizing .ssh-bottom-divider {
align-items: center;
justify-content: center;
gap: 3px;
width: 24px;
height: 24px;
width: 22px;
height: 22px;
margin: 0 2px;
padding: 0;
border: none;
border-radius: 3px;
border-radius: 6px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
background: transparent;
color: #8a8a8a;
font-size: 11px;
color: #98a2ab;
font-size: 10px;
cursor: pointer;
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
}
.workspace-tabbar-btn2:hover {
background: var(--dv-icon-hover-background-color, rgba(90, 93, 94, 0.31));
background: rgba(255, 255, 255, 0.07);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.24);
color: #ffffff;
}
.workspace-tabbar-btn2.is-active {
color: #3fb950;
background: rgba(63, 185, 80, 0.12);
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;
}
@@ -496,9 +568,9 @@ 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;
}
@@ -662,7 +734,7 @@ body.ssh-resizing .ssh-bottom-divider {
}
.workspace-terminal-tab-close {
padding: 4px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
@@ -671,8 +743,10 @@ body.ssh-resizing .ssh-bottom-divider {
background: none;
color: inherit;
font: inherit;
font-size: 10px;
line-height: 1;
cursor: pointer;
border-radius: 2px;
border-radius: 4px;
}
.workspace-terminal-tab-close:hover {
+8
View File
@@ -8,6 +8,7 @@ import type {
SessionOpenResult
} from './ipc'
import type { AppSettings } from './settings'
import type { ReleaseNote, UpdateState } from './ipc'
import type {
HostKeyAction,
HostKeyPromptEvent,
@@ -105,4 +106,11 @@ export interface AppApi {
/** json = serialized dockview layout (DockviewApi.toJSON()) */
saveLayout(meta: LayoutMeta, json: string): Promise<void>
deleteLayout(id: string): Promise<void>
// ---- updater (domestic feed first, GitHub fallback) ----
updateCheck(): Promise<UpdateState>
updateDownload(): Promise<void>
updateInstall(): void
updateChangelog(): Promise<ReleaseNote[]>
onUpdateState(cb: (s: UpdateState) => void): () => void
}
+40 -1
View File
@@ -90,7 +90,15 @@ export const Ipc = {
ZMODEM_DONE: 'zmodem:done',
// ---- misc ----
APP_INFO: 'app:info'
APP_INFO: 'app:info',
// ---- updater (check/download/install + state broadcast) ----
UPDATE_CHECK: 'update:check',
UPDATE_DOWNLOAD: 'update:download',
UPDATE_INSTALL: 'update:install',
UPDATE_CHANGELOG: 'update:changelog',
/** main -> renderer broadcast: UpdateState */
UPDATE_STATE: 'update:state'
} as const
export interface PtyCreateOptions {
@@ -122,6 +130,37 @@ export interface AppInfo {
homeDir: string
}
// ---- updater ----
export type UpdateStatus =
| 'idle'
| 'checking'
| 'available'
| 'latest'
| 'downloading'
| 'downloaded'
| 'error'
/** dev builds have no update channel */
| 'dev'
export interface UpdateState {
status: UpdateStatus
/** running app version */
currentVersion: string
/** newer version when available/downloading/downloaded */
version?: string
/** 0-100 while downloading */
percent?: number
error?: string
/** which feed served the last check: domestic Gitea first, GitHub fallback */
feed?: 'gitea' | 'github'
}
export interface ReleaseNote {
version: string
date: string
body: string
}
export interface LayoutMeta {
id: string
name: string
+13 -3
View File
@@ -34,6 +34,10 @@ export interface TerminalSettings {
copyOnSelect: boolean
/** confirm before pasting multi-line / suspicious text */
pasteRiskConfirm: boolean
/** inline command suggestion popup while typing (history + library) */
suggestEnabled: boolean
/** accent color for the active tab outline, rail indicator and SSH badges */
tabAccentColor: string
}
export interface SystemSettings {
@@ -43,6 +47,10 @@ export interface SystemSettings {
preventSleep: boolean
/** Electron accelerator that toggles window visibility; '' = disabled (M6) */
globalShowHide?: string
/** close-button behavior: prompt every time / hide to tray / quit directly */
closeAction?: 'ask' | 'tray' | 'exit'
/** check for updates shortly after startup (manual check always available) */
autoCheckUpdate?: boolean
}
export interface AppSettings {
@@ -89,13 +97,15 @@ 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
pasteRiskConfirm: true,
suggestEnabled: true,
tabAccentColor: '#3fb950'
},
customThemes: [],
highlightRules: DEFAULT_HIGHLIGHT_RULES,
system: { launchAtLogin: false, preventSleep: false, globalShowHide: '' }
system: { launchAtLogin: false, preventSleep: false, globalShowHide: '', closeAction: 'tray', autoCheckUpdate: true }
}
-269
View File
@@ -1,269 +0,0 @@
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __commonJS = (cb, mod) => function __require() {
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// tests/electron-stub.cjs
var require_electron_stub = __commonJS({
"tests/electron-stub.cjs"(exports2, module2) {
module2.exports = {
BrowserWindow: {
getAllWindows: () => []
},
app: {},
ipcMain: {},
safeStorage: {
isEncryptionAvailable: () => false
}
};
}
});
// src/main/commands.ts
var commands_exports = {};
__export(commands_exports, {
CommandsStore: () => CommandsStore,
defaultCommandsPath: () => defaultCommandsPath
});
module.exports = __toCommonJS(commands_exports);
var import_electron = __toESM(require_electron_stub());
var import_crypto = require("crypto");
var import_fs = require("fs");
var import_promises = require("fs/promises");
var import_path = require("path");
var HISTORY_CAP = 500;
function emptyFile() {
return { history: [], library: [] };
}
function defaultCommandsPath(userDataPath) {
return userDataPath;
}
function two(n) {
return String(n).padStart(2, "0");
}
function stamp(d = /* @__PURE__ */ new Date()) {
return `${d.getFullYear()}${two(d.getMonth() + 1)}${two(d.getDate())}-${two(d.getHours())}${two(d.getMinutes())}${two(d.getSeconds())}`;
}
var CommandsStore = class {
/** <userData>/commands.json */
file;
/** <userData>/logs */
logsDir;
/** <userData>/logs/index.json */
indexFile;
openPathImpl;
/** All known logs keyed by absolute file path (hydrated from index on boot). */
metasByFile = /* @__PURE__ */ new Map();
/** Logs currently accumulating output, keyed by sessionId. */
activeBySession = /* @__PURE__ */ new Map();
constructor(userData, openPathImpl) {
const ud = userData ?? import_electron.app.getPath("userData");
this.file = (0, import_path.join)(ud, "commands.json");
this.logsDir = (0, import_path.join)(ud, "logs");
this.indexFile = (0, import_path.join)(this.logsDir, "index.json");
this.openPathImpl = openPathImpl;
this.hydrateIndex();
}
// ---- commands file (history + library) -----------------------------------
loadCommands() {
try {
const raw = JSON.parse((0, import_fs.readFileSync)(this.file, "utf8"));
if (raw && typeof raw === "object" && Array.isArray(raw.history)) {
const data = raw;
return {
history: Array.isArray(data.history) ? data.history : [],
library: Array.isArray(data.library) ? data.library : []
};
}
} catch {
}
return emptyFile();
}
saveCommands(data) {
(0, import_fs.mkdirSync)((0, import_path.join)(this.file, ".."), { recursive: true });
(0, import_fs.writeFileSync)(this.file, JSON.stringify(data, null, 2), "utf8");
}
/** Record one executed command: trim; dedupe vs newest; move to front; cap. */
recordCommand(cmd) {
const trimmed = cmd.trim();
if (!trimmed) return;
const data = this.loadCommands();
const top = data.history[0];
if (top && top.command === trimmed) {
top.lastUsedAt = Date.now();
} else {
data.history.unshift({
id: (0, import_crypto.randomUUID)(),
command: trimmed,
createdAt: Date.now(),
lastUsedAt: Date.now()
});
if (data.history.length > HISTORY_CAP) data.history.length = HISTORY_CAP;
}
this.saveCommands(data);
}
/** History sorted by most recently used. */
listHistory() {
return this.loadCommands().history.slice().sort((a, b) => (b.lastUsedAt ?? 0) - (a.lastUsedAt ?? 0));
}
clearHistory() {
const data = this.loadCommands();
data.history = [];
this.saveCommands(data);
}
/** Library sorted newest first. */
listLibrary() {
return this.loadCommands().library.slice().sort((a, b) => b.createdAt - a.createdAt);
}
/** Insert (no id) or update (has id); returns the stored entry. */
saveLibraryItem(item) {
const data = this.loadCommands();
let stored;
if (typeof item.id === "string" && item.id.length > 0) {
stored = data.library.find((x) => x.id === item.id);
}
if (stored) {
stored.name = item.name;
stored.command = item.command;
stored.note = item.note;
stored.group = item.group;
stored.params = item.params;
} else {
stored = {
id: item.id && item.id.length > 0 ? item.id : (0, import_crypto.randomUUID)(),
name: item.name,
command: item.command,
note: item.note,
group: item.group,
params: item.params,
createdAt: Date.now()
};
data.library.push(stored);
}
this.saveCommands(data);
return stored;
}
deleteLibraryItem(id) {
const data = this.loadCommands();
const next = data.library.filter((x) => x.id !== id);
if (next.length < data.library.length) {
data.library = next;
this.saveCommands(data);
}
}
// ---- session logs -----------------------------------------------------------
hydrateIndex() {
try {
if (!(0, import_fs.existsSync)(this.indexFile)) return;
const raw = JSON.parse((0, import_fs.readFileSync)(this.indexFile, "utf8"));
if (!Array.isArray(raw)) return;
for (const x of raw) {
if (x && typeof x.sessionId === "string" && typeof x.file === "string") {
const meta = x;
this.metasByFile.set(meta.file, meta);
if (meta.endedAt === void 0) this.activeBySession.set(meta.sessionId, meta);
}
}
} catch {
}
}
/** Write the full log index so listSessionLogs survives restart. */
persistIndex() {
try {
(0, import_fs.mkdirSync)(this.logsDir, { recursive: true });
const all = Array.from(this.metasByFile.values()).sort(
(a, b) => b.startedAt - a.startedAt
);
(0, import_fs.writeFileSync)(this.indexFile, JSON.stringify(all, null, 2), "utf8");
} catch {
}
}
/** Finalize a log (set endedAt, drop from active, persist index). */
finishLog(meta) {
meta.endedAt = Date.now();
this.activeBySession.delete(meta.sessionId);
this.persistIndex();
}
/** Start logging a session; any prior active log for the id is stopped first. */
logStart(sessionId) {
const prev = this.activeBySession.get(sessionId);
if (prev) this.finishLog(prev);
(0, import_fs.mkdirSync)(this.logsDir, { recursive: true });
const fileName = `${stamp()}-${sessionId.slice(0, 8)}.log`;
const file = (0, import_path.join)(this.logsDir, fileName);
describeFile(file);
const meta = { sessionId, file, fileName, startedAt: Date.now() };
this.metasByFile.set(file, meta);
this.activeBySession.set(sessionId, meta);
this.persistIndex();
return meta;
}
/** Append output for an active session. No-op when not logging (post-stop safe). */
logWrite(sessionId, data) {
const meta = this.activeBySession.get(sessionId);
if (!meta) {
return;
}
(0, import_promises.appendFile)(meta.file, data, "utf8").catch(() => {
});
}
/** Stop logging a session, stamping endedAt into the registry + index. */
logStop(sessionId) {
const meta = this.activeBySession.get(sessionId);
if (!meta) return;
this.finishLog(meta);
}
/** All known session logs (including persisted ones), newest first. */
listSessionLogs() {
return Array.from(this.metasByFile.values()).sort((a, b) => b.startedAt - a.startedAt);
}
/** Reveal the logs directory in the OS file manager. */
openLogsDir() {
try {
(0, import_fs.mkdirSync)(this.logsDir, { recursive: true });
if (this.openPathImpl) {
void this.openPathImpl(this.logsDir);
} else {
void import_electron.shell.openPath(this.logsDir);
}
} catch {
}
}
};
function describeFile(p) {
try {
(0, import_fs.writeFileSync)(p, "", "utf8");
} catch {
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
CommandsStore,
defaultCommandsPath
});
+1 -1
View File
@@ -5,7 +5,7 @@
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2023"],
"types": ["node"],
"types": ["node", "electron-vite/node"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,