Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0de0eee58d | ||
|
|
f25a1627f3 | ||
|
|
a9acdbe500 |
No files matched your search
@@ -15,6 +15,9 @@ research/
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
# credentials (do not commit)
|
||||
.env
|
||||
|
||||
# ts build info
|
||||
*.tsbuildinfo
|
||||
.sftp-test-*.bin
|
||||
@@ -25,3 +28,7 @@ tests/.session-e2e.cjs
|
||||
tests/.hl-split-smoke.cjs
|
||||
tests/.zmodem-e2e.cjs
|
||||
release/
|
||||
|
||||
# stray local test artifacts
|
||||
.min-test.bin
|
||||
.min2-test.bin
|
||||
@@ -1 +0,0 @@
|
||||
Line truncated
|
||||
@@ -1 +0,0 @@
|
||||
Line truncated
|
||||
@@ -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.
|
||||
@@ -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 |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
+24
-13
@@ -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
@@ -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(中英切换)、最近命令历史出现两条命令拼接的记录(广播键入时行捕获合并,低优先级修)
|
||||
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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.1",
|
||||
"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",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 131 B |
+53
-3
@@ -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') {
|
||||
@@ -55,6 +102,7 @@ app.whenReady().then(() => {
|
||||
// settings dialog is never opened this run.
|
||||
applyStartupSystemSettings(loadSettings())
|
||||
createWindow()
|
||||
initTray(showOrCreate)
|
||||
configureAutoUpdater()
|
||||
|
||||
app.on('activate', () => {
|
||||
@@ -63,6 +111,8 @@ app.whenReady().then(() => {
|
||||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
// Let window 'close' events pass through so teardown completes.
|
||||
markQuitting()
|
||||
killAllPtys()
|
||||
globalShortcut.unregisterAll()
|
||||
})
|
||||
|
||||
+9
-1
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 ?? 'ask'
|
||||
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 ?? 'ask'
|
||||
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)
|
||||
}
|
||||
+135
-36
@@ -1,54 +1,153 @@
|
||||
import { app } from 'electron'
|
||||
import { app, ipcMain, net } 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') {
|
||||
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 {
|
||||
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)
|
||||
useFeed('github')
|
||||
await autoUpdater.checkForUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
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) })
|
||||
}
|
||||
}
|
||||
|
||||
/** Gitea releases first, GitHub releases as fallback; both are public reads. */
|
||||
async function fetchChangelog(): Promise<ReleaseNote[]> {
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
@@ -3,6 +3,22 @@ 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'
|
||||
|
||||
/** 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 }}
|
||||
>
|
||||
<span className="app-titlebar-title">OpenTerminal</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App(): React.JSX.Element {
|
||||
const hydrate = useSettingsStore((s) => s.hydrate)
|
||||
@@ -14,6 +30,7 @@ export default function App(): React.JSX.Element {
|
||||
|
||||
return (
|
||||
<div className="app-root">
|
||||
<TitleBar />
|
||||
<Workspace onOpenSettings={() => setSettingsOpen(true)} />
|
||||
<SettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
<TransferPanel />
|
||||
|
||||
@@ -66,3 +66,21 @@ 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-title {
|
||||
opacity: 0.75;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -84,7 +84,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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -343,4 +343,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: #1e2531;
|
||||
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;
|
||||
}
|
||||
@@ -494,16 +494,23 @@ 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
|
||||
},
|
||||
@@ -617,7 +624,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
|
||||
|
||||
@@ -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('')}
|
||||
|
||||
@@ -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;
|
||||
@@ -59,6 +63,54 @@
|
||||
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;
|
||||
@@ -128,33 +180,36 @@
|
||||
|
||||
/* ---- tab polish: rounded pills, 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: 9px;
|
||||
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: rgba(63, 185, 80, 0.10);
|
||||
box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.75);
|
||||
color: #3fd06a;
|
||||
}
|
||||
|
||||
.workspace-dockview .dv-tab.dv-active-tab:hover {
|
||||
color: #46c96a;
|
||||
box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.95);
|
||||
color: #4ad476;
|
||||
}
|
||||
|
||||
.workspace-dockview .dv-tab .workspace-terminal-tab {
|
||||
@@ -166,14 +221,21 @@
|
||||
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: 17px;
|
||||
height: 17px;
|
||||
border-radius: 4px;
|
||||
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: #3fd06a;
|
||||
background: rgba(63, 185, 80, 0.08);
|
||||
box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.65);
|
||||
}
|
||||
|
||||
/* ---- empty state overlay ---- */
|
||||
@@ -361,21 +423,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: rgba(63, 185, 80, 0.08);
|
||||
box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.65);
|
||||
color: #3fd06a;
|
||||
}
|
||||
|
||||
/* ---- M6 tab-bar workspace actions (broadcast + quick-input) ---- */
|
||||
@@ -390,26 +455,30 @@ 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;
|
||||
color: #3fd06a;
|
||||
background: rgba(63, 185, 80, 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.55);
|
||||
}
|
||||
|
||||
.workspace-tabbar-bcast-count {
|
||||
@@ -662,7 +731,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 +740,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,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
@@ -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
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface TerminalSettings {
|
||||
copyOnSelect: boolean
|
||||
/** confirm before pasting multi-line / suspicious text */
|
||||
pasteRiskConfirm: boolean
|
||||
/** inline command suggestion popup while typing (history + library) */
|
||||
suggestEnabled: boolean
|
||||
}
|
||||
|
||||
export interface SystemSettings {
|
||||
@@ -43,6 +45,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 {
|
||||
@@ -93,9 +99,10 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
||||
rendererMode: 'auto',
|
||||
autoWrap: true,
|
||||
copyOnSelect: false,
|
||||
pasteRiskConfirm: true
|
||||
pasteRiskConfirm: true,
|
||||
suggestEnabled: true
|
||||
},
|
||||
customThemes: [],
|
||||
highlightRules: DEFAULT_HIGHLIGHT_RULES,
|
||||
system: { launchAtLogin: false, preventSleep: false, globalShowHide: '' }
|
||||
system: { launchAtLogin: false, preventSleep: false, globalShowHide: '', closeAction: 'ask', autoCheckUpdate: true }
|
||||
}
|
||||
@@ -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
@@ -5,7 +5,7 @@
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"types": ["node", "electron-vite/node"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
Reference in new issue
Block a user