Compare commits

..
3 Commits
Author SHA1 Message Date
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
38 changed files with 974 additions and 414 deletions

No files matched your search

+7
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,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
View File
@@ -1 +0,0 @@
Line truncated
-1
View File
@@ -1 +0,0 @@
Line truncated
+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.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
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') {
@@ -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
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 ?? '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
View File
@@ -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)
})
}
+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)
+17
View File
@@ -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 />
+18
View File
@@ -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;
}
+6 -1
View File
@@ -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
}
+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>
)
}
+100 -1
View File
@@ -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;
}
+21 -7
View File
@@ -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
+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('')}
+103 -32
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;
@@ -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
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
+9 -2
View File
@@ -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 }
}
-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,