Compare commits

..
19 Commits
Author SHA1 Message Date
954801926@qq.com d3a628f177 feat(M8): workspace isolation + SSH bottom file panel + context menu + monitor polish
- dual dockview workspaces (terminal/ssh), rail mode entries, sidebar filtered by mode
- SshBottomPanel: file browser below terminal, draggable divider; sftp.css finally imported (was orphaned since M4)
- file context menu: refresh/open/download/upload/rename/mkdir/copy-path/permissions/delete
- monitor restyle: memory amber bar, disk rows, net rx/tx inline (toFixed 4)
- completion fixes: Enter accepts; char-loss fixed by full-line rewrite (Ctrl+U) + echo-suppress ref
- chmod no-op fix: bitsToOctal concatenated decimal (329) instead of octal (511); chown only on change
- popups (layout menu/broadcast popover/file context menu) mount to body - dockview render overlay isolation clipped them under the sidebar
- rail slimmed: split + template actions folded into layout menu; log toolbar hover-only
2026-09-07 02:39:33 +08:00
954801926@qq.com 54c803091c docs: M6/M7 delivered — roadmap + state snapshot (release v0.7.0 published) 2026-09-06 21:03:20 +08:00
954801926@qq.com 2a32ee910a feat(M7): electron-builder packaging (MSI + NSIS) + electron-updater
- electron-builder.yml: msi(fixed upgradeCode)+nsis x64, asarUnpack node-pty/ssh2, npmRebuild off, publish generic for latest.yml
- updater: packaged-only, OT_UPDATE_URL/OT_UPDATE_TOKEN env, ready+5s check, warn-only failures; auto-update channel is NSIS (MSI not supported by electron-updater)
- scripts dist/dist:dir; win-unpacked smoke verified (native modules load)
2026-09-06 20:48:17 +08:00
954801926@qq.com fa83097528 chore: gitignore esbuild test bundles and release dir 2026-09-06 20:16:23 +08:00
954801926@qq.com 0663e5affa feat(M6): broadcast input + main-process ZMODEM + quick input panel + shortcuts
- broadcast: session target picker popover, fan-out write path, <2 targets auto-disable, per-tab target dots
- zmodem: main-process engine over ssh sessions (rz send / sz receive), progress reuses transfer panel, offer 120s / stall 90s watchdogs, e2e test with real zmodem.js peer (both directions)
- quick input panel: bottom-right overlay (command line + auto-\r + library + recent), broadcast-aware send
- shortcuts: Ctrl+=/-/0 font size (persisted), globalShowHide accelerator via globalShortcut, Ctrl+PgUp/PgDn terminal cycling
- contracts: ZMODEM_OFFER/RESPOND/DONE channels, SystemSettings.globalShowHide, TransferKind zmodem-upload/download
- review fixes: xterm-helper-textarea font hotkey pass-through, zmodem error-vs-cancelled progress state, offer timer clear, receive stream destroy
2026-09-06 20:15:49 +08:00
954801926@qq.com 0953ac4f41 docs: context compression — STATE.md snapshot (plan unchanged, M3-M5 done, M6 next) 2026-09-06 17:32:05 +08:00
954801926@qq.com 90058eb69f docs: M3/M4/M5 delivered 2026-09-06 17:29:23 +08:00
954801926@qq.com 7467433a61 feat(M5): command history/library + completion + session logs
- main/commands.ts: JSON-backed history (500 cap, dedupe), command library
  CRUD, per-session plain-text output logs (start/stop/index/open dir)
- pty data path feeds the session logger via injected hooks
- TerminalView: input line capture -> history recording, prefix completion
  popup (history + library, Tab/Arrows/Esc), session log toggle buttons
- Sidebar: 命令 section — history (click to run, clear) and library
  (add/delete, click to run into active session)
- tests: commands-store self-test ALL PASSED; full suite green
2026-09-06 17:28:15 +08:00
954801926@qq.com 7977c264be chore: ignore sftp test artifacts 2026-09-06 17:07:49 +08:00
954801926@qq.com 2849fc30dc feat(M4): file permissions/ownership + monitor charts
- SftpEntry carries uid/gid/mode (drwxr-xr-x style) from readdir attrs
- chmod/chown via SSH exec (JD sftp subsystem silently ignores SETSTAT)
- FilePanel: permission + owner columns, chmod/chown dialog (rwx grid +
  octal input + uid/gid)
- MonitorPanel: canvas sparkline charts — CPU history (60 samples, 3min)
  and network rx/tx rates, dpr-aware, no deps
- tests: sftp-chmod real-server verification (chmod 700/755 round trip)
2026-09-06 17:07:11 +08:00
954801926@qq.com 06fa4ca023 feat(M3): SSH productization — Terminal/SSH split + Wave-style server monitoring
- Sidebar split into 本地终端 / SSH 服务器 sections + recent sessions quick reconnect
- SSH tabs carry server badge (icon + green edge); local tabs unchanged
- SSH session panels mount a collapsible live monitor panel (CPU/mem/disk/net/load/uptime)
- src/main/sysinfo.ts polls remote via ssh2 exec (/proc + df), 3s interval,
  error-tolerant (3 consecutive failures auto-stop), auto-stops on session close
- tests: sysinfo-e2e (loopback exec with canned /proc output) — ALL PASSED
2026-09-06 13:45:52 +08:00
954801926@qq.com f97edbb016 docs: roadmap v2 — M3 SSH productization (Terminal/SSH split + Wave-style server monitoring), renumber SFTP to M4 2026-09-06 13:15:12 +08:00
954801926@qq.com 0be65370f6 fix: ssh connect flow never fired + 120s host-key prompt budget
- Workspace: saved-credential connections now call connect() directly;
  ConnectFlow only rendered a connecting spinner without ever firing
  openSession, leaving attempts hung forever (root cause of the stuck
  '连接中' modal)
- Host-key prompt timeout raised 30s -> 120s (a security decision needs
  time; 30s aborted while users compare fingerprints)
- main: unhandledRejection/uncaughtException loggers; ssh diagnostics on
  error paths
2026-09-06 13:08:47 +08:00
954801926@qq.com bf3b2a7c30 docs: drop AI assistant milestone (not needed) 2026-09-06 12:33:26 +08:00
954801926@qq.com 727ed93c01 docs: audit roadmap — mark early-delivered items, fix duplicated M3 row 2026-09-06 11:49:47 +08:00
954801926@qq.com 659a1ac695 docs: note i18n groundwork needed for UI language switching 2026-09-06 11:44:14 +08:00
954801926@qq.com 18506c926a feat: system settings tab — launch at login and prevent-sleep toggles
AppSettings.system persisted through the existing settings IPC; the main
process applies real side effects at startup and on every save via
app.setLoginItemSettings and powerSaveBlocker.
2026-09-06 11:43:40 +08:00
954801926@qq.com db0eae5efd fix: flush held text-run carry on a 16ms timer so shell prompts render immediately
The highlight stream holds a trailing text run for cross-chunk anchor
consistency; a bare shell prompt (plain text, no follow-up data) stayed
held until the next keypress. Now the carry auto-flushes within one frame.
2026-09-06 11:26:17 +08:00
954801926@qq.com 551723c2d4 OpenTerminal v0.2.1: terminal suite with SSH, split panes, themes and keyword highlighting
- Local terminal (node-pty) + SSH sessions (ssh2) with unified data plane
- dockview split panes, per-group '+' tab bar button, layout templates
- Session replay buffer (late-subscriber catch-up), host key pinning,
  credential encryption via safeStorage
- 12 builtin themes + custom theme editor, system font enumeration
- Keyword highlighting: regex rules with priority, ANSI truecolor injection,
  chunk-boundary-safe stream (carry-over of partial escape sequences)
- Dark antd theme app-wide, settings dialog redesign, error boundary
- Tests: ssh loopback, session e2e, highlight split-chunk regression
2026-09-06 11:19:08 +08:00
57 changed files with 540 additions and 2935 deletions

No files matched your search

-13
View File
@@ -15,9 +15,6 @@ research/
*.log
.DS_Store
# credentials (do not commit)
.env
# ts build info
*.tsbuildinfo
.sftp-test-*.bin
@@ -28,13 +25,3 @@ tests/.session-e2e.cjs
tests/.hl-split-smoke.cjs
tests/.zmodem-e2e.cjs
release/
# stray local test artifacts
.min-test.bin
.min2-test.bin
# release notes draft (consumed by scripts/release.cjs)
RELEASE_NOTES.md
tmp-test/
tmp-test*
+1
View File
@@ -0,0 +1 @@
Line truncated
+1
View File
@@ -0,0 +1 @@
Line truncated
-48
View File
@@ -1,48 +0,0 @@
# OpenTerminal 开发备忘
Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
## 常用命令
- 开发:`npm run dev`(主进程改动不热重建,需重启)
- 类型检查:`npx tsc --noEmit -p tsconfig.web.json`
- 打包:`npm run dist`,产物在 `release/`(msi + exe + latest.yml + blockmap)
- 国内网络需镜像:`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ npm run dist`
## 仓库与远程
- `origin` = Gitea `https://git.codingplan.site/admin/OpenTerminal.git`(主仓库 + 更新通道)
- `github` = `https://github.com/billowliu2/OpenTerminal.git`(镜像 + 备用更新源)
- ⚠️ Gitea 的 upload-pack 有故障,`git fetch origin` 会报 `bad pack header`;查询远端状态用 API 或 `git ls-remote`。推送正常。
- 2026-09:远端 main 旧历史(M3–M8,至 v0.8.0 d3a628f)被 force-push 覆盖为当前历史,旧提交仍由 tag v0.6.0 / v0.7.0 / v0.8.0 保留。
## 凭据
- `.env`(已 gitignore):`GIT_TOKEN`(Gitea)、`GH_TOKEN`(GitHub)等。git 推送通过 credential helper 使用该令牌。
## 发布新版本
1. `package.json` 版本号 +1,写 `RELEASE_NOTES.md`(仓库根,已 gitignore)
2. `npm run dist` 构建
3. `node scripts/release.cjs <版本号>`,例如 `node scripts/release.cjs 1.0.2`
- 依次完成:Gitea release(含 msi/exe 资产)→ Gitea 更新通道(`api/packages/admin/generic/openterminal-update/stable`,更新 latest.yml/blockmap/exe/release-notes.md)→ GitHub release
- 可用 `--skip-github` / `--skip-gitea` 跳过某步
- 访问 GitHub API 不通时,设 `HTTPS_PROXY=http://127.0.0.1:7897` 再走代理
4. 验证更新通道:`curl https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/latest.yml` 应返回新版本号
5. `git tag v<版本号>` 并推送两个远程
## 更新机制
- 检查更新:先 Gitea 更新通道(强制直连,不走系统代理),失败回退 GitHub(走系统代理)。 electron-updater 用独立 session(partition `electron-updater`),代理模式在 `useFeed` 里按源切换
- 更新日志:Gitea 仓库是私有的(匿名 API 404),改为从更新通道的 `release-notes.md` 读取(直连 session `openterminal-update-direct`),再回退 Gitea/GitHub releases API
- electron-updater 不支持 MSI 自动更新,自动更新只走 NSIS exe
## 主题机制
- 终端主题由 xterm 主题派生 UI 配色:`src/renderer/src/theme/chrome.ts` 的 `applyChromeTheme` 写入 `--chrome-bg/-bg-deep/-border/-hover` CSS 变量,antd token 在 `main.tsx` ThemedConfigProvider 派生
- 标签强调色:`settings.tabAccentColor`(默认 `#3fb950`),经 `--tab-accent` CSS 变量生效
## 终端尺寸同步
- `TerminalView.scheduleFit`:fit 后**去抖 100ms** 再把 cols/rows 发给 PTY,并跳过与上次相同的尺寸。每次 ResizeObserver 都戳 PTY 会让全屏 TUI(Claude Code 等)在最大化/还原的中间尺寸上反复重绘,留下重复帧
- 拖动窗口期间 xterm 网格立即更新,PTY 尺寸在停止后 100ms 生效
-21
View File
@@ -1,21 +0,0 @@
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.
+13 -28
View File
@@ -1,6 +1,8 @@
# OpenTerminal
开源的现代化终端应用:本地终端 + SSH 远程会话 + 分屏管理 + 主题/字体定制。Electron + React + TypeScript,从 0 到 1 独立开发。
一个受 XTerminal 启发的开源终端应用:本地终端 + 分屏管理 + 字体/主题定制。Electron + React + TypeScript。
对应调研背景与逆向分析记录见 `research/`(XTerminal 5.7.17 解包产物与功能清单)。
## 快速开始
@@ -13,46 +15,34 @@ 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 自动探测),自动声明 256 色与真彩能力
- 本地终端(node-pty,Windows PowerShell / macOS zsh / Linux bash 自动探测)
- xterm.js 渲染:WebGL 高性能模式,加载失败/上下文丢失自动降级 DOM
- 会话生命周期:退出遮罩显示退出码;窗口关闭时回收全部会话
- 会话生命周期:退出遮罩显示退出码、关闭按钮;窗口关闭时回收全部会话
- 搜索(Ctrl+F 内嵌搜索条、全部高亮、n/N 计数)
- 剪贴板(Ctrl+Shift+C/V、可选选中即复制)
- 粘贴风险确认(多行/长文本需手动确认,可关闭)
- 字体/字号/字重/字距/行高/回滚行数/光标样式实时生效
- 关键词高亮:11 条内置规则(权限、路径、错误状态、IP、URL 等),支持自定义正则规则
**SSH 远程会话**
**SSH 远程会话(M2)**
- ssh2 实现:密码 / 私钥(文件路径或粘贴内容)/ SSH Agent 三种认证
- 连接管理器:左侧书签栏(分组手风琴)、双击连接、新建/编辑/删除对话框
- 凭据安全:密码/私钥/口令经 Electron safeStorage(DPAPI)加密后落盘,渲染层永远接触不到明文密钥;支持「连接时询问密码」模式
- 主机指纹校验(known hosts 固定):首次连接确认、指纹变更红色告警、30s 无响应自动拒绝
- 服务器硬件监控:CPU / 内存 / 磁盘 / 网络实时面板(无需在服务器部署任何脚本)
- SFTP 文件管理:双栏浏览、拖拽传输、队列/进度/取消、权限与属主修改
- ZMODEM(rz/sz)文件传输
- 连接中状态展示、失败原因提示、lastConnectedAt 记录
- 会话数据面与本地终端完全统一(写/缩放/关闭/退出事件同通道)
**分屏与布局**
- dockview 标签系统:水平/垂直分屏、拖拽重排、标签右键菜单
- dockview 标签系统:水平/垂直分屏、拖拽重排、标签右键菜单(关闭/关闭其他/关闭右侧)
- 布局模板:把当前分屏保存为模板、一键应用(自动重建会话)、可删除
- Terminal / SSH 双工作区,互不混排
- 空态引导页
**效率工具**
- 广播输入:勾选 ≥2 个终端后同步键入,标签带广播标记
- 快捷输入面板:右下角浮层,命令历史 + 命令库(支持 `{{param}}` 变量)+ 一键执行
- 输入建议:历史 + 命令库来源,Tab 接受、回车始终直接执行(可在设置中整体关闭)
- 会话日志:手动启停、纯文本落盘
- 快捷键:Ctrl+=/-/0 字号、Ctrl+PgUp/PgDn 切换面板、全局唤起/隐藏(可配)
**外观与窗口**
- 12 款内置主题(Dracula / One Half / Solarized / Gruvbox / Nord / Monokai / GitHub 等)
**设置与主题**
- 12 款内置主题(Dracula / One Half / Solarized / Gruvbox / Nord / Monokai / GitHub / Afterglow / Material 等)
- 自定义主题编辑器:16 色 ANSI 调色板 + 前景/背景/光标/选区色,实时预览
- 窗口标题栏与背景跟随终端主题
- 系统托盘:关闭按钮可设「每次询问 / 最小化到托盘 / 直接退出」,托盘菜单可切换
- 单实例运行:重复启动唤出已有窗口
- 系统字体枚举(font-list)+ 内置默认字体栈
- 设置持久化(userData/settings.json,原子写入)+ 多窗口实时同步
**测试**
@@ -72,7 +62,6 @@ 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/
@@ -90,7 +79,3 @@ src/
- 布局模板:`%APPDATA%/OpenTerminal/layouts/*.json`
- SSH 连接书签:`%APPDATA%/OpenTerminal/connections.json`(密钥字段 DPAPI 加密)
- 主机指纹:`%APPDATA%/OpenTerminal/ssh_known_hosts.json`
## License
[MIT](LICENSE)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

+13 -24
View File
@@ -1,6 +1,6 @@
# OpenTerminal 长期路线图
目标:以开源组件组合构建现代化的本地 + SSH 终端应用,按里程碑推进,每个里程碑可独立交付使用。
目标:以开源组件组合复刻 XTerminal 的核心终端能力(调研依据见 `research/i18n-extracted.txt` 功能清单),按里程碑推进,每个里程碑可独立交付使用。
## 里程碑总览
@@ -15,7 +15,6 @@
| M6 | 广播输入 + ZMODEM + 快捷输入面板 + 快捷键系统 | ✅ 已完成(v0.6) |
| M7 | 打包分发(MSI + NSIS + 自动更新) | ✅ 已完成(v0.7,Release 已发布) |
| M8 | 工作区分离(Terminal/SSH 双工作区)+ SSH 底部文件面板 + 右键菜单 + 监控美化 + 补全修复 | ✅ 已完成(v0.8) |
| M9 | 体验打磨:系统托盘 + 关闭行为 + 单实例 + 主题联动标题栏 + 布局菜单重做 + 补全交互修正 + 应用图标 | ✅ 已完成 |
## M2 — SSH 远程会话 ✅
@@ -41,17 +40,17 @@
### B. 服务器硬件监控(参考 Wave Terminal 的 sysinfo 块)
技术可行性:✅ 高,零部署方案已在真实服务器上验证。
技术可行性:✅ 高。XTerminal 逆向已证实路线 1 的完整链路(ensureSetupServerStatusFiles / getServerStatsByQuery / getServerProcessList)。
- **零部署路线**: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 列表
- **路线 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 `conn.exec` → stdout JSON → 主进程解析 → `STATS:{id, stats}` 广播 → 渲染层
- [ ] `src/main/sysinfo.ts`:按会话轮询(默认 3s,可配置),会话关闭/断线自动停止;解析容错
- [ ] 契约:`shared/sysinfo.ts`(SysinfoSample + STATS 通道 + 面板开关事件)
- [ ] UI:SSH 会话右侧可折叠监控面板——CPU 多核条形、内存/磁盘用量条、网络上下行、负载/运行时间
- [ ] (增强路线)进程 Top 列表 + 结束进程
- [ ] (路线 1)进程 Top 列表 + 结束进程
- 验收:连接后 ≤3s 看到实时 CPU/内存;断开会话监控自动停止;服务器跑 `stress` 曲线明显上升
## M4 — SFTP 文件管理 ✅
@@ -65,10 +64,10 @@
- [ ] 命令历史(sqlite + better-sqlite3,按会话去重)
- [ ] 命令库(分组 + 参数变量 `{{param}}` + 一键运行/粘贴)
- [ ] 终端补全:历史 + 命令库 + 内置命令表(内置命令文档可从 tldr 页面生成)
- [ ] 终端补全:历史 + 命令库 + 内置命令表(XTerminal 内置 607 个 Linux 命令文档,可从 tldr 页面生成)
- [ ] 会话日志:手动启停、纯文本落盘、断线暂停指示
- [x] ~~关键词高亮~~(✅ 已提前完成:highlightEngine 流式 ANSI 注入 + 11 条预设 + 设置页管理,见 M2.5)
- 验收:补全 ↑↓ 选择 / Tab 接受 / Esc 关闭 / 回车直接执行,交互流畅
- 验收:补全 ↑↓/Tab/Esc 交互与 XTerminal 一致
## M6 — 多终端协同 ✅
@@ -83,25 +82,15 @@
- [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 可覆盖)
- [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 程序不再降级配色
- [ ] 代码签名(可选,未做);应用图标(默认 Electron 图标,MSI 快捷方式暂禁用待图标补齐)
## 工程约定(贯穿各阶段)
1. **契约先行**:新增功能先在 `src/shared/` 定义通道与模型,再实现两端
2. **原生依赖**:优先选带预编译二进制的包(@lydell/node-pty 模式),避免用户机器装构建链
3. **测试**:main 进程服务(pty/ssh/sftp)用 vitest 单测;渲染层交互靠 dev 手测
4. **里程碑验收后打 git tag**
3. **渲染层加密不可取**:不做代码混淆/加密(XTerminal 加密渲染层的做法牺牲了可调试性,收益仅是防抄)
4. **测试**:main 进程服务(pty/ssh/sftp)用 vitest 单测;渲染层交互靠 dev 手测
5. **里程碑验收后打 git tag**(引入 git 后)
## 已知待办(M1 遗留)
@@ -110,7 +99,7 @@
- [x] ~~首启动空屏~~(v0.2.1:启动自动打开一个终端)
- [ ] autoWrap=false 的固定列宽模式(xterm.js 无原生 wrapMode,需自定义列数控制)
- [ ] 终端标题跟随 OSC 序列(窗格标题显示当前目录/命令)
- [ ] 内置等宽字体打包(OFL 许可:JetBrains Mono / Fira Code 等)
- [ ] rendererMode webgl 的终端数量上限自动降级(大量终端实例时回退 DOM 渲染)
- [ ] 内置等宽字体打包(OFL 许可:JetBrains Mono / Fira Code 等,XTerminal 内置 19 款可作清单参考)
- [ ] rendererMode webgl 的终端数量上限自动降级(XTerminal:7 个后强制 DOM)
- [ ] 会话数据在面板间拖拽迁移(dockview 原生支持面板拖动,已具备)
- [ ] 界面语言切换(中/英):需先把全应用文案抽为 i18n 资源目录(一次完整重构),设置页预留「系统」分区
+3 -5
View File
@@ -4,8 +4,7 @@
## 当前版本与仓库
- v0.1.0(版本重置:作为全新开源项目从 0.1.0 起步),远程 `git.codingplan.site/admin/OpenTerminal.git`(国内仓)+ `github.com/billowliu2/OpenTerminal.git`(GitHub 镜像仓);凭据存于 `.env`(已 git 忽略),凭据助手按 host 自动读取
- 开源协议:MIT(LICENSE)
- v0.8.0,远程 `git.codingplan.site/admin/OpenTerminal.git`(令牌已存于 .git/config)
- 更新通道 = `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 再派两端
@@ -23,7 +22,6 @@
| M6 广播输入+ZMODEM+快捷输入面板+快捷键系统 | ✅(v0.6.0,UI 活体验收;ZMODEM 双向 e2e) |
| M7 打包分发(MSI+NSIS+自动更新) | ✅(v0.7.0 已发布 Release) |
| M8 工作区分离+底部文件面板+右键菜单+监控美化+补全修复 | ✅(v0.8.0) |
| M9 体验打磨(托盘+关闭行为+单实例+主题联动标题栏+布局菜单重做+补全修正+图标) | ✅ |
## M8 实现要点(v0.8.0,已落地)
@@ -79,7 +77,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 无图标会链接失败**~~(已修复:build/icon.png 由程序生成,electron-builder 自动转 ico,MSI/NSIS 快捷方式已启用)
7. **MSI 无图标会链接失败**(WiX LGHT0094:Shortcut 引用不存在的 Icon)→ electron-builder.yml 已设 msi createDesktop/StartMenuShortcut:false;补 build/icon.ico 后可恢复
8. **electron-updater 不支持 MSI 自动更新** → 更新通道走 NSIS exe;MSI 仅分发
9. **命令库测试时间戳竞态**:saveLibraryItem 连续保存同一毫秒 createdAt 相同排序不稳 → 测试保存间 wait(2)
10. **JD 服务器测试凭据已过期**;凭据一律环境变量且不落盘
@@ -92,6 +90,6 @@ node tests/zmodem-e2e.mjs
## 待办(按优先级)
1. ~~应用图标~~(已完成:build/icon.png,程序生成的原创图标)
1. 应用图标(build/icon.ico)——MSI 快捷方式随图标恢复
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}
# 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
# 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
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.8.0",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-terminal",
"version": "0.8.0",
"version": "0.1.0",
"dependencies": {
"@lydell/node-pty": "^1.2.0-beta.15",
"@xterm/addon-fit": "^0.11.0",
+2 -3
View File
@@ -1,11 +1,10 @@
{
"name": "open-terminal",
"productName": "OpenTerminal",
"version": "1.0.5",
"description": "Open-source terminal with SSH, split panes, themes and fonts",
"version": "0.8.0",
"description": "Terminal with split panes, themes and fonts (XTerminal-inspired)",
"main": "out/main/index.js",
"author": "Bill",
"license": "MIT",
"private": true,
"scripts": {
"dev": "electron-vite dev",
-155
View File
@@ -1,155 +0,0 @@
// OpenTerminal release publisher
// Usage: node scripts/release.cjs <version> [--skip-github] [--skip-gitea]
// node scripts/release.cjs 1.0.2
// Reads release notes from RELEASE_NOTES.md (repo root, gitignored).
// Credentials from .env (repo root, gitignored): GIT_TOKEN, GH_TOKEN.
// If HTTPS_PROXY / HTTP_PROXY (env or .env) is set, traffic goes through it
// (needed for api.github.com / uploads.github.com in some networks).
const fs = require('node:fs')
const path = require('node:path')
const ROOT = path.join(__dirname, '..')
const V = process.argv[2]
if (!V || !/^\d+\.\d+\.\d+$/.test(V)) {
console.error('usage: node scripts/release.cjs <x.y.z> [--skip-github] [--skip-gitea]')
process.exit(1)
}
const SKIP_GH = process.argv.includes('--skip-github')
const SKIP_GITEA = process.argv.includes('--skip-gitea')
const env = Object.fromEntries(
fs.readFileSync(path.join(ROOT, '.env'), 'utf8').split('\n')
.filter((l) => l.includes('=') && !l.startsWith('#'))
.map((l) => { const i = l.indexOf('='); return [l.slice(0, i).trim(), l.slice(i + 1).trim()] })
)
const notes = fs.readFileSync(path.join(ROOT, 'RELEASE_NOTES.md'), 'utf8')
const R = path.join(ROOT, 'release')
const files = [
path.join(R, `OpenTerminal-${V}-setup.msi`),
path.join(R, `OpenTerminal-${V}-setup.exe`)
]
for (const f of files) {
if (!fs.existsSync(f)) { console.error('missing build artifact:', f); process.exit(1) }
}
// Guard rails: the artifacts must belong to the version being published, and
// the tag must not exist yet — otherwise a re-run silently republishes a
// *different* binary under an already-released version number, and installed
// clients never see it (their version check compares numbers, not hashes).
const pkgVersion = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version
if (pkgVersion !== V) {
console.error(`package.json is ${pkgVersion} but you asked to publish ${V}`)
process.exit(1)
}
const ymlPath = path.join(R, 'latest.yml')
if (!fs.existsSync(ymlPath)) { console.error('missing release/latest.yml — run npm run dist first'); process.exit(1) }
const ymlVersion = fs.readFileSync(ymlPath, 'utf8').match(/^version:\s*(\S+)/m)?.[1]
if (ymlVersion !== V) {
console.error(`release/latest.yml says ${ymlVersion} but you asked to publish ${V} — rebuild first`)
process.exit(1)
}
const proxy = process.env.HTTPS_PROXY || process.env.https_proxy || env.HTTPS_PROXY || env.PROXY
if (proxy) {
const { ProxyAgent, setGlobalDispatcher } = require('undici')
setGlobalDispatcher(new ProxyAgent(proxy))
console.log('using proxy:', proxy)
}
async function upload(url, file, token, authScheme) {
const name = path.basename(file)
const stat = fs.statSync(file)
const resp = await fetch(`${url}${url.includes('?') ? '&' : '?'}name=${encodeURIComponent(name)}`, {
method: 'POST',
headers: {
Authorization: `${authScheme} ${token}`,
'Content-Type': 'application/octet-stream',
'Content-Length': String(stat.size)
},
body: fs.createReadStream(file),
duplex: 'half'
})
console.log(` upload ${name}: HTTP ${resp.status}`)
if (!resp.ok) {
const body = (await resp.text()).slice(0, 300)
throw new Error(`upload failed (${resp.status}) for ${name}: ${body}`)
}
}
async function gitea() {
console.log('=== Gitea release ===')
const base = 'https://git.codingplan.site/api/v1/repos/admin/OpenTerminal'
const resp = await fetch(`${base}/releases`, {
method: 'POST',
headers: { Authorization: `token ${env.GIT_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ tag_name: `v${V}`, name: `OpenTerminal v${V}`, body: notes, draft: false, prerelease: false })
})
if (!resp.ok) { throw new Error(`Gitea release create failed: ${resp.status} ${(await resp.text()).slice(0, 300)}`) }
const rel = await resp.json()
console.log('release created, id =', rel.id)
for (const f of files) await upload(`${base}/releases/${rel.id}/assets`, f, env.GIT_TOKEN, 'token')
}
async function giteaChannel() {
console.log('=== Gitea update channel ===')
const base = 'https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable'
const channelFiles = [
[path.join(R, 'latest.yml'), 'latest.yml'],
[path.join(R, `OpenTerminal-${V}-setup.exe.blockmap`), `OpenTerminal-${V}-setup.exe.blockmap`],
[path.join(R, `OpenTerminal-${V}-setup.exe`), `OpenTerminal-${V}-setup.exe`],
// Changelog served from the channel: the Gitea repo is private (anonymous
// releases API 404s), but the generic package is publicly readable.
[path.join(ROOT, 'RELEASE_NOTES.md'), 'release-notes.md']
]
// Validate before touching the channel: a missing file after the DELETE would
// leave the update channel empty (clients then fall back to GitHub, which is
// unreachable in China for most users).
for (const [f] of channelFiles) {
if (!fs.existsSync(f)) throw new Error(`missing channel file: ${f}`)
}
// latest.yml goes last: it is what makes clients start downloading, so the
// payload must already be in place.
const isLatest = ([f]) => path.basename(f) === 'latest.yml'
const ordered = [...channelFiles.filter((e) => !isLatest(e)), ...channelFiles.filter(isLatest)]
// Replace the version only once every file is known to be present on disk.
const del = await fetch(base, { method: 'DELETE', headers: { Authorization: `token ${env.GIT_TOKEN}` } })
console.log(' delete old stable:', del.status)
for (const [f, name] of ordered) {
const stat = fs.statSync(f)
const resp = await fetch(`${base}/${encodeURIComponent(name)}`, {
method: 'PUT',
headers: { Authorization: `token ${env.GIT_TOKEN}`, 'Content-Type': 'application/octet-stream', 'Content-Length': String(stat.size) },
body: fs.createReadStream(f),
duplex: 'half'
})
console.log(` put ${name}: HTTP ${resp.status}`)
if (!resp.ok) throw new Error(`channel put failed (${resp.status}) for ${name}: ${(await resp.text()).slice(0, 200)}`)
}
}
async function github() {
console.log('=== GitHub release ===')
const resp = await fetch('https://api.github.com/repos/billowliu2/OpenTerminal/releases', {
method: 'POST',
headers: {
Authorization: `Bearer ${env.GH_TOKEN}`,
'Content-Type': 'application/json',
'User-Agent': 'OpenTerminal',
Accept: 'application/vnd.github+json'
},
body: JSON.stringify({ tag_name: `v${V}`, name: `OpenTerminal v${V}`, body: notes, draft: false, prerelease: false })
})
if (!resp.ok) { throw new Error(`GitHub release create failed: ${resp.status} ${(await resp.text()).slice(0, 300)}`) }
const rel = await resp.json()
console.log('release created, id =', rel.id)
const up = `https://uploads.github.com/repos/billowliu2/OpenTerminal/releases/${rel.id}/assets`
for (const f of [...files, path.join(R, `OpenTerminal-${V}-setup.exe.blockmap`), path.join(R, 'latest.yml')]) {
await upload(up, f, env.GH_TOKEN, 'Bearer')
}
}
async function main() {
if (!SKIP_GITEA) { await gitea(); await giteaChannel() }
if (!SKIP_GH) await github()
console.log('done')
}
main().catch((e) => { console.error(e); process.exit(1) })
Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 B

+10 -86
View File
@@ -24,13 +24,9 @@ import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs'
import { appendFile } from 'fs/promises'
import { join } from 'path'
import type { CommandItem, SessionLogMeta } from '../shared/commands'
import { DEFAULT_SETTINGS } from '../shared/settings'
import { loadSettings } from './settingsStore'
/** Upper bound on recorded history entries when no limit is configured. */
/** Upper bound on recorded history entries. */
const HISTORY_CAP = 500
/** Hard ceiling: the history is also the completion source, so it stays small. */
export const HISTORY_LIMIT_MAX = 500
interface CommandsFile {
history: CommandItem[]
@@ -71,12 +67,6 @@ export class CommandsStore {
private readonly metasByFile = new Map<string, SessionLogMeta>()
/** Logs currently accumulating output, keyed by sessionId. */
private readonly activeBySession = new Map<string, SessionLogMeta>()
/**
* Pending append per log file. appendFile is async, so two rapid logWrite
* calls would otherwise race and land out of order. Chaining each new append
* onto the previous one keeps the file append-only in call order.
*/
private readonly pendingAppends = new Map<string, Promise<void>>()
constructor(userData?: string, openPathImpl?: (p: string) => Promise<unknown>) {
const ud = userData ?? app.getPath('userData')
@@ -110,32 +100,15 @@ export class CommandsStore {
writeFileSync(this.file, JSON.stringify(data, null, 2), 'utf8')
}
/**
* Record one executed command.
*
* Deduplication is over the *whole* history, not just the newest entry: a
* command used three commands ago is the same command, so re-running it moves
* the existing entry to the front (refreshing `lastUsedAt`) instead of adding
* a second copy. History reads back sorted by `lastUsedAt`, so hoisting is
* exactly what "most recently used" means — and it keeps the list from
* filling up with one line repeated.
*
* Silently does nothing when 设置 → 终端 → 记录命令历史 is off: the setting is
* read here (not at the call site) so every recording path honours it, and
* existing history is left untouched rather than cleared.
*/
/** Record one executed command: trim; dedupe vs newest; move to front; cap. */
recordCommand(cmd: string): void {
const trimmed = cmd.trim()
if (!trimmed) return
const { historyEnabled, historyLimit } = loadHistoryPrefs()
if (!historyEnabled) return
const cap = normalizeLimit(historyLimit)
const data = this.loadCommands()
const existing = data.history.findIndex((item) => item.command === trimmed)
if (existing >= 0) {
const [item] = data.history.splice(existing, 1)
item.lastUsedAt = Date.now()
data.history.unshift(item)
const top = data.history[0]
if (top && top.command === trimmed) {
// Same as the newest entry: refresh its recency (already at the front).
top.lastUsedAt = Date.now()
} else {
data.history.unshift({
id: randomUUID(),
@@ -143,23 +116,16 @@ export class CommandsStore {
createdAt: Date.now(),
lastUsedAt: Date.now()
})
if (data.history.length > HISTORY_CAP) data.history.length = HISTORY_CAP
}
if (data.history.length > cap) data.history.length = cap
this.saveCommands(data)
}
/**
* History sorted by most recently used, trimmed to the configured limit.
*
* The trim happens on read as well as on write so lowering the limit in
* settings takes effect at once instead of waiting for the next command.
*/
/** History sorted by most recently used. */
listHistory(): CommandItem[] {
const { historyLimit } = loadHistoryPrefs()
return this.loadCommands()
.history.slice()
.sort((a, b) => (b.lastUsedAt ?? 0) - (a.lastUsedAt ?? 0))
.slice(0, normalizeLimit(historyLimit))
}
clearHistory(): void {
@@ -280,18 +246,8 @@ export class CommandsStore {
// not logging (or already stopped) -> ignore, prevents stop/append race
return
}
// Chain onto any in-flight append so rapid writes land in call order.
const prev = this.pendingAppends.get(meta.file) ?? Promise.resolve()
const next = prev.then(() => appendFile(meta.file, data, 'utf8')).then(
() => undefined,
() => {
// file may have been removed after stop -> ignore
}
)
this.pendingAppends.set(meta.file, next)
// Avoid unbounded growth of the chain map once the file settles.
next.finally(() => {
if (this.pendingAppends.get(meta.file) === next) this.pendingAppends.delete(meta.file)
appendFile(meta.file, data, 'utf8').catch(() => {
// file may have been removed after stop -> ignore
})
}
@@ -329,36 +285,4 @@ function describeFile(p: string): void {
} catch {
// best effort; appends will also create it lazily
}
}
/**
* History preferences straight from settings.
*
* Read on every use rather than cached: the store is constructed once at boot
* but the user can flip the switch at any time, and a stale copy would keep
* recording after they turned it off.
*/
function loadHistoryPrefs(): { historyEnabled: boolean; historyLimit: number } {
try {
const terminal = loadSettings().terminal
return {
historyEnabled: terminal.historyEnabled !== false,
historyLimit: normalizeLimit(terminal.historyLimit)
}
} catch {
// Settings unreadable (e.g. store used outside Electron in a test): fall
// back to the same defaults the app ships with.
return {
historyEnabled: DEFAULT_SETTINGS.terminal.historyEnabled,
historyLimit: DEFAULT_SETTINGS.terminal.historyLimit
}
}
}
/** Clamp a configured limit into 1..HISTORY_LIMIT_MAX; unusable → the default. */
function normalizeLimit(value: unknown): number {
if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT_SETTINGS.terminal.historyLimit
const rounded = Math.floor(value)
if (rounded < 1) return 1
return Math.min(rounded, HISTORY_LIMIT_MAX)
}
-104
View File
@@ -1,104 +0,0 @@
import { statSync } from 'fs'
import { homedir, hostname } from 'os'
import { isAbsolute, resolve as resolvePath, normalize } from 'path'
/**
* Resolve a cd-style argument against a known working directory.
*
* Lives in the main process because this is the only side with node's `path`,
* and it must stay platform-aware for the cross-platform roadmap: `path.resolve`
* already does the right thing for `C:\a`, `/home/a`, `..`, `.` and mixed
* separators on the host platform, so the core carries no drive-letter or
* backslash assumptions of its own.
*
* Returns the resolved, existing directory, or null when the argument does not
* denote one (in which case callers keep the previous cwd).
*/
/** Flags that a shell may put in front of the path (`cd /d X`, `sl -Path X`). */
const LEADING_FLAGS = /^(?:\/d|-d|-p|-path|-literalpath)\s+/i
/** Everything from a command separator on is a different command. */
const SEPARATOR = /\s*(?:&&|\|\||[;|])\s*/
export function resolveCwd(current: string | undefined, rawArg: string): string | null {
let arg = rawArg.trim()
// `cd X && something` — only the cd part is ours.
arg = arg.split(SEPARATOR)[0]?.trim() ?? ''
// Repeatedly strip shell flags (`cd /d "D:\x"`, `sl -Path .`).
while (LEADING_FLAGS.test(arg)) arg = arg.replace(LEADING_FLAGS, '').trim()
// Strip one layer of matching quotes ("D:\Program Files" / '~/src').
const quoted = arg.match(/^(["'])(.*)\1$/)
if (quoted) arg = quoted[2]
// Bare `cd`, `cd ~`, `cd $HOME` → home directory.
if (arg === '' || arg === '~' || arg === '$HOME' || arg === '%USERPROFILE%') {
return existingDir(homedir())
}
// `cd -` means the shell's previous directory, which we cannot know here.
if (arg === '-') return null
const base = current && current.trim() ? current : homedir()
// An absolute argument wins; otherwise resolve against the base. Both forms
// are handled by path.resolve on every platform.
const target = isAbsolute(arg) ? normalize(arg) : resolvePath(base, arg)
return existingDir(target)
}
function existingDir(candidate: string): string | null {
try {
return statSync(candidate).isDirectory() ? candidate : null
} catch {
return null
}
}
/**
* Normalize a cwd reported by the shell itself.
*
* Both portable forms land here:
* - OSC 7: `file://<host>/<path>` (bash/zsh/fish, iTerm2, WezTerm, kitty…)
* - OSC 9;9: a bare path (ConEmu convention, Windows shells)
*
* A report from a remote host is ignored (an ssh/container shell is not our
* filesystem). On Windows the POSIX-ish `/C:/dir` form is folded back to
* `C:\dir`; path.resolve does the rest per platform.
*/
export function normalizeReportedCwd(raw: string): string | null {
let value = raw.trim()
if (!value) return null
const fileUrl = value.match(/^file:\/\/([^/]*)(\/.*)$/i)
if (fileUrl) {
const host = fileUrl[1]
if (host && !isLocalHost(host)) return null
value = safeDecode(fileUrl[2])
// `/C:/Users/x` → `C:/Users/x` (Windows shells report the URL this way).
const drive = value.match(/^\/([A-Za-z]:)(\/.*)?$/)
if (drive && process.platform === 'win32') value = `${drive[1]}${drive[2] ?? '\\'}`
}
// A Windows drive path reported with forward slashes still resolves fine.
return existingDir(resolvePath(value)) ?? existingDir(value)
}
function isLocalHost(host: string): boolean {
const lower = host.toLowerCase()
return lower === 'localhost' || lower === '127.0.0.1' || lower === '::1' || lower === osHostname()
}
function osHostname(): string {
try {
return hostname().toLowerCase()
} catch {
return ''
}
}
function safeDecode(value: string): string {
try {
return decodeURIComponent(value)
} catch {
return value
}
}
+3 -54
View File
@@ -1,61 +1,19 @@
import { app, BrowserWindow, globalShortcut, nativeImage, shell } from 'electron'
import { existsSync } from 'fs'
import { app, BrowserWindow, globalShortcut, shell } from 'electron'
import { join } from 'path'
import { registerIpc } from './ipc'
import { killAllPtys } from './pty'
import { applyStartupSystemSettings, loadSettings } from './settingsStore'
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())
}
import { configureAutoUpdater } from './updater'
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: theme.colors.background,
backgroundColor: '#1e1e1e',
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
@@ -64,11 +22,6 @@ 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') {
@@ -98,12 +51,10 @@ process.on('uncaughtException', (err) => {
app.whenReady().then(() => {
registerIpc()
registerUpdateIpc()
// OS-level effects (login item, sleep blocker) must apply even if the
// settings dialog is never opened this run.
applyStartupSystemSettings(loadSettings())
createWindow()
initTray(showOrCreate)
configureAutoUpdater()
app.on('activate', () => {
@@ -112,8 +63,6 @@ app.whenReady().then(() => {
})
app.on('before-quit', () => {
// Let window 'close' events pass through so teardown completes.
markQuitting()
killAllPtys()
globalShortcut.unregisterAll()
})
-10
View File
@@ -6,8 +6,6 @@ import type { HostKeyAction, SessionOpenOptions, SshConnection, SshConnectionInp
import { getLayout, listLayouts, saveLayout, deleteLayout } from './layouts'
import { startPolling, stopPolling } from './sysinfo'
import { registerSettingsIpc } from './settingsStore'
import { registerSessionStateIpc } from './sessionState'
import { normalizeReportedCwd, resolveCwd } from './cwd'
import { ConnectionsStore, defaultConnectionsPath } from './connectionsStore'
import { KnownHostsStore, defaultKnownHostsPath } from './knownHosts'
import { resolveHostKey } from './ssh'
@@ -173,12 +171,4 @@ export function registerIpc(): void {
ipcMain.on(Ipc.LOG_OPEN_DIR, () => cmds.openLogsDir())
registerSettingsIpc()
registerSessionStateIpc()
// Resolve a cd-style argument against the current cwd (platform-aware; only
// the main process has node's `path`).
ipcMain.handle(Ipc.CWD_RESOLVE, (_event, current: string | undefined, arg: string) =>
resolveCwd(current, arg)
)
ipcMain.handle(Ipc.CWD_REPORT, (_event, payload: string) => normalizeReportedCwd(payload))
}
+3 -49
View File
@@ -9,9 +9,6 @@ import type { HostKeyCheckResult } from './knownHosts'
import { configureSysinfo, registerSysinfoClient, stopPolling } from './sysinfo'
import { registerSftpClientProvider, closeSftp } from './sftp'
import { attachZmodem, detachZmodem, feedZmodem, isZmodemActive } from './zmodem'
import { pickAdapter } from './shellIntegration'
import { loadSettings } from './settingsStore'
import { statSync } from 'fs'
// Re-export so the session-layer loopback bundle (tests/*-e2e.mjs) can drive the
// M3 polling engine without importing src/main/sysinfo.ts separately.
@@ -127,15 +124,6 @@ export function configureSessionRuntime(deps: SessionRuntimeDeps): void {
})
}
function existingDir(candidate: string | undefined): string | null {
if (!candidate || !candidate.trim()) return null
try {
return statSync(candidate).isDirectory() ? candidate : null
} catch {
return null
}
}
function defaultShell(): string {
switch (process.platform) {
case 'win32':
@@ -150,40 +138,10 @@ function defaultShell(): string {
export function createPty(opts: PtyCreateOptions = {}): PtyCreateResult {
const id = randomUUID()
const shell = opts.shell ?? defaultShell()
// A remembered directory can be gone by the next launch (renamed, unmounted,
// another machine's path after syncing settings) — fall back to home rather
// than failing the spawn.
const cwd = existingDir(opts.cwd) ?? homedir()
// Advertise color capability + terminal identity so TUIs (e.g. kimi CLI)
// use their full-color theme instead of the degraded white/amber fallback.
const env = {
...process.env,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
TERM_PROGRAM: 'OpenTerminal',
...opts.env
} as Record<string, string>
// NO_COLOR (no-color.org) leaks in from whatever launched the app — an agent
// CLI, an IDE, a CI shell. Every library that honors it (chalk and friends)
// then prints uncolored output, which reads as "this terminal is broken".
// The pty we hand out does truecolor, so the opt-out is dropped. Same for the
// explicit FORCE_COLOR=0 form of the same request.
delete env.NO_COLOR
if (env.FORCE_COLOR === '0') delete env.FORCE_COLOR
const cwd = opts.cwd ?? homedir()
const env = { ...process.env, ...opts.env } as Record<string, string>
// Shell integration (opt-in): let the shell announce its own cwd over OSC 7 so
// the remembered path is exact instead of inferred from typed `cd` commands.
// Every platform/shell difference lives in the adapter table, not here.
const args = [...(opts.shellArgs ?? [])]
if (opts.shellArgs === undefined && loadSettings().system.shellIntegration) {
const adapter = pickAdapter(shell)
if (adapter) {
args.push(...adapter.args(shell))
Object.assign(env, adapter.env?.() ?? {})
}
}
const pty = spawn(shell, args, { name: 'xterm-256color', cols: 80, rows: 24, cwd, env })
const pty = spawn(shell, [], { name: 'xterm-256color', cols: 80, rows: 24, cwd, env })
sessions.set(id, { kind: 'local', pty })
replayBuffers.set(id, '')
@@ -200,9 +158,6 @@ export function createPty(opts: PtyCreateOptions = {}): PtyCreateResult {
pty.onExit(({ exitCode }) => {
try {
sessions.delete(id)
// The session is gone: drop its replay buffer too (killPty was the only
// path that did, so naturally-exiting shells leaked up to 64KB each).
replayBuffers.delete(id)
safeStopLog(id)
broadcast(Ipc.PTY_EXIT, { id, exitCode })
} catch {
@@ -288,7 +243,6 @@ export async function openSession(opts: SessionOpenOptions): Promise<{ id: strin
detachZmodem(handle.id)
safeStopLog(handle.id)
sessions.delete(handle.id)
replayBuffers.delete(handle.id)
deps.broadcast(Ipc.PTY_EXIT, { id: handle.id, exitCode: 0 })
} catch {
// never crash the event loop
-82
View File
@@ -1,82 +0,0 @@
import { app, ipcMain } from 'electron'
import { join } from 'path'
import { Ipc, type SessionPanelState, type SessionSnapshot } from '../shared/ipc'
import { isRecord, readJson, writeJson } from './store'
/**
* The automatic session snapshot: dockview layouts plus a per-panel record, so
* the next launch can restore the arrangement and drop every pane back into the
* directory it was in.
*
* Distinct from the user-managed layout *templates* (`layouts/`): templates are
* deliberately saved/loaded sets of layouts, the snapshot is the single
* "where I left off" memory. They coexist.
*/
const MAX_PANELS = 64
/** A layout is a few KB; anything past this is a bug or a runaway, not a layout. */
const MAX_LAYOUT_BYTES = 2 * 1024 * 1024
const snapshotPath = (): string => join(app.getPath('userData'), 'session.json')
function isPanel(value: unknown): value is SessionPanelState {
if (!isRecord(value)) return false
return (
typeof value.panelId === 'string' &&
typeof value.title === 'string' &&
(value.kind === 'local' || value.kind === 'ssh') &&
(value.cwd === undefined || typeof value.cwd === 'string') &&
(value.connectionId === undefined || typeof value.connectionId === 'string') &&
(value.hostLabel === undefined || typeof value.hostLabel === 'string')
)
}
/** Parse defensively: a corrupt snapshot must degrade to "no snapshot". */
function sanitize(raw: unknown): SessionSnapshot | null {
if (!isRecord(raw)) return null
const layouts = isRecord(raw.layouts) ? raw.layouts : {}
const panels = Array.isArray(raw.panels) ? raw.panels.filter(isPanel).slice(0, MAX_PANELS) : []
if (panels.length === 0 && !layouts.terminal && !layouts.ssh) return null
const encode = (value: unknown): unknown => {
if (value === undefined || value === null) return undefined
// Cheap size guard: serializing here means a too-large layout is dropped
// rather than written to disk on every save.
try {
const json = JSON.stringify(value)
return json.length > MAX_LAYOUT_BYTES ? undefined : value
} catch {
return undefined
}
}
return {
version: 1,
savedAt: typeof raw.savedAt === 'number' ? raw.savedAt : Date.now(),
mode: raw.mode === 'ssh' ? 'ssh' : 'terminal',
layouts: { terminal: encode(layouts.terminal), ssh: encode(layouts.ssh) },
panels,
lastLocalCwd: typeof raw.lastLocalCwd === 'string' ? raw.lastLocalCwd : undefined
}
}
export function loadSessionSnapshot(): SessionSnapshot | null {
return sanitize(readJson(snapshotPath()))
}
export function saveSessionSnapshot(next: SessionSnapshot): SessionSnapshot | null {
const clean = sanitize(next)
if (!clean) return null
clean.savedAt = Date.now()
writeJson(snapshotPath(), clean)
return clean
}
export function clearSessionSnapshot(): void {
writeJson(snapshotPath(), { version: 1, savedAt: Date.now(), mode: 'terminal', layouts: {}, panels: [] })
}
export function registerSessionStateIpc(): void {
ipcMain.handle(Ipc.SESSION_STATE_GET, () => loadSessionSnapshot())
ipcMain.handle(Ipc.SESSION_STATE_SET, (_event, snapshot: SessionSnapshot) => saveSessionSnapshot(snapshot))
}
+4 -25
View File
@@ -13,19 +13,10 @@ 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: '',
// Must match DEFAULT_SETTINGS.system in @shared/settings — an "ask" here made
// a fresh install prompt on close while the docs and UI promised tray.
closeAction: 'tray',
autoCheckUpdate: true
}
const DEFAULT_SYSTEM: SystemSettings = { launchAtLogin: false, preventSleep: false, globalShowHide: '' }
const TERMINAL_KEYS = new Set(Object.keys(DEFAULT_SETTINGS.terminal) as (keyof TerminalSettings)[])
@@ -45,11 +36,9 @@ function isHighlightRule(value: unknown): value is HighlightRule {
}
function sanitizeRules(value: unknown): HighlightRule[] {
// An explicit empty array is a valid choice ("no highlighting"); only
// malformed data falls back to the built-in rules. Returning the defaults for
// [] made deleting the last rule look like it silently failed.
if (!Array.isArray(value)) return DEFAULT_HIGHLIGHT_RULES
return value.filter(isHighlightRule)
const rules = value.filter(isHighlightRule)
return rules.length > 0 ? rules : DEFAULT_HIGHLIGHT_RULES
}
function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } {
@@ -88,19 +77,10 @@ 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
: 'tray'
system = {
launchAtLogin: candidate.launchAtLogin === true,
preventSleep: candidate.preventSleep === true,
globalShowHide,
closeAction,
autoCheckUpdate: candidate.autoCheckUpdate !== false,
// Both default on/off as in DEFAULT_SETTINGS; absent means "not chosen".
restoreSession: candidate.restoreSession !== false,
shellIntegration: candidate.shellIntegration === true
globalShowHide
}
}
}
@@ -167,7 +147,6 @@ 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 })
+2 -12
View File
@@ -195,23 +195,13 @@ export function deleteRemote(sessionId: string, paths: string[]): Promise<void>
* chmod/chown run over an exec channel: the JD test server's sftp subsystem
* accepts SETSTAT but silently ignores it, while shell chmod/chown work.
*/
/**
* POSIX single-quote escaping. `JSON.stringify` only escapes `"`, so a remote
* file name containing `$`, a backtick or a quote would still be expanded by the
* far-side shell — that is remote command execution triggered by a file name.
*/
function shQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`
}
export function chmodRemote(sessionId: string, path: string, mode: string): Promise<void> {
if (!/^[0-7]{1,4}$/.test(mode)) throw new Error(`非法权限值: ${mode}`)
return execQuiet(sessionId, `chmod ${mode} ${shQuote(path)}`)
return execQuiet(sessionId, `chmod ${mode} ${JSON.stringify(path)}`)
}
export function chownRemote(sessionId: string, path: string, uid: number, gid: number): Promise<void> {
if (!Number.isInteger(uid) || !Number.isInteger(gid)) throw new Error('非法 uid/gid')
return execQuiet(sessionId, `chown ${uid}:${gid} ${shQuote(path)}`)
return execQuiet(sessionId, `chown ${uid}:${gid} ${JSON.stringify(path)}`)
}
/** Run a command on the session's shell channel and wait for it to finish. */
-114
View File
@@ -1,114 +0,0 @@
import { basename } from 'path'
import { homedir } from 'os'
/**
* Shell integration: make the shell announce its working directory so the app
* can remember it exactly, instead of guessing from typed `cd` commands.
*
* The channel is the industry standard **OSC 7** (`ESC ] 7 ; file://<host><path>
* BEL`): bash, zsh, fish, iTerm2, WezTerm, kitty and Windows Terminal all speak
* it, so the app's parser is written once and works on every platform. Windows
* shells that historically emit ConEmu's **OSC 9;9** (`ESC ] 9 ; 9 ; <path> BEL`)
* are handled by the same parser as a second form.
*
* Everything platform-specific is confined to an adapter below: adding a shell
* or a platform means adding one entry here, never touching the renderer, the
* snapshot format or the restore path.
*
* Trade-off: an adapter wraps the user's prompt (that is the only reliable hook
* a foreign process has), so this whole feature is opt-in via
* 设置 → 系统 → Shell 集成. With it off the app falls back to parsing typed
* `cd` commands, which covers the common case with zero interference.
*/
export interface ShellAdapter {
/** Stable id used by the setting and by logs. */
id: string
/** Human label shown in settings. */
label: string
/** True when this adapter can drive the given shell executable. */
matches(shellPath: string, platform: NodeJS.Platform): boolean
/** Extra argv appended when spawning the shell. */
args(shellPath: string): string[]
/** Extra environment variables for the child. */
env?(): Record<string, string>
}
/** `ESC ] 7 ; file://<host>/<path> BEL` — the portable form. */
function posixPromptCommand(): string {
return `printf '\\033]7;file://%s%s\\033\\\\' "$HOSTNAME" "$PWD"`
}
/**
* PowerShell: keep the user's prompt function and re-emit it after our own
* write, so oh-my-posh / starship / a hand-written prompt all keep working.
*/
const POWERSHELL_ADAPTER: ShellAdapter = {
id: 'powershell',
label: 'PowerShell',
matches: (shellPath) => /(?:^|[\\/])(pwsh|powershell)(\.exe)?$/i.test(shellPath),
args: () => [
'-NoLogo',
'-NoExit',
'-Command',
[
// Save whatever prompt is in effect, then wrap it.
'$global:__otPrompt = $function:prompt',
'function global:prompt {',
' $p = $PWD.ProviderPath; if (-not $p) { $p = $PWD.Path }',
' $esc = [char]27; $bel = [char]7',
' Write-Host -NoNewline "$esc]7;file://$env:COMPUTERNAME/$($p -replace \'\\\\\',\'/\')$bel"',
' if ($global:__otPrompt) { & $global:__otPrompt } else { "PS $p> " }',
'}'
].join(' ')
]
}
/** cmd.exe: `$E` is ESC inside the prompt string, `$P` the current directory. */
const CMD_ADAPTER: ShellAdapter = {
id: 'cmd',
label: '命令提示符 (cmd)',
matches: (shellPath, platform) => platform === 'win32' && /(?:^|[\\/])cmd(\.exe)?$/i.test(shellPath),
args: () => [
'/K',
// Emit OSC 7 (path with backslashes swapped for slashes) then the normal
// `C:\dir>` prompt.
'prompt $E]7;file://%COMPUTERNAME%/$P$E\\$P$G'
]
}
/**
* POSIX shells: a one-line prompt hook. bash honours PROMPT_COMMAND directly;
* zsh/fish hook through the same env var where their startup files cooperate —
* see `env()` notes. Implementation is kept deliberately small because these
* need verifying on their own platforms before being enabled by default.
*/
const POSIX_ADAPTER: ShellAdapter = {
id: 'posix',
label: 'bash / zsh / fish',
matches: (shellPath, platform) =>
platform !== 'win32' && /(?:^|[\\/])(bash|zsh|dash|fish|sh)$/i.test(shellPath),
args: () => [],
env: () => ({
// bash reads this before every prompt. zsh ignores it (no harm); fish uses
// its own hook, so fish users on this path simply keep the cd-parsing
// fallback until a dedicated adapter is verified.
PROMPT_COMMAND: posixPromptCommand()
})
}
/** Every adapter the app knows about. Order matters: first match wins. */
export const SHELL_ADAPTERS: ShellAdapter[] = [POWERSHELL_ADAPTER, CMD_ADAPTER, POSIX_ADAPTER]
export function pickAdapter(shellPath: string, platform: NodeJS.Platform = process.platform): ShellAdapter | null {
const name = basename(shellPath)
return SHELL_ADAPTERS.find((a) => a.matches(name, platform) || a.matches(shellPath, platform)) ?? null
}
/** The default POSIX shell, used when the setting is on and no shell is given. */
export function posixFallbackShell(): string {
return process.env.SHELL || '/bin/sh'
}
/** Kept for symmetry with the adapters that need a home directory. */
export const integrationHome = (): string => homedir()
-63
View File
@@ -1,63 +0,0 @@
import { mkdirSync, readFileSync, renameSync, writeFileSync, unlinkSync } from 'fs'
import { dirname, join } from 'path'
/**
* Small shared persistence helper for the app's JSON stores (settings,
* connections, known hosts, commands, layouts, the session snapshot).
*
* Why not a database: these are a handful of KB written by a single process at
* human speed, and every native DB binding (better-sqlite3 et al) would add a
* per-platform rebuild + packaging burden to a cross-platform app. What the
* stores actually needed was the *reliability* a DB implies — that is what this
* gives: an atomic write (write to a sibling temp file, then rename) so a crash
* or a full disk can never leave a half-written file behind, and a capped size
* so a runaway value cannot fill the disk either.
*
* If a dataset ever outgrows this (command history in the tens of thousands, or
* full-text search over session logs), swap the implementation here — callers
* only ever see `readJson`/`writeJson`.
*/
/** Refuse to load a file larger than this (guards against a runaway store). */
const MAX_BYTES = 8 * 1024 * 1024
const tmpPath = (file: string): string => `${file}.tmp`
export function readJson<T = unknown>(file: string): T | null {
try {
const raw = readFileSync(file, 'utf8')
if (raw.length > MAX_BYTES) return null
return JSON.parse(raw) as T
} catch {
// Missing, unreadable or corrupt: callers fall back to their defaults.
// A corrupt file is left in place (not deleted) so it can be inspected.
return null
}
}
export function writeJson(file: string, value: unknown): void {
mkdirSync(dirname(file), { recursive: true })
const tmp = tmpPath(file)
try {
writeFileSync(tmp, JSON.stringify(value, null, 2), 'utf8')
renameSync(tmp, file)
} catch (err) {
// Clean up the temp file so a failed write does not leave litter behind.
try {
unlinkSync(tmp)
} catch {
/* nothing to clean */
}
throw err
}
}
/** Convenience for stores whose file lives under a directory. */
export function jsonFileIn(dir: string, name: string): string {
return join(dir, name)
}
/** Minimal shape guard used by the stores before trusting a parsed value. */
export function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
-120
View File
@@ -1,120 +0,0 @@
import { app, BrowserWindow, dialog, Menu, nativeImage, Tray } from 'electron'
import type { SystemSettings } from '@shared/settings'
import { loadSettings, saveSettings } from './settingsStore'
import trayIconPath from './assets/tray.png?asset'
export type CloseAction = NonNullable<SystemSettings['closeAction']>
let tray: Tray | null = null
let quitting = false
let balloonShown = false
/** Once true, window close events pass through and the app really quits. */
export function markQuitting(): void {
quitting = true
}
function persistCloseAction(action: CloseAction): void {
const next = loadSettings()
next.system.closeAction = action
saveSettings(next)
}
function showMainWindow(showOrCreate: () => void): void {
const win = BrowserWindow.getAllWindows()[0]
if (win) {
if (win.isMinimized()) win.restore()
win.show()
win.focus()
} else {
showOrCreate()
}
}
/** Rebuild the context menu so the close-action radio items reflect settings. */
function refreshContextMenu(showOrCreate: () => void): void {
if (!tray) return
const action = loadSettings().system.closeAction ?? 'tray'
const setAction = (value: CloseAction) => (): void => {
persistCloseAction(value)
refreshContextMenu(showOrCreate)
}
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: '显示主窗口', click: (): void => showMainWindow(showOrCreate) },
{ type: 'separator' },
{ label: '关闭按钮行为', enabled: false },
{ label: '每次询问', type: 'radio', checked: action === 'ask', click: setAction('ask') },
{ label: '最小化到托盘', type: 'radio', checked: action === 'tray', click: setAction('tray') },
{ label: '直接退出', type: 'radio', checked: action === 'exit', click: setAction('exit') },
{ type: 'separator' },
{
label: '退出',
click: (): void => {
markQuitting()
app.quit()
}
}
])
)
}
/** Create the tray icon and wire left-click to window restore. */
export function initTray(showOrCreate: () => void): void {
const icon = nativeImage.createFromPath(trayIconPath)
tray = new Tray(icon)
tray.setToolTip('OpenTerminal')
tray.on('click', (): void => showMainWindow(showOrCreate))
refreshContextMenu(showOrCreate)
}
function hideToTray(win: BrowserWindow): void {
win.hide()
if (process.platform === 'win32' && tray && !balloonShown) {
balloonShown = true
tray.displayBalloon({
iconType: 'info',
// The Windows toast already prints the app name as its header, so the
// balloon title carries the state only — no "OpenTerminal" twice.
title: '已最小化到托盘',
content: '终端会话仍在后台运行,点击托盘图标可恢复窗口。'
})
}
}
/**
* Window close interception. Must be called synchronously from the 'close'
* event: preventDefault happens before any await on every branch.
*/
export async function onMainWindowClose(win: BrowserWindow, e: Electron.Event, showOrCreate: () => void): Promise<void> {
if (quitting) return
const action = loadSettings().system.closeAction ?? 'tray'
if (action === 'tray') {
e.preventDefault()
hideToTray(win)
return
}
if (action === 'exit') return // fall through: close proceeds, app quits
e.preventDefault()
const { response, checkboxChecked } = await dialog.showMessageBox(win, {
type: 'question',
title: '关闭 OpenTerminal',
message: '最小化到托盘,还是直接退出?',
detail: '最小化到托盘时终端会话保持运行。',
buttons: ['最小化到托盘', '直接退出'],
defaultId: 0,
cancelId: 1,
checkboxLabel: '记住我的选择,不再询问',
checkboxChecked: false,
noLink: true
})
if (response === 0) {
if (checkboxChecked) persistCloseAction('tray')
hideToTray(win)
} else {
if (checkboxChecked) persistCloseAction('exit')
markQuitting()
app.quit()
}
refreshContextMenu(showOrCreate)
}
+36 -172
View File
@@ -1,190 +1,54 @@
import { app, ipcMain, net, session } from 'electron'
import { app } 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'
/**
* 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.
* 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.
*/
const GITEA_FEED =
const DEFAULT_FEED_URL =
'https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/'
const GITEA_RELEASES_API =
'https://git.codingplan.site/api/v1/repos/admin/OpenTerminal/releases?limit=10'
const GITHUB_REPO = { owner: 'billowliu2', repo: 'OpenTerminal' }
const GITHUB_RELEASES_API =
'https://api.github.com/repos/billowliu2/OpenTerminal/releases?per_page=10'
let state: UpdateState = { status: 'idle', currentVersion: app.getVersion() }
let activeFeed: 'gitea' | 'github' = 'gitea'
let eventsWired = false
function setState(patch: Partial<UpdateState>): void {
state = { ...state, ...patch, currentVersion: app.getVersion() }
broadcast(Ipc.UPDATE_STATE, state)
}
function useFeed(feed: 'gitea' | 'github'): void {
activeFeed = feed
if (feed === 'gitea') {
// Domestic feed: always direct — a system proxy only breaks it.
void autoUpdater.netSession.setProxy({ mode: 'direct' })
const token = process.env.OT_UPDATE_TOKEN
autoUpdater.setFeedURL({
provider: 'generic',
url: process.env.OT_UPDATE_URL || GITEA_FEED,
...(token ? { requestHeaders: { Authorization: `token ${token}` } } : {})
})
} else {
// GitHub usually needs the system proxy (when one is enabled).
void autoUpdater.netSession.setProxy({ mode: 'system' })
autoUpdater.setFeedURL({ provider: 'github', ...GITHUB_REPO })
}
}
function wireEvents(): void {
if (eventsWired) return
eventsWired = true
autoUpdater.on('checking-for-update', () => setState({ status: 'checking', error: undefined }))
autoUpdater.on('update-available', (info) =>
setState({ status: 'available', version: info.version, feed: activeFeed, percent: undefined })
)
autoUpdater.on('update-not-available', () => setState({ status: 'latest', version: undefined }))
autoUpdater.on('download-progress', (p) =>
setState({ status: 'downloading', percent: Math.round(p.percent) })
)
autoUpdater.on('update-downloaded', (info) =>
setState({ status: 'downloaded', version: info.version, percent: undefined })
)
autoUpdater.on('error', (err) =>
setState({ status: 'error', error: err instanceof Error ? err.message : String(err) })
)
}
/** Check the active feed; on failure switch Gitea → GitHub and retry once. */
async function checkWithFallback(): Promise<void> {
try {
await autoUpdater.checkForUpdates()
} catch (err) {
if (activeFeed !== 'gitea') throw err
console.warn('[updater] gitea feed failed, falling back to github:', err)
const giteaErr = err instanceof Error ? err.message : String(err)
useFeed('github')
try {
await autoUpdater.checkForUpdates()
} catch (err2) {
const ghErr = err2 instanceof Error ? err2.message : String(err2)
throw new Error(`国内源: ${giteaErr};GitHub: ${ghErr}`)
}
}
}
async function handleCheck(): Promise<UpdateState> {
if (!app.isPackaged) {
setState({ status: 'dev' })
return state
}
setState({ status: 'checking', error: undefined, percent: undefined })
try {
await checkWithFallback()
} catch (err) {
setState({ status: 'error', error: err instanceof Error ? err.message : String(err) })
}
return state
}
async function handleDownload(): Promise<void> {
if (!app.isPackaged) return
setState({ status: 'downloading', percent: 0 })
try {
await autoUpdater.downloadUpdate()
} catch (err) {
setState({ status: 'error', error: err instanceof Error ? err.message : String(err) })
}
}
/** Direct-connection fetch for the domestic update channel (ignores system proxy). */
async function directFetch(url: string): Promise<Response> {
const s = session.fromPartition('openterminal-update-direct', { cache: false })
await s.setProxy({ mode: 'direct' })
return s.fetch(url, { headers: { 'User-Agent': 'OpenTerminal' } })
}
/**
* Changelog sources, in order:
* 1. update channel's release-notes.md — the Gitea repo itself is private
* (anonymous API reads 404), but the generic package is publicly readable.
* 2. Gitea / GitHub releases APIs (full history when reachable).
*/
async function fetchChangelog(): Promise<ReleaseNote[]> {
try {
const [notesResp, ymlResp] = await Promise.all([
directFetch(`${GITEA_FEED}release-notes.md`),
directFetch(`${GITEA_FEED}latest.yml`)
])
if (notesResp.ok) {
const body = await notesResp.text()
const yml = ymlResp.ok ? await ymlResp.text() : ''
const version = yml.match(/^version:\s*(\S+)/m)?.[1] ?? ''
const date = yml.match(/^releaseDate:\s*'?(\S+?)'?$/m)?.[1]?.slice(0, 10) ?? ''
if (body.trim()) return [{ version: version ? `v${version}` : 'latest', date, body }]
}
} catch {
// fall through to releases APIs
}
for (const url of [GITEA_RELEASES_API, GITHUB_RELEASES_API]) {
try {
const resp = await net.fetch(url, { headers: { 'User-Agent': 'OpenTerminal' } })
if (!resp.ok) continue
const data = (await resp.json()) as Array<{
tag_name?: string
name?: string
published_at?: string
body?: string
}>
return data
.map((r) => ({
version: r.tag_name ?? r.name ?? '',
date: (r.published_at ?? '').slice(0, 10),
body: r.body ?? ''
}))
.filter((r) => r.version)
} catch {
// try next source
}
}
return []
}
export function registerUpdateIpc(): void {
ipcMain.handle(Ipc.UPDATE_CHECK, handleCheck)
ipcMain.handle(Ipc.UPDATE_DOWNLOAD, handleDownload)
ipcMain.handle(Ipc.UPDATE_INSTALL, () => {
// The close interceptor (tray flow) would swallow this quit — mark first.
markQuitting()
autoUpdater.quitAndInstall()
})
ipcMain.handle(Ipc.UPDATE_CHANGELOG, fetchChangelog)
}
export function configureAutoUpdater(): void {
// Update checks only run in packaged builds; no-op during development.
if (!app.isPackaged) {
return
}
useFeed('gitea')
wireEvents()
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}` } } : {})
})
autoUpdater.logger = console
autoUpdater.autoDownload = false
autoUpdater.autoDownload = true
autoUpdater.autoInstallOnAppQuit = true
// Startup check only when the user left it enabled (设置 → 关于).
if (loadSettings().system.autoCheckUpdate === false) return
// Check shortly after startup; log errors without crashing the app.
const timer = setTimeout(() => {
void handleCheck()
autoUpdater
.checkForUpdatesAndNotify()
.catch((err) => console.warn('[updater] checkForUpdates failed:', err))
}, 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
@@ -1,16 +0,0 @@
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 })
}
}
}
-11
View File
@@ -309,17 +309,6 @@ export function attachZmodem(sessionId: string, deps: ZmodemDeps): void {
engine.mode = role === 'receive' ? 'receive' : 'send'
engine.detection = detection
engine.active = true
// A detect is the start of a fresh transfer on this session, so the
// one-shot flags of the previous one must go with it. Leaving `confirmed`
// set made the *second* `sz`/`rz` on a session a no-op: respondZmodem
// bailed out, the engine stayed `active` (swallowing every keystroke and
// all terminal data) until the stall timer finally fired.
engine.confirmed = false
engine.progressEmitted = false
engine.doneEmitted = false
engine.dir = null
engine.session = null
engine.receiveStream = null
engine.transferId = `zm-${sessionId}`
emitProgress(engine, { file: '', bytes: 0, totalBytes: 0 })
try {
+2 -17
View File
@@ -2,7 +2,7 @@ import { contextBridge, ipcRenderer } from 'electron'
import { Ipc } from '../shared/ipc'
import type { AppSettings } from '../shared/settings'
import type { AppApi } from '../shared/api'
import type { LayoutMeta, PtyCreateOptions, PtyDataEvent, PtyExitEvent, ReleaseNote, SessionSnapshot, UpdateState, ZmodemOfferEvent, ZmodemResponse, ZmodemDoneEvent } from '../shared/ipc'
import type { LayoutMeta, PtyCreateOptions, PtyDataEvent, PtyExitEvent, ZmodemOfferEvent, ZmodemResponse, ZmodemDoneEvent } from '../shared/ipc'
import type {
HostKeyAction,
HostKeyPromptEvent,
@@ -117,22 +117,7 @@ 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),
updateCheck: () => ipcRenderer.invoke(Ipc.UPDATE_CHECK),
updateDownload: () => ipcRenderer.invoke(Ipc.UPDATE_DOWNLOAD),
updateInstall: () => ipcRenderer.invoke(Ipc.UPDATE_INSTALL),
updateChangelog: () => ipcRenderer.invoke(Ipc.UPDATE_CHANGELOG),
onUpdateState: (cb: (s: UpdateState) => void) => {
const listener = (_: unknown, s: UpdateState): void => cb(s)
ipcRenderer.on(Ipc.UPDATE_STATE, listener)
return () => ipcRenderer.removeListener(Ipc.UPDATE_STATE, listener)
},
getSessionState: () => ipcRenderer.invoke(Ipc.SESSION_STATE_GET),
saveSessionState: (snapshot: SessionSnapshot) => ipcRenderer.invoke(Ipc.SESSION_STATE_SET, snapshot),
resolveCwd: (current: string | undefined, arg: string) => ipcRenderer.invoke(Ipc.CWD_RESOLVE, current, arg),
reportCwd: (payload: string) => ipcRenderer.invoke(Ipc.CWD_REPORT, payload)
deleteLayout: (id: string) => ipcRenderer.invoke(Ipc.LAYOUTS_DELETE, id)
}
contextBridge.exposeInMainWorld('api', api)
-33
View File
@@ -3,50 +3,17 @@ import Workspace from '@renderer/workspace/Workspace'
import { SettingsDialog } from '@renderer/settings/SettingsDialog'
import { TransferPanel } from '@renderer/sftp/TransferPanel'
import { useSettingsStore } from '@renderer/settings/store'
import { getThemeById } from '@shared/theme'
import { applyChromeTheme, applyTabAccent } from '@renderer/theme/chrome'
import appIconUrl from '../../../build/icon.png'
/** Custom window title bar: draggable strip themed with the active terminal
* theme; native min/max/close buttons come from BrowserWindow titleBarOverlay. */
function TitleBar(): React.JSX.Element {
const settings = useSettingsStore((s) => s.settings)
const theme = getThemeById(settings.terminal.themeId, settings.customThemes)
return (
<div
className="app-titlebar"
style={{ background: theme.colors.background, color: theme.colors.foreground }}
>
<img className="app-titlebar-icon" src={appIconUrl} alt="" draggable={false} />
<span className="app-titlebar-title">OpenTerminal</span>
</div>
)
}
export default function App(): React.JSX.Element {
const hydrate = useSettingsStore((s) => s.hydrate)
const themeId = useSettingsStore((s) => s.settings.terminal.themeId)
const customThemes = useSettingsStore((s) => s.settings.customThemes)
const tabAccentColor = useSettingsStore((s) => s.settings.terminal.tabAccentColor)
const [settingsOpen, setSettingsOpen] = useState(false)
useEffect(() => {
void hydrate()
}, [hydrate])
// Sidebar / tab bars / dividers follow the active terminal theme.
useEffect(() => {
applyChromeTheme(getThemeById(themeId, customThemes).colors)
}, [themeId, customThemes])
// Active-tab accent color (rail indicator, tab outline, SSH badges).
useEffect(() => {
applyTabAccent(tabAccentColor)
}, [tabAccentColor])
return (
<div className="app-root">
<TitleBar />
<Workspace onOpenSettings={() => setSettingsOpen(true)} />
<SettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} />
<TransferPanel />
+1 -6
View File
@@ -12,12 +12,7 @@
display: flex;
flex-direction: column;
background: var(--chrome-bg);
/* Framed like the dockview area: the right edge was the only border, which
left the sidebar's top (against the title bar) and bottom (against the
window edge) looking unfinished next to the neighbouring separators. */
border-right: 1px solid var(--chrome-border);
border-top: 1px solid var(--chrome-border);
border-bottom: 1px solid var(--chrome-border);
box-sizing: border-box;
}
@@ -351,7 +346,7 @@
}
.connections-local-item.is-active {
background: color-mix(in srgb, var(--tab-accent, #3fb950) 12%, transparent);
background: rgba(63, 185, 80, 0.12);
}
.connections-local-name {
-2
View File
@@ -1,5 +1,3 @@
/// <reference types="vite/client" />
import type { AppApi } from '../shared/api'
declare global {
+7 -38
View File
@@ -20,16 +20,16 @@ body,
font-size: 13px;
}
/* App-wide slim slate scrollbars: the native Windows style renders light
/* App-wide slim dark scrollbars: the native Windows style renders light
tracks + arrow buttons that look broken on the dark surface. */
* {
scrollbar-width: thin;
scrollbar-color: rgba(125, 145, 170, 0.5) transparent;
scrollbar-color: rgba(121, 121, 121, 0.45) transparent;
}
*::-webkit-scrollbar {
width: 12px;
height: 12px;
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-track {
@@ -37,17 +37,12 @@ body,
}
*::-webkit-scrollbar-thumb {
background: rgba(125, 145, 170, 0.5);
border: 3px solid transparent;
border-radius: 6px;
background-clip: padding-box;
min-height: 28px;
background: rgba(121, 121, 121, 0.45);
border-radius: 5px;
}
*::-webkit-scrollbar-thumb:hover {
background: rgba(150, 172, 200, 0.7);
border: 3px solid transparent;
background-clip: padding-box;
background: rgba(160, 160, 160, 0.6);
}
*::-webkit-scrollbar-button,
@@ -71,29 +66,3 @@ button {
display: flex;
flex-direction: column;
}
/* ---- custom window title bar (native buttons via titleBarOverlay) ---- */
.app-titlebar {
flex: 0 0 36px;
display: flex;
align-items: center;
/* right padding clears the native min/max/close overlay buttons */
padding: 0 148px 0 14px;
-webkit-app-region: drag;
user-select: none;
font-size: 12px;
letter-spacing: 0.2px;
}
.app-titlebar-icon {
width: 16px;
height: 16px;
margin-right: 7px;
border-radius: 4px;
pointer-events: none;
}
.app-titlebar-title {
opacity: 0.75;
font-weight: 500;
}
+9 -40
View File
@@ -1,14 +1,12 @@
import ReactDOM from 'react-dom/client'
import { useEffect, type ReactNode } from 'react'
import { useEffect } from 'react'
import { App as AntdApp, ConfigProvider, theme as antdTheme } from 'antd'
import type { AppApi } from '@shared/api'
import { DEFAULT_SETTINGS } from '@shared/settings'
import { getThemeById } from '@shared/theme'
import App from './App'
import { ErrorBoundary } from './ErrorBoundary'
import ZmodemOffers from './workspace/ZmodemOffers'
import { useSettingsStore } from './settings/store'
import { mix } from './theme/chrome'
import './global.css'
// Dev convenience: allow opening the renderer in a plain browser (vite page)
@@ -86,16 +84,7 @@ if (typeof window !== 'undefined' && !window.api) {
listLayouts: async () => [],
getLayout: async () => null,
saveLayout: async () => undefined,
deleteLayout: async () => undefined,
updateCheck: async () => ({ status: 'dev' as const, currentVersion: 'dev' }),
updateDownload: async () => undefined,
updateInstall: async () => undefined,
updateChangelog: async () => [],
onUpdateState: () => noop,
getSessionState: async () => null,
saveSessionState: async () => null,
resolveCwd: async () => null,
reportCwd: async () => null
deleteLayout: async () => undefined
}
;(window as unknown as { api: AppApi }).api = api
}
@@ -154,33 +143,13 @@ function FontHotkeyListener(): null {
// antd <App> provides the context that App.useApp() consumers (workspace
// message/notification) rely on; without it they get empty stubs and crash.
// ErrorBoundary keeps any render crash from black-screening the whole window.
/** antd surfaces (settings dialog, menus, popovers) follow the terminal theme:
* base/container/elevated backgrounds derive from the theme's background. */
function ThemedConfigProvider({ children }: { children: ReactNode }): React.JSX.Element {
const themeId = useSettingsStore((s) => s.settings.terminal.themeId)
const customThemes = useSettingsStore((s) => s.settings.customThemes)
const colors = getThemeById(themeId, customThemes).colors
return (
<ConfigProvider
theme={{
algorithm: antdTheme.darkAlgorithm,
token: {
colorBgBase: colors.background,
colorBgContainer: mix(colors.background, colors.foreground, 0.06) ?? '#1f1f1f',
colorBgElevated: mix(colors.background, colors.foreground, 0.1) ?? '#252525',
colorTextBase: colors.foreground,
borderRadius: 6
}
}}
>
{children}
</ConfigProvider>
)
}
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<ThemedConfigProvider>
<ConfigProvider
theme={{
algorithm: antdTheme.darkAlgorithm,
token: { colorBgContainer: '#1f1f1f', colorBgElevated: '#252525', borderRadius: 6 }
}}
>
<AntdApp>
<ErrorBoundary>
<App />
@@ -188,5 +157,5 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<FontHotkeyListener />
</ErrorBoundary>
</AntdApp>
</ThemedConfigProvider>
</ConfigProvider>
)
-105
View File
@@ -1,105 +0,0 @@
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={() => void 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>
)
}
+1 -3
View File
@@ -6,7 +6,6 @@ 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'
@@ -36,8 +35,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS
/>
)
},
{ key: 'system', label: '系统', children: <SystemSettingsTab /> },
{ key: 'about', label: '关于', children: <AboutTab /> }
{ key: 'system', label: '系统', children: <SystemSettingsTab /> }
]
const closeEditor = (): void => setEditorState((prev) => (prev.open ? { ...prev, open: false } : prev))
@@ -278,43 +278,6 @@ export function RenderSettingsTab(): React.JSX.Element {
/>
}
/>
<SettingRow
label="输入建议"
desc="输入时弹出命令建议;Tab 接受,回车始终直接执行"
control={
<Switch
checked={settings.terminal.suggestEnabled}
onChange={(checked) => void updateTerminal({ suggestEnabled: checked })}
/>
}
/>
<SettingRow
label="记录命令历史"
desc="关闭后不再记录新命令;已保存的历史仍然保留"
control={
<Switch
checked={settings.terminal.historyEnabled !== false}
onChange={(checked) => void updateTerminal({ historyEnabled: checked })}
/>
}
/>
<SettingRow
label="历史条数上限"
desc="命令历史最多保留多少条,重复命令只记一次"
control={
<InputNumber
min={1}
max={500}
step={10}
value={settings.terminal.historyLimit}
disabled={settings.terminal.historyEnabled === false}
onChange={(value) => {
if (typeof value === 'number') void updateTerminal({ historyLimit: value })
}}
style={{ width: 110 }}
/>
}
/>
</div>
)
}
@@ -344,42 +307,6 @@ export function SystemSettingsTab(): React.JSX.Element {
/>
}
/>
<SettingRow
label="启动时恢复上次会话"
desc="下次启动恢复分屏布局,并让每个终端回到它上次所在的目录;关闭则每次都以单个终端启动"
control={
<Switch
checked={settings.system.restoreSession !== false}
onChange={(checked) => void updateSystem({ restoreSession: checked })}
/>
}
/>
<SettingRow
label="Shell 集成"
desc="让 shell 主动上报工作目录(OSC 7),路径记忆更精确(含脚本内的 cd);会包装你的命令提示符,遇到提示符主题异常时关掉即可"
control={
<Switch
checked={settings.system.shellIntegration === true}
onChange={(checked) => void updateSystem({ shellIntegration: checked })}
/>
}
/>
<SettingRow
label="关闭按钮行为"
desc="点击窗口关闭按钮时的动作;最小化到托盘后可在托盘图标右键菜单中退出"
control={
<Select
className="settings-select"
value={settings.system.closeAction ?? 'tray'}
onChange={(value) => void updateSystem({ closeAction: value })}
options={[
{ value: 'tray', label: '最小化到托盘' },
{ value: 'exit', label: '直接退出' },
{ value: 'ask', label: '每次询问' }
]}
/>
}
/>
<SettingRow
label="全局唤起快捷键"
desc="Control+Shift+Alt+T 等,留空禁用"
+2 -22
View File
@@ -1,5 +1,5 @@
import { useMemo } from 'react'
import { Button, ColorPicker, Popconfirm } from 'antd'
import { Button, Popconfirm } from 'antd'
import type { TerminalSettings } from '@shared/settings'
import { DEFAULT_SETTINGS } from '@shared/settings'
import type { TerminalTheme, ThemeColors } from '@shared/theme'
@@ -46,26 +46,7 @@ export function ThemeSettingsTab({
}
return (
<div className="theme-pane">
<div className="theme-accent-row">
<div>
<div className="settings-block-label">标签强调色</div>
<div className="settings-block-hint">活动标签边框、侧栏指示条与 SSH 徽章的强调颜色</div>
</div>
<div className="theme-accent-controls">
<ColorPicker
value={settings.terminal.tabAccentColor}
onChange={(color) => void updateTerminal({ tabAccentColor: color.toHexString() })}
/>
<Button
size="small"
onClick={() => void updateTerminal({ tabAccentColor: DEFAULT_SETTINGS.terminal.tabAccentColor })}
>
恢复默认
</Button>
</div>
</div>
<div className="theme-gallery">
<div className="theme-gallery">
{themeCards.map((t) => {
const selected = t.id === activeTheme.id
const isCustom = t.builtin === false
@@ -135,7 +116,6 @@ export function ThemeSettingsTab({
<div className="theme-card-new-icon">+</div>
<div className="theme-card-new-text">新建主题</div>
</div>
</div>
</div>
)
}
+5 -128
View File
@@ -5,13 +5,13 @@
/* ---------- settings dialog scaffolding ---------- */
.ant-modal-header {
border-bottom: 1px solid var(--chrome-border);
border-bottom: 1px solid #2d2d2d;
padding-bottom: 14px;
}
.ant-modal .ant-tabs-nav {
margin-bottom: 8px;
border-bottom: 1px solid var(--chrome-border);
border-bottom: 1px solid #2d2d2d;
}
.settings-footer {
@@ -116,7 +116,7 @@
margin-top: 14px;
border-radius: 6px;
padding: 12px 14px;
border: 1px solid var(--chrome-border);
border: 1px solid #2d2d2d;
overflow: hidden;
}
@@ -140,30 +140,6 @@
/* ---------- theme gallery ---------- */
/* ---- theme tab: accent picker + gallery ---- */
.theme-pane {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
}
.theme-accent-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--chrome-border);
border-radius: 8px;
}
.theme-accent-controls {
display: flex;
align-items: center;
gap: 8px;
}
.theme-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
@@ -357,7 +333,7 @@
.theme-editor-preview-box {
border-radius: 6px;
padding: 10px 14px;
border: 1px solid var(--chrome-border);
border: 1px solid #2d2d2d;
font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace;
font-size: 12px;
}
@@ -367,103 +343,4 @@
line-height: 1.6;
overflow: hidden;
text-overflow: ellipsis;
}
/* ---- about / updater tab ---- */
.about-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 18px;
}
.about-logo {
width: 48px;
height: 48px;
border-radius: 12px;
background: var(--chrome-hover);
color: #3fd06a;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 18px;
}
.about-name {
font-size: 16px;
font-weight: 600;
}
.about-version {
font-size: 12px;
opacity: 0.7;
margin-top: 3px;
}
.about-status {
margin-left: 10px;
}
.about-status.is-new {
color: #3fd06a;
}
.about-error {
color: #f85149;
margin-top: 4px;
font-size: 12px;
word-break: break-all;
}
.about-controls {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 18px;
}
.about-autocheck {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
opacity: 0.85;
}
.about-changelog {
border-top: 1px solid rgba(255, 255, 255, 0.08);
padding-top: 12px;
}
.about-changelog-title {
font-weight: 600;
margin-bottom: 10px;
}
.about-changelog-empty {
opacity: 0.5;
font-size: 12px;
}
.about-release {
margin-bottom: 14px;
}
.about-release-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.about-release-date {
font-size: 12px;
opacity: 0.6;
}
.about-release-body {
font-size: 12px;
opacity: 0.85;
white-space: pre-wrap;
line-height: 1.6;
}
}
+18 -229
View File
@@ -6,7 +6,7 @@ import { FitAddon } from '@xterm/addon-fit'
import { WebglAddon } from '@xterm/addon-webgl'
import { SearchAddon } from '@xterm/addon-search'
import '@xterm/xterm/css/xterm.css'
import { ClearOutlined, CopyOutlined, FolderOpenOutlined, PauseOutlined, SearchOutlined, SelectOutlined, SnippetsOutlined, SoundOutlined } from '@ant-design/icons'
import { FolderOpenOutlined, PauseOutlined, SoundOutlined } from '@ant-design/icons'
import { getThemeById } from '@shared/theme'
import type { CommandItem } from '@shared/commands'
import type { PtyDataEvent, PtyExitEvent } from '@shared/ipc'
@@ -14,8 +14,6 @@ import type { TerminalSettings } from '@shared/settings'
import { useSettingsStore } from '@renderer/settings/store'
import { writeBroadcast } from '@renderer/workspace/broadcastStore'
import { compileRules, HighlightStream, type CompiledRule } from './highlightEngine'
import { cdArgument, conemuCwd } from './cwdTracker'
import { getSessionCwd, setSessionCwd } from '@renderer/workspace/sessionCwdStore'
import './terminal.css'
/** Platform-modifier-based chord (Cmd on macOS, Ctrl elsewhere), plus Shift. */
@@ -46,13 +44,6 @@ const SEARCH_DECORATIONS: NonNullable<NonNullable<Parameters<SearchAddon['findNe
activeMatchColorOverviewRuler: '#c77800'
}
// xterm's default overviewRulerBorder (#7f7f7f) renders as a light vertical line
// on the dark surface once scrollback exists. Force it transparent; search-match
// marks in the ruler keep their own colors.
function withChromeColors(colors: ITheme): ITheme {
return { ...colors, overviewRulerBorder: '#00000000' }
}
export interface TerminalHandle {
focus(): void
clear(): void
@@ -156,7 +147,7 @@ function stepLineBuffer(
// clear-line) only removes buffer content; never appends.
const first = data[0]
if (b === '' && first === '\x1b') return { line: '' }
if (first === '\r') return submitOr(outBuf, b)
if (first === '\r') return submitOr(b)
if (first === '\x7f') {
outBuf.current = b.slice(0, -1)
return { line: outBuf.current }
@@ -170,12 +161,8 @@ function stepLineBuffer(
return { line: outBuf.current }
}
function submitOr(outBuf: React.MutableRefObject<string>, b: string): { line: string; submit?: string } {
function submitOr(b: string): { line: string; submit?: string } {
const t = b.trim()
// Commit the line out of the buffer: the shell has it now, and anything typed
// next belongs to a fresh line. Leaving it in made every following command
// accumulate onto the previous one (`ls` + `pwd` → `lspwd` in the history).
outBuf.current = ''
return t ? { line: '', submit: t } : { line: '', submit: '' }
}
@@ -232,13 +219,6 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
const searchRef = useRef<SearchAddon | null>(null)
const observerRef = useRef<ResizeObserver | null>(null)
const timerRef = useRef<number>(0)
// PTY resize bookkeeping: `ptyTimerRef` debounces the resize while a window
// drag/maximize settles, `ptySizeRef` drops the no-op resizes in between.
// Poking the pty on every intermediate layout tick makes full-screen TUIs
// (Claude Code et al.) redraw repeatedly at half-settled sizes, which is what
// leaves duplicated frames behind in the scrollback.
const ptyTimerRef = useRef<number>(0)
const ptySizeRef = useRef<{ cols: number; rows: number } | null>(null)
const deadRef = useRef(false)
const searchOpenRef = useRef(false)
const searchTermRef = useRef('')
@@ -293,10 +273,6 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
const [searchOpen, setSearchOpen] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const [resultInfo, setResultInfo] = useState('')
// Right-click context menu: position + whether a selection existed at open
// time (decides if 复制 is enabled).
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null)
const [menuHasSel, setMenuHasSel] = useState(false)
const [paste, setPaste] = useState<string | null>(null)
const [dead, setDead] = useState(false)
const [exitCode, setExitCode] = useState(0)
@@ -350,66 +326,12 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
return
}
const term = termRef.current
if (!term || deadRef.current) return
// The xterm grid is already correct; only tell the pty once it settles.
const last = ptySizeRef.current
if (last && last.cols === term.cols && last.rows === term.rows) return
const send = (): void => {
const live = termRef.current
if (!live || deadRef.current) return
if (live.cols === ptySizeRef.current?.cols && live.rows === ptySizeRef.current?.rows) return
ptySizeRef.current = { cols: live.cols, rows: live.rows }
window.api.resizePty(sessionId, live.cols, live.rows)
if (term && !deadRef.current) {
window.api.resizePty(sessionId, term.cols, term.rows)
}
window.clearTimeout(ptyTimerRef.current)
// First size for a session goes out immediately (the pty spawns at a
// placeholder 80x24 and a shell may already be drawing); later changes —
// window drags, maximize/restore — wait for the layout to settle.
if (!last) {
send()
return
}
// 100ms mirrors what other Electron terminals use: long enough to swallow
// a maximize/restore animation tick storm, short enough that a plain
// window drag does not visibly wrap against a stale width.
ptyTimerRef.current = window.setTimeout(send, 100)
})
}, [sessionId])
const openSearch = useCallback(() => {
searchOpenRef.current = true
setSearchOpen(true)
}, [])
const openContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault()
setMenuHasSel(Boolean(termRef.current?.getSelection()))
// Clamp so the menu never overflows the window edge.
const x = Math.min(e.clientX, window.innerWidth - 216)
const y = Math.min(e.clientY, window.innerHeight - 200)
setMenuPos({ x: Math.max(x, 4), y: Math.max(y, 4) })
}, [])
// Global dismiss: Escape / scroll / window blur close the context menu.
useEffect(() => {
if (!menuPos) return
const close = (): void => setMenuPos(null)
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') {
e.stopPropagation()
close()
}
}
window.addEventListener('keydown', onKey, true)
window.addEventListener('wheel', close, true)
window.addEventListener('blur', close)
return () => {
window.removeEventListener('keydown', onKey, true)
window.removeEventListener('wheel', close, true)
window.removeEventListener('blur', close)
}
}, [menuPos])
const closeSearch = useCallback(() => {
searchOpenRef.current = false
searchRef.current?.clearDecorations()
@@ -541,7 +463,8 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
}
const mod = navigator.platform.toLowerCase().includes('mac') ? e.metaKey : e.ctrlKey
if ((e.key === 'f' || e.key === 'F') && mod && !e.altKey) {
openSearch()
searchOpenRef.current = true
setSearchOpen(true)
return false
}
if (isModShiftChord(e, 'c')) {
@@ -571,27 +494,20 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
moveSelection(-1)
return false
}
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`).
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 关闭).
const selected = list[selIndexRef.current] ?? list[0]
if (selected) handleTab(selected)
return false
}
if (e.key === 'Enter') {
// Never let the popup swallow Enter: dismiss it and submit as typed.
suggestionsRef.current = []
selIndexRef.current = 0
setSuggestions([])
setSuggestionIndex(0)
return true
}
}
return true
},
[closeSearch, openSearch, copySelection, pasteFromClipboard, moveSelection, handleTab]
[closeSearch, copySelection, pasteFromClipboard, moveSelection, handleTab]
)
// ---- terminal instance: built once per session ----
@@ -607,7 +523,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
cursorBlink: tSettings.cursorBlink,
cursorStyle: tSettings.cursorStyle,
cursorInactiveStyle: tSettings.cursorInactiveStyle,
theme: withChromeColors(getThemeById(tSettings.themeId, settings.customThemes).colors as ITheme),
theme: getThemeById(tSettings.themeId, settings.customThemes).colors as ITheme,
allowProposedApi: true,
overviewRuler: { width: 9, showTopBorder: false, showBottomBorder: false },
drawBoldTextInBrightColors: true,
@@ -640,29 +556,6 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
term.loadAddon(searchAddon)
searchRef.current = searchAddon
// cwd reports straight from the shell: OSC 7 is the portable form (bash,
// zsh, fish, iTerm2, WezTerm, kitty all speak it), OSC 9;9 is ConEmu's,
// used by the Windows shells. Normalization happens in the main process.
const reportCwd = (payload: string): void => {
void window.api
.reportCwd(payload)
.then((cwd: string | null) => {
if (cwd) setSessionCwd(sessionId, cwd)
})
.catch(() => undefined)
}
const oscDisposables = [
term.parser.registerOscHandler(7, (payload) => {
reportCwd(payload)
return true
}),
term.parser.registerOscHandler(9, (payload) => {
const value = conemuCwd(payload)
if (value) reportCwd(value)
return true
})
]
termRef.current = term
// Rebind (e.g. template apply) must clear a death marker left by the
// previous session's exit — otherwise the pane stays under the dead mask.
@@ -672,7 +565,6 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
// M5: reset per-session state on (re)bind — fresh line buffer, no leftovers
// from a template apply or panel reuse; recording resumes idle.
lineBufRef.current = ''
ptySizeRef.current = null
setSuggestions([])
setSuggestionIndex(0)
if (recordingRef.current) {
@@ -707,43 +599,16 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
scheduleCarryFlush()
}
const disposables: { dispose(): void }[] = [
...oscDisposables,
term.onData((data) => {
if (!deadRef.current) writeBroadcast(sessionId, data)
// M5: a completion acceptance rewrite armed `diffRewriteRef`; consume it
// now so this redraw chunk replaces (not appends to) the line buffer.
const consumeRewrite = diffRewriteRef.current
diffRewriteRef.current = false
// Full-screen TUIs (vim, Claude Code, …) own the alternate buffer, their
// own key handling and their own cursor: the line buffer, the command
// history and the completion popup have no business there. Interfering
// swallowed ↑/↓/Tab/Esc (Esc could not even leave vim's insert mode) and
// wrote TUI keystrokes into the command history.
if (term.buffer.active.type === 'alternate') {
lineBufRef.current = ''
if (suggestionsRef.current.length > 0) {
suggestionsRef.current = []
selIndexRef.current = 0
setSuggestions([])
setSuggestionIndex(0)
}
return
}
// M5: line capture → recordCommand + inline completion overlay.
const step = stepLineBuffer(lineBufRef, data, consumeRewrite)
if (typeof step.submit === 'string' && step.submit) {
void window.api.recordCommand(step.submit).catch(() => undefined)
// Remember the directory for the next launch: a cd-style line is
// resolved in the main process (the only side with node's `path`).
const cdArg = cdArgument(step.submit)
if (cdArg !== null) {
void window.api
.resolveCwd(getSessionCwd(sessionId), cdArg)
.then((next: string | null) => {
if (next) setSessionCwd(sessionId, next)
})
.catch(() => undefined)
}
}
if (deadRef.current) {
suggestionsRef.current = []
@@ -752,14 +617,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
setSuggestionIndex(0)
} else if (step.line) {
ensureCompletionCache()
// 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
)
: []
const list = buildSuggestions(completionCacheRef.current, step.line)
suggestionsRef.current = list
selIndexRef.current = Math.min(selIndexRef.current, Math.max(list.length - 1, 0))
if (list.length === 0) selIndexRef.current = 0
@@ -830,7 +688,6 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
return () => {
if (carryTimer !== undefined) window.clearTimeout(carryTimer)
if (ptyTimerRef.current) window.clearTimeout(ptyTimerRef.current)
for (const unsubscribe of unsubscribes) unsubscribe()
for (const disposable of disposables) disposable.dispose()
observer?.disconnect()
@@ -866,10 +723,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
term.options.cursorBlink = tSettings.cursorBlink
term.options.cursorStyle = tSettings.cursorStyle
term.options.cursorInactiveStyle = tSettings.cursorInactiveStyle
// Live-appliable in xterm 6: without this the setting only took effect for
// terminals opened after the change, which reads as "the setting is broken".
term.options.scrollback = tSettings.scrollback
term.options.theme = withChromeColors(getThemeById(tSettings.themeId, settings.customThemes).colors as ITheme)
term.options.theme = { ...(getThemeById(tSettings.themeId, settings.customThemes).colors as ITheme) }
scheduleFit()
}, [tSettings, settings.customThemes, scheduleFit])
@@ -959,73 +813,8 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
searchTermRef.current = value
}, [])
const runMenuAction = useCallback(
(action: () => void) => {
setMenuPos(null)
action()
termRef.current?.focus()
},
[]
)
return (
<div className={terminalClassName} onMouseDown={() => termRef.current?.focus()} onContextMenu={openContextMenu}>
{menuPos && (
<>
<div
className="term-menu-overlay"
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
setMenuPos(null)
termRef.current?.focus()
}}
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
setMenuPos(null)
}}
/>
<div className="term-menu" style={{ left: menuPos.x, top: menuPos.y }}>
<button
type="button"
className="term-menu-item"
disabled={!menuHasSel}
onClick={() => runMenuAction(() => void copySelection())}
>
<CopyOutlined />
<span className="term-menu-label">复制</span>
<kbd>Ctrl+Shift+C</kbd>
</button>
<button
type="button"
className="term-menu-item"
disabled={dead}
onClick={() => runMenuAction(() => void pasteFromClipboard())}
>
<SnippetsOutlined />
<span className="term-menu-label">粘贴</span>
<kbd>Ctrl+Shift+V</kbd>
</button>
<div className="term-menu-sep" />
<button type="button" className="term-menu-item" onClick={() => runMenuAction(openSearch)}>
<SearchOutlined />
<span className="term-menu-label">查找</span>
<kbd>Ctrl+F</kbd>
</button>
<button type="button" className="term-menu-item" onClick={() => runMenuAction(() => termRef.current?.selectAll())}>
<SelectOutlined />
<span className="term-menu-label">全选</span>
<kbd />
</button>
<button type="button" className="term-menu-item" onClick={() => runMenuAction(() => termRef.current?.clear())}>
<ClearOutlined />
<span className="term-menu-label">清屏</span>
<kbd />
</button>
</div>
</>
)}
<div className={terminalClassName} onMouseDown={() => termRef.current?.focus()}>
{searchOpen && (
<SearchBar
value={searchTerm}
-39
View File
@@ -1,39 +0,0 @@
/**
* Working-directory tracking helpers.
*
* Two signals feed the same store:
* 1. the shell itself, over OSC 7 (`file://host/path`) or ConEmu's OSC 9;9 —
* exact, but only present when shell integration is on;
* 2. the command the user typed, matched against the `cd` family — the
* fallback that works with no shell cooperation at all.
*
* Both are platform-neutral: path resolution and existence checks happen in the
* main process (`src/main/cwd.ts`) where node's `path` lives, so nothing here
* assumes backslashes or drive letters.
*/
/**
* The `cd` family, per shell:
* bash/zsh/dash/fish cd
* cmd.exe cd, chdir
* PowerShell cd (alias), sl (alias), Set-Location, pushd/Push-Location
* The `$` anchor is what keeps lookalikes out: `cdrom`, `cdemo` never match.
*/
const CD_COMMAND = /^\s*(?:cd|chdir|sl|set-location|pushd|push-location)(?:\s+([\s\S]*))?$/i
/**
* Return the argument of a cd-style command line, or null when the line is not
* one. An empty string means "bare cd" (the shell's home directory).
*/
export function cdArgument(line: string): string | null {
const match = line.match(CD_COMMAND)
if (!match) return null
return (match[1] ?? '').trim()
}
/** Payload of an OSC 9 sequence that follows the ConEmu `9;<path>` convention. */
export function conemuCwd(payload: string): string | null {
if (!payload.startsWith('9;')) return null
const value = payload.slice(2).trim()
return value || null
}
+7 -88
View File
@@ -13,24 +13,15 @@
height: 100%;
}
/* xterm.css defaults .xterm-viewport to #000; when the pane height isn't a
multiple of the cell height the leftover bottom strip shows that black.
Pin it to the (theme-derived) chrome background so the gap is invisible. */
.terminal-view .xterm .xterm-viewport {
background-color: var(--chrome-bg);
}
/* Slim slate scrollbar for the xterm viewport — the native Windows one renders
light track + arrow buttons that look broken on the dark surface. The thumb
sits inset inside a wider grab lane (transparent border + padding-box clip)
so it reads as a soft slate bar, never a white line. */
/* Dark slim scrollbar for the xterm viewport — the native Windows one renders
light track + arrow buttons that look broken on the dark surface. */
.terminal-view .xterm-viewport {
scrollbar-width: thin;
scrollbar-color: rgba(125, 145, 170, 0.5) transparent;
scrollbar-color: rgba(121, 121, 121, 0.45) transparent;
}
.terminal-view .xterm-viewport::-webkit-scrollbar {
width: 12px;
width: 10px;
}
.terminal-view .xterm-viewport::-webkit-scrollbar-track {
@@ -38,17 +29,12 @@
}
.terminal-view .xterm-viewport::-webkit-scrollbar-thumb {
background: rgba(125, 145, 170, 0.5);
border: 3px solid transparent;
border-radius: 6px;
background-clip: padding-box;
min-height: 28px;
background: rgba(121, 121, 121, 0.45);
border-radius: 5px;
}
.terminal-view .xterm-viewport::-webkit-scrollbar-thumb:hover {
background: rgba(150, 172, 200, 0.7);
border: 3px solid transparent;
background-clip: padding-box;
background: rgba(160, 160, 160, 0.6);
}
.terminal-view .xterm-viewport::-webkit-scrollbar-button,
@@ -118,73 +104,6 @@
white-space: nowrap;
}
/* ---- right-click context menu ---- */
.term-menu-overlay {
position: fixed;
inset: 0;
z-index: 40;
}
.term-menu {
position: fixed;
z-index: 41;
min-width: 200px;
padding: 4px;
border: 1px solid var(--chrome-border);
border-radius: 8px;
background: var(--chrome-bg-deep);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
font-size: 12px;
color: var(--chrome-fg);
user-select: none;
}
.term-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 10px;
border: none;
border-radius: 5px;
background: transparent;
color: var(--chrome-fg);
font: inherit;
cursor: pointer;
text-align: left;
}
.term-menu-item:hover:not(:disabled),
.term-menu-item:focus-visible {
background: var(--chrome-hover);
color: var(--chrome-accent);
}
.term-menu-item:disabled {
opacity: 0.4;
cursor: default;
}
.term-menu-label {
flex: 1;
text-align: left;
}
.term-menu-item > kbd {
border: none;
background: transparent;
color: var(--chrome-fg);
opacity: 0.55;
font: inherit;
font-size: 11px;
}
.term-menu-sep {
height: 1px;
margin: 4px 8px;
background: var(--chrome-border);
}
/* ---- session log record / open-dir buttons (floating, top-left) ---- */
.term-recbar {
position: absolute;
-51
View File
@@ -1,51 +0,0 @@
import type { ThemeColors } from '@shared/theme'
function parseHex(color: string): [number, number, number] | null {
const m = /^#([0-9a-f]{6})$/i.exec(color.trim())
if (!m) return null
const n = parseInt(m[1], 16)
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}
function toHex(v: number): string {
return Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
}
/** t=0 → a, t=1 → b */
export function mix(a: string, b: string, t: number): string | null {
const ca = parseHex(a)
const cb = parseHex(b)
if (!ca || !cb) return null
return `#${toHex(ca[0] + (cb[0] - ca[0]) * t)}${toHex(ca[1] + (cb[1] - ca[1]) * t)}${toHex(ca[2] + (cb[2] - ca[2]) * t)}`
}
/** Derive the app chrome palette (sidebar, tab bars, dividers) from the active
* terminal theme so the whole window follows theme switches instead of only
* the terminal canvas. Values land on :root CSS variables; anything the theme
* can't provide keeps the static fallback in global.css. */
export function applyChromeTheme(colors: ThemeColors): void {
const style = document.documentElement.style
const set = (key: string, value: string | null): void => {
if (value) style.setProperty(key, value)
}
set('--chrome-bg', colors.background)
set('--chrome-fg', colors.foreground)
set('--chrome-bg-deep', mix(colors.background, '#000000', 0.35))
set('--chrome-hover', mix(colors.background, colors.foreground, 0.08))
set('--chrome-border', mix(colors.background, colors.foreground, 0.16))
}
/** Tab accent: base color + a lightened foreground variant for text on dark
* tinted backgrounds. Consumed via var(--tab-accent*) in workspace.css. */
export function applyTabAccent(color: string): void {
const style = document.documentElement.style
// Settings hydrated from an older main process may lack the key; clear the
// vars so the CSS fallbacks apply instead of an invalid "undefined" value.
if (!color) {
style.removeProperty('--tab-accent')
style.removeProperty('--tab-accent-fg')
return
}
style.setProperty('--tab-accent', color)
style.setProperty('--tab-accent-fg', mix(color, '#ffffff', 0.18) ?? color)
}
+44 -416
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { App, Button, Checkbox, Popover, Tooltip } from 'antd'
import { App, Button, Checkbox, Dropdown, Popover, Tooltip } from 'antd'
import type { MenuProps } from 'antd'
import {
CloudServerOutlined,
@@ -16,7 +16,6 @@ import {
import { DockviewReact } from 'dockview-react'
import type {
DockviewApi,
DockviewPanelApi,
DockviewReadyEvent,
IDockviewHeaderActionsProps,
IDockviewPanel,
@@ -34,9 +33,6 @@ import { TerminalPanel } from './TerminalPanel'
import { ApplyTemplateModal, SaveTemplateModal, TerminalTab } from './TerminalTab'
import { useBroadcastStore, writeBroadcast } from './broadcastStore'
import { QuickInputPanel, useQuickInputStore } from './QuickInputPanel'
import { forgetSessionCwd, getSessionCwd, onSessionCwdChange, setSessionCwd } from './sessionCwdStore'
import { useSettingsStore } from '@renderer/settings/store'
import type { SessionSnapshot, SessionPanelState } from '@shared/ipc'
import type { WorkspaceMode } from './workspaceMode'
import { useWorkspaceModeStore } from './workspaceModeStore'
import './workspace.css'
@@ -73,97 +69,8 @@ function nextTerminalTitle(): string {
return `终端 ${titleSeqHolder.__otTitleSeq}`
}
function sessionIdOf(panel: IDockviewPanel | undefined): string | undefined {
return (panel?.params as TerminalParams | undefined)?.sessionId
}
/**
* A dockview panel id.
*
* Panel ids must never be reused as pty session ids. `updateParameters` can
* swap the session behind a panel, but the panel id is fixed for the panel's
* whole life, so an id that doubles as a session id goes stale the moment the
* pane is rebound: the restored pane would be addressed by an id that no longer
* names any session, and it comes up with no pty behind it. Ids here are opaque
* and distinct from any session id by construction.
*/
function panelId(): string {
return `p-${window.crypto.randomUUID().slice(0, 8)}`
}
interface RawLayout {
grid?: unknown
panels?: Record<string, { id?: string; params?: { sessionId?: string } }>
activeGroup?: string
}
interface RenamedLayout {
layout: unknown
/** old panel id → new panel id, so a caller holding a snapshot keyed by the
* old ids can still find a panel's recorded state afterwards. */
renames: Map<string, string>
}
/**
* Rewrite a snapshot layout so no panel id equals its session id.
*
* Older builds created panels with `id: sessionId`, and those snapshots are
* still on disk. `updateParameters` cannot rename a panel, so the id has to be
* swapped in the serialized layout where it appears in all three places that
* must agree: the `panels` map key, the panel's own `id`, and every `views`
* entry in the grid tree.
*
* Returns the layout untouched when no collision exists (the common case), so
* the fast path stays a plain object reference.
*/
function breakIdCoincidence(layout: unknown): RenamedLayout {
if (!layout || typeof layout !== 'object') return { layout, renames: new Map() }
const raw = layout as RawLayout
const panels = raw.panels
if (!panels) return { layout, renames: new Map() }
const renames = new Map<string, string>()
for (const [key, panel] of Object.entries(panels)) {
const sessionId = panel?.params?.sessionId
if (sessionId && key === sessionId) renames.set(key, panelId())
}
if (renames.size === 0) return { layout, renames }
const next: RawLayout = { ...raw, panels: {} }
for (const [key, panel] of Object.entries(panels)) {
const target = renames.get(key)
next.panels![target ?? key] = target ? { ...panel, id: target } : panel
}
// `views` / `activeView` are the only fields naming panels. Three shapes to
// tell apart, and the walk must preserve whichever is present:
// grid { root, width, height, orientation }
// branch { type: 'branch', data: node[], size }
// leaf { type: 'leaf', data: { views, activeView, id }, size }
const renameLeaf = (leaf: Record<string, unknown>): Record<string, unknown> => {
if (!Array.isArray(leaf.views)) return leaf
return {
...leaf,
views: leaf.views.map((v) => (typeof v === 'string' ? renames.get(v) ?? v : v)),
activeView:
typeof leaf.activeView === 'string' ? renames.get(leaf.activeView) ?? leaf.activeView : leaf.activeView
}
}
const renameNode = (node: unknown): unknown => {
if (Array.isArray(node)) return node.map(renameNode)
if (!node || typeof node !== 'object') return node
const entry = node as Record<string, unknown>
if (entry.type === 'leaf' || entry.type === 'branch') {
const data = entry.data
if (entry.type === 'leaf' && data && typeof data === 'object' && !Array.isArray(data)) {
return { ...entry, data: renameLeaf(data as Record<string, unknown>) }
}
return { ...entry, data: renameNode(data) }
}
// The grid wrapper (and anything else shaped like it) keys its tree on `root`.
if (entry.root !== undefined) return { ...entry, root: renameNode(entry.root) }
return node
}
next.grid = renameNode(raw.grid)
return { layout: next, renames }
function sessionIdOf(panel: IDockviewPanel): string | undefined {
return (panel.params as TerminalParams | undefined)?.sessionId
}
/** The dockview api that owns a given workspace mode. */
@@ -185,9 +92,8 @@ function createTabActions(mode: WorkspaceMode): (props: IDockviewHeaderActionsPr
}
void (async (): Promise<void> => {
const result = await window.api.createPty()
setSessionCwd(result.id, result.cwd)
containerApi.addPanel<TerminalParams>({
id: panelId(),
id: result.id,
component: TERMINAL_COMPONENT,
tabComponent: TERMINAL_TAB_COMPONENT,
title: nextTerminalTitle(),
@@ -279,54 +185,6 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
/** B's sidebar handle; refresh() after a successful SSH connect */
const sidebarRef = useRef<ConnectionSidebarHandle>(null)
// ---- session memory (restore last layout + per-pane cwd) ----
/** Set once the boot pass has read the snapshot; saves stay quiet until then. */
const bootDoneRef = useRef(false)
/** Debounce handle for the snapshot write. */
const saveTimerRef = useRef(0)
/** Newest local directory seen — seeds brand-new terminals. */
const lastLocalCwdRef = useRef<string | null>(null)
/** Assemble what the next launch needs: both layouts, every pane's kind and
* directory, and the workspace the user was in. */
const buildSnapshot = useCallback((): SessionSnapshot => {
const collect = (api: DockviewApi | undefined): SessionPanelState[] => {
if (!api) return []
return api.panels.map((panel) => {
const params = panel.params as TerminalParams | undefined
return {
panelId: panel.id,
title: panel.title ?? '',
kind: params?.sessionKind === 'ssh' ? 'ssh' : 'local',
cwd: getSessionCwd(params?.sessionId),
hostLabel: params?.hostLabel
}
})
}
return {
version: 1,
savedAt: Date.now(),
mode: useWorkspaceModeStore.getState().mode,
layouts: {
terminal: terminalApiRef.current?.toJSON(),
ssh: sshApiRef.current?.toJSON()
},
panels: [...collect(terminalApiRef.current), ...collect(sshApiRef.current)],
lastLocalCwd: lastLocalCwdRef.current ?? undefined
}
}, [])
/** Debounced write (layout changes stream during window drags). */
const scheduleSave = useCallback((): void => {
// Never overwrite the snapshot before the boot pass has read it — an empty
// layout would otherwise erase the very thing we are about to restore.
if (!bootDoneRef.current) return
window.clearTimeout(saveTimerRef.current)
saveTimerRef.current = window.setTimeout(() => {
void window.api.saveSessionState(buildSnapshot()).catch(() => undefined)
}, 500)
}, [buildSnapshot])
/** Idempotently kill a pty session. */
const killSession = useCallback((sessionId: string | undefined): void => {
if (!sessionId) return
@@ -340,13 +198,8 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
deadSessionsRef.current.add(sessionId)
}, [])
const createSession = useCallback(async (cwd?: string): Promise<string> => {
// `cwd` is the memory hook: a restored pane and a freshly opened one both
// start where the user left off. The main process validates the path and
// answers with the directory it actually used, which seeds the cwd store so
// an immediate snapshot save already reflects it.
const result = await window.api.createPty(cwd ? { cwd } : {})
setSessionCwd(result.id, result.cwd)
const createSession = useCallback(async (): Promise<string> => {
const result = await window.api.createPty()
return result.id
}, [])
@@ -390,41 +243,11 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
if (mode === 'terminal') terminalApiRef.current = api
else sshApiRef.current = api
// One pty/ssh session can back several panels — an SSH split mirrors a
// single session on purpose. So closing a panel must not kill a session
// another panel is still showing; count the panels per session (seeding
// from the panels already present, e.g. a restored layout) and kill only
// when the last one goes away.
const useCount = new Map<string, number>()
for (const existing of api.panels) {
const sid = sessionIdOf(existing)
if (sid) useCount.set(sid, (useCount.get(sid) ?? 0) + 1)
}
const countPanel = (panel: IDockviewPanel): void => {
const sid = sessionIdOf(panel)
if (sid) useCount.set(sid, (useCount.get(sid) ?? 0) + 1)
}
const releaseSession = (panel: IDockviewPanel): void => {
const sid = sessionIdOf(panel)
if (!sid) return
const left = (useCount.get(sid) ?? 1) - 1
if (left > 0) {
useCount.set(sid, left)
return
}
useCount.delete(sid)
killSession(sid)
}
const cleanups = [
api.onDidRemovePanel((panel: IDockviewPanel) => {
// Closing a pane keeps its directory as the seed for the next one.
const closing = getSessionCwd(sessionIdOf(panel))
if (closing) lastLocalCwdRef.current = closing
releaseSession(panel)
killSession(sessionIdOf(panel))
recomputeLocalPanels()
recountAll()
scheduleSave()
}),
api.onDidActivePanelChange((event) => {
if (mode === 'terminal') activeTerminalPanelRef.current = event.panel ?? undefined
@@ -437,17 +260,12 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
api.onDidLayoutChange(() => {
recomputeLocalPanels()
recountAll()
scheduleSave()
}),
api.onDidAddPanel((panel: IDockviewPanel) => {
countPanel(panel)
recountAll()
scheduleSave()
})
api.onDidAddPanel(() => recountAll())
]
disposablesRef.current.push(...cleanups)
},
[killSession, recomputeLocalPanels, recountAll, scheduleSave]
[killSession, recomputeLocalPanels, recountAll]
)
const handleTerminalReady = useCallback(
@@ -491,46 +309,14 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
return useWorkspaceModeStore.subscribe(sync)
}, [])
/** Session memory follows the directories the panes report and the workspace
* the user is in — both feed the debounced snapshot write. */
useEffect(() => {
const offCwd = onSessionCwdChange(() => {
const active = getSessionCwd(sessionIdOf(activeTerminalPanelRef.current))
if (active) lastLocalCwdRef.current = active
scheduleSave()
})
const offMode = useWorkspaceModeStore.subscribe(() => scheduleSave())
return () => {
offCwd()
offMode()
}
}, [scheduleSave])
/**
* Boot pass: pick up where the user left off — layout plus each pane's
* directory — when 设置 → 系统 → 启动时恢复上次会话 is on. Otherwise (or when no
* snapshot exists) seed one terminal. Either way the workspace is never empty.
*/
/** First run: seed one local terminal so the terminal workspace is never
* empty on launch. Runs once, after the terminal dockview is ready. */
useEffect(() => {
if (!terminalReady) return
let cancelled = false
void (async (): Promise<void> => {
const current = terminalApiRef.current
if (!current) return
const restoreOn = useSettingsStore.getState().settings.system.restoreSession !== false
const snapshot = restoreOn ? await window.api.getSessionState().catch(() => null) : null
if (cancelled) return
const restorable = Boolean(snapshot && (snapshot.layouts.terminal || snapshot.panels.length > 0))
const restored = snapshot && restorable ? await restoreFromSnapshot(snapshot) : false
if (cancelled) return
if (!restored && !current.panels.some((p) => sessionIdOf(p))) void addTerminal()
bootDoneRef.current = true
scheduleSave()
})()
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- boot exactly once, on first ready
const current = terminalApiRef.current
if (current && !current.panels.some((p) => sessionIdOf(p))) void addTerminal()
// seed the terminal workspace exactly once, on first ready
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [terminalReady])
/** Kill remaining sessions on window close. */
@@ -610,15 +396,11 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
const current = terminalApiRef.current
if (!current) return
useWorkspaceModeStore.getState().setMode('terminal')
// Start where the user already is: the active pane's directory, else the
// most recent one we saw (e.g. the pane they just closed).
const inherit =
getSessionCwd(sessionIdOf(activeTerminalPanelRef.current)) ?? lastLocalCwdRef.current ?? undefined
const sessionId = await createSession(inherit)
const sessionId = await createSession()
const reference = activeTerminalPanelRef.current
current.addPanel<TerminalParams>({
id: panelId(),
id: sessionId,
component: TERMINAL_COMPONENT,
tabComponent: TERMINAL_TAB_COMPONENT,
title: nextTerminalTitle(),
@@ -653,7 +435,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
// is visible once added.
useWorkspaceModeStore.getState().setMode('ssh')
current.addPanel<TerminalParams>({
id: panelId(),
id: result.id,
component: TERMINAL_COMPONENT,
tabComponent: TERMINAL_TAB_COMPONENT,
title: conn.name,
@@ -738,7 +520,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
if (!sessionId) return
const refParams = reference.params as TerminalParams | undefined
current.addPanel<TerminalParams>({
id: panelId(),
id: `ssh-${sessionId}-${window.crypto.randomUUID().slice(0, 8)}`,
component: TERMINAL_COMPONENT,
tabComponent: TERMINAL_TAB_COMPONENT,
title: reference.title,
@@ -764,113 +546,23 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
}, [])
/**
* Replace the pty behind an existing panel, in place.
*
* Panel identity and session identity are deliberately separate: a panel keeps
* its parameters object across `updateParameters`, so React re-renders the
* panel's `<TerminalView>` with the new `sessionId` prop exactly the way it
* does when the user switches terminals — its bind effect tears the old xterm
* down and replays the new session's output.
* Rebind every restored session panel to a fresh local pty. Templates cannot
* carry live session state (SSH needs credentials not stored in the layout),
* so sessions are recreated as local terminals in whichever workspace the
* layout put them.
*/
const bindPanelSession = useCallback(
async (panel: DockviewPanelApi | undefined, currentSessionId: string, cwd?: string): Promise<void> => {
if (!panel) return
const nextSessionId = await createSession(cwd)
panel.updateParameters({ sessionId: nextSessionId, sessionKind: 'local' })
deadSessionsRef.current.delete(currentSessionId)
forgetSessionCwd(currentSessionId)
},
[createSession]
)
/**
* Rebind restored panels to fresh ptys.
*
* Templates cannot carry live session state (SSH needs credentials that are
* never stored in a layout), so their panels are recreated as local terminals
* in whichever workspace the layout put them.
*
* The automatic snapshot additionally carries each pane's last working
* directory, and `dropSsh` closes ssh panes rather than faking them as local
* terminals — a restored ssh pane has no session to show yet (the reconnect
* placeholder is a follow-up).
*
* `renames` bridges the id rewrite the caller applied to the layout: the
* snapshot is keyed by the panel ids *as they were written*, but the panels
* now carry fresh ids, so a lookup by the new id would always miss and the
* pane would silently lose its remembered directory.
*/
const rebindSessionPanels = useCallback(
async (
api: DockviewApi | undefined,
snapshot?: SessionSnapshot,
dropSsh = false,
renames?: Map<string, string>
): Promise<void> => {
if (!api) return
const recorded = new Map((snapshot?.panels ?? []).map((p) => [p.panelId, p]))
const lookup = (panelId: string): SessionPanelState | undefined => {
const direct = recorded.get(panelId)
if (direct) return direct
for (const [oldId, newId] of renames ?? []) {
if (newId === panelId) return recorded.get(oldId)
}
return undefined
}
for (const panel of api.panels) {
const id = sessionIdOf(panel)
if (!id || panel.view.contentComponent !== TERMINAL_COMPONENT) continue
if (dropSsh && (panel.params as TerminalParams | undefined)?.sessionKind === 'ssh') {
panel.api.close()
continue
}
await bindPanelSession(panel.api, id, lookup(panel.id)?.cwd)
}
},
[bindPanelSession]
)
const rebindRestoredPanels = useCallback(
async (api: DockviewApi): Promise<void> => {
await rebindSessionPanels(api)
},
[rebindSessionPanels]
)
/**
* Restore the previous layout and drop every pane back into the directory it
* was left in. Returns false when the snapshot cannot be applied (an
* incompatible dockview layout), so the caller falls back to a fresh terminal.
*
* The layout is rewritten before `fromJSON` sees it: a snapshot written by an
* older build has `panelId === sessionId` for every pane (panel ids used to be
* session ids), which is a state the restore can no longer represent — it
* would reinstate a panel whose id names a session that does not exist, and
* the pane stays dead. `breakIdCoincidence` gives those panels fresh ids up
* front, so the reapplied layout always satisfies panel id ≠ session id.
*/
const restoreFromSnapshot = useCallback(
async (snapshot: SessionSnapshot): Promise<boolean> => {
const terminal = terminalApiRef.current
const ssh = sshApiRef.current
const terminalLayout = breakIdCoincidence(snapshot.layouts.terminal)
const sshLayout = breakIdCoincidence(snapshot.layouts.ssh)
try {
if (terminalLayout.layout) terminal?.fromJSON(terminalLayout.layout as never)
if (sshLayout.layout) ssh?.fromJSON(sshLayout.layout as never)
} catch {
return false
for (const panel of api.panels) {
const id = sessionIdOf(panel)
if (id && panel.view.contentComponent === TERMINAL_COMPONENT) {
const newSessionId = (await window.api.createPty()).id
panel.api.updateParameters({ sessionId: newSessionId, sessionKind: 'local' })
deadSessionsRef.current.delete(id)
}
}
const renames = new Map([...terminalLayout.renames, ...sshLayout.renames])
await rebindSessionPanels(terminal, snapshot, true, renames)
await rebindSessionPanels(ssh, snapshot, true, renames)
lastLocalCwdRef.current = snapshot.lastLocalCwd ?? lastLocalCwdRef.current
useWorkspaceModeStore.getState().setMode(snapshot.mode)
recountAll()
recomputeLocalPanels()
return true
},
[rebindSessionPanels, recountAll, recomputeLocalPanels]
[]
)
const handleApplyTemplate = useCallback(
@@ -1255,7 +947,17 @@ function IconRail({
/>
<div className="workspace-rail-divider" />
<RailButton title="新建终端" icon={<PlusOutlined />} onClick={onNewTerminal} />
<LayoutFlyout menu={layoutMenu} />
{/* 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>
<div className="workspace-rail-divider" />
<ModeRailButtons />
<div className="workspace-rail-spacer" />
@@ -1291,93 +993,19 @@ 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,
hideTooltip
active
}: {
title: string
icon: ReactNode
onClick?: () => void
active?: boolean
hideTooltip?: boolean
}): React.JSX.Element {
return (
<Tooltip title={hideTooltip ? '' : title} placement="right">
<Tooltip title={title} placement="right">
<button
type="button"
className={['workspace-rail-btn', active ? ' is-active' : ''].join('')}
@@ -1,36 +0,0 @@
/**
* Last known working directory per pty session.
*
* Filled by <TerminalView> (typed `cd` commands and shell OSC reports) and read
* by the workspace when it builds a session snapshot, so a restored pane opens
* in the directory it was left in. Keyed by sessionId, which is regenerated on
* every launch — the snapshot maps panelId → cwd, and this map is the live
* lookup behind it.
*/
const cwds = new Map<string, string>()
const listeners = new Set<() => void>()
export function setSessionCwd(sessionId: string, cwd: string): void {
if (!sessionId || !cwd) return
if (cwds.get(sessionId) === cwd) return
cwds.set(sessionId, cwd)
for (const listener of listeners) listener()
}
export function getSessionCwd(sessionId: string | undefined): string | undefined {
return sessionId ? cwds.get(sessionId) : undefined
}
export function forgetSessionCwd(sessionId: string | undefined): void {
if (!sessionId) return
if (cwds.delete(sessionId)) {
for (const listener of listeners) listener()
}
}
/** Subscribe to any change; returns an unsubscribe function. */
export function onSessionCwdChange(listener: () => void): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
+51 -125
View File
@@ -3,16 +3,12 @@
.workspace-root {
display: flex;
flex-direction: row;
/* flex child of .app-root below the custom title bar */
flex: 1 1 auto;
min-height: 0;
height: 100%;
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;
@@ -55,62 +51,14 @@
/* ---- M6.1 rail active state (mode entries) ---- */
.workspace-rail-btn.is-active {
color: var(--dv-active-tab-foreground-color, #e8e8e8);
box-shadow: inset 2px 0 0 var(--tab-accent, #3fb950);
background: color-mix(in srgb, var(--tab-accent, #3fb950) 10%, transparent);
box-shadow: inset 2px 0 0 #3fb950;
background: rgba(63, 185, 80, 0.10);
}
.workspace-rail-spacer {
flex: 1 1 auto;
}
/* ---- layout flyout (custom panel, dockview-safe) ---- */
.workspace-flyout-anchor {
position: relative;
display: flex;
}
.workspace-flyout {
position: absolute;
left: calc(100% + 6px);
top: 0;
z-index: 960;
min-width: 148px;
padding: 6px;
display: flex;
flex-direction: column;
gap: 2px;
background: #1f1f1f;
border: 1px solid var(--chrome-border);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
.workspace-flyout-item {
display: flex;
align-items: center;
height: 32px;
padding: 0 12px;
border: none;
border-radius: 5px;
background: transparent;
color: rgba(255, 255, 255, 0.85);
font-size: 13px;
text-align: left;
white-space: nowrap;
cursor: pointer;
}
.workspace-flyout-item:hover {
background: var(--chrome-hover);
color: #ffffff;
}
.workspace-flyout-divider {
height: 1px;
margin: 4px 6px;
background: var(--chrome-border);
}
.workspace-rail-busy {
width: 8px;
height: 8px;
@@ -173,46 +121,40 @@
/* Align terminal chrome with the app chrome. */
.dockview-theme-abyss {
--dv-group-view-background-color: var(--chrome-bg);
--dv-tabs-and-actions-container-background-color: var(--chrome-bg-deep, #10141a);
--dv-tabs-and-actions-container-height: 28px;
--dv-tabs-and-actions-container-background-color: #10141a;
--dv-tabs-and-actions-container-height: 32px;
--dv-tab-divider-color: transparent;
/* Pane split dividers: soft slate band at rest, accent blue on hover/drag. */
--dv-sash-color: rgba(148, 163, 184, 0.28);
--dv-active-sash-color: rgba(59, 142, 234, 0.75);
}
/* ---- tab polish: low rectangular tabs, quiet inactive, green active ---- */
/* ---- tab polish: rounded pills, quiet inactive, green active ---- */
.workspace-dockview .dv-tabs-and-actions-container {
padding: 4px 5px 0;
padding: 3px 4px 0;
box-sizing: border-box;
}
.workspace-dockview .dv-tab {
border-radius: 3px;
padding: 0 8px;
margin-right: 4px;
background: rgba(255, 255, 255, 0.04);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.10);
color: #98a2ab;
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
border-radius: 6px;
padding: 0 9px;
margin-right: 2px;
background: transparent;
color: #8a8a8a;
transition: background-color 0.12s ease, color 0.12s ease;
}
.workspace-dockview .dv-tab.dv-inactive-tab:hover {
background: rgba(255, 255, 255, 0.07);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.20);
background: rgba(255, 255, 255, 0.06);
color: #d0d0d0;
}
/* XTerminal-style active tab: green outline + tinted bg + green title. */
.workspace-dockview .dv-tab.dv-active-tab {
background: color-mix(in srgb, var(--tab-accent, #3fb950) 10%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tab-accent, #3fb950) 75%, transparent);
color: var(--tab-accent-fg, #3fd06a);
background: rgba(63, 185, 80, 0.08);
box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.7);
color: #3fb950;
}
.workspace-dockview .dv-tab.dv-active-tab:hover {
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tab-accent, #3fb950) 95%, transparent);
color: var(--tab-accent-fg, #4ad476);
color: #46c96a;
}
.workspace-dockview .dv-tab .workspace-terminal-tab {
@@ -220,25 +162,18 @@
}
.workspace-dockview .dv-tab .workspace-terminal-tab-title {
font-size: 12px;
font-size: 12.5px;
line-height: 1;
}
/* Close glyph as its own mini outlined pill, mirroring the tab border. */
.workspace-dockview .dv-tab .workspace-terminal-tab-close {
width: 15px;
height: 15px;
border-radius: 2px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
opacity: 0.8;
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
width: 18px;
height: 18px;
opacity: 0.6;
}
.workspace-dockview .dv-tab .workspace-terminal-tab-close:hover {
opacity: 1;
color: var(--tab-accent-fg, #3fd06a);
background: color-mix(in srgb, var(--tab-accent, #3fb950) 8%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tab-accent, #3fb950) 65%, transparent);
}
/* ---- empty state overlay ---- */
@@ -380,11 +315,11 @@ body.ssh-resizing .ssh-bottom-divider {
.workspace-terminal-tab-icon {
flex: 0 0 auto;
font-size: 12px;
color: var(--tab-accent, #3fb950);
color: #3fb950;
}
.workspace-dockview .dv-tab .workspace-terminal-tab-ssh {
border-left: 2px solid var(--tab-accent, #3fb950);
border-left: 2px solid #3fb950;
padding-left: 5px !important;
gap: 5px;
}
@@ -400,9 +335,9 @@ body.ssh-resizing .ssh-bottom-divider {
padding: 1px 6px 2px;
box-sizing: border-box;
border-radius: 9px;
border: 1px solid color-mix(in srgb, var(--tab-accent, #3fb950) 38%, transparent);
background: color-mix(in srgb, var(--tab-accent, #3fb950) 8%, transparent);
color: var(--tab-accent, #3fb950);
border: 1px solid rgba(63, 185, 80, 0.38);
background: rgba(63, 185, 80, 0.08);
color: #3fb950;
font-family: 'Cascadia Mono', 'Consolas', 'SFMono-Regular', monospace;
font-size: 10.5px;
line-height: 1;
@@ -410,9 +345,9 @@ body.ssh-resizing .ssh-bottom-divider {
}
.workspace-dockview .dv-tab.dv-active-tab .workspace-terminal-tab-ssh .ssh-host-badge {
border-color: color-mix(in srgb, var(--tab-accent, #3fb950) 60%, transparent);
background: color-mix(in srgb, var(--tab-accent, #3fb950) 14%, transparent);
color: var(--tab-accent-fg, #46c96a);
border-color: rgba(63, 185, 80, 0.6);
background: rgba(63, 185, 80, 0.14);
color: #46c96a;
}
/* ---- per-group "+" right after the last tab ----
@@ -426,24 +361,21 @@ body.ssh-resizing .ssh-bottom-divider {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
width: 24px;
height: 24px;
margin: 0 6px;
padding: 0;
border: none;
border-radius: 6px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
border-radius: 3px;
background: transparent;
color: #98a2ab;
font-size: 10px;
color: #8a8a8a;
font-size: 11px;
cursor: pointer;
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
}
.workspace-tabbar-add:hover {
background: color-mix(in srgb, var(--tab-accent, #3fb950) 8%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tab-accent, #3fb950) 65%, transparent);
color: var(--tab-accent-fg, #3fd06a);
background: var(--dv-icon-hover-background-color, rgba(90, 93, 94, 0.31));
color: #ffffff;
}
/* ---- M6 tab-bar workspace actions (broadcast + quick-input) ---- */
@@ -458,36 +390,32 @@ body.ssh-resizing .ssh-bottom-divider {
align-items: center;
justify-content: center;
gap: 3px;
width: 22px;
height: 22px;
width: 24px;
height: 24px;
margin: 0 2px;
padding: 0;
border: none;
border-radius: 6px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
border-radius: 3px;
background: transparent;
color: #98a2ab;
font-size: 10px;
color: #8a8a8a;
font-size: 11px;
cursor: pointer;
transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
}
.workspace-tabbar-btn2:hover {
background: rgba(255, 255, 255, 0.07);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.24);
background: var(--dv-icon-hover-background-color, rgba(90, 93, 94, 0.31));
color: #ffffff;
}
.workspace-tabbar-btn2.is-active {
color: var(--tab-accent-fg, #3fd06a);
background: color-mix(in srgb, var(--tab-accent, #3fb950) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tab-accent, #3fb950) 55%, transparent);
color: #3fb950;
background: rgba(63, 185, 80, 0.12);
}
.workspace-tabbar-bcast-count {
font-size: 10px;
font-weight: 600;
color: var(--tab-accent, #3fb950);
color: #3fb950;
line-height: 1;
}
@@ -568,9 +496,9 @@ body.ssh-resizing .ssh-bottom-divider {
font-size: 10px;
font-weight: 600;
line-height: 1;
color: var(--tab-accent, #3fb950);
background: color-mix(in srgb, var(--tab-accent, #3fb950) 10%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tab-accent, #3fb950) 35%, transparent);
color: #3fb950;
background: rgba(63, 185, 80, 0.1);
box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.35);
user-select: none;
}
@@ -734,7 +662,7 @@ body.ssh-resizing .ssh-bottom-divider {
}
.workspace-terminal-tab-close {
padding: 0;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
@@ -743,10 +671,8 @@ body.ssh-resizing .ssh-bottom-divider {
background: none;
color: inherit;
font: inherit;
font-size: 10px;
line-height: 1;
cursor: pointer;
border-radius: 4px;
border-radius: 2px;
}
.workspace-terminal-tab-close:hover {
+1 -18
View File
@@ -5,11 +5,9 @@ import type {
PtyCreateResult,
PtyDataEvent,
PtyExitEvent,
SessionOpenResult,
SessionSnapshot
SessionOpenResult
} from './ipc'
import type { AppSettings } from './settings'
import type { ReleaseNote, UpdateState } from './ipc'
import type {
HostKeyAction,
HostKeyPromptEvent,
@@ -107,19 +105,4 @@ 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(): Promise<void>
updateChangelog(): Promise<ReleaseNote[]>
onUpdateState(cb: (s: UpdateState) => void): () => void
// ---- session snapshot (restore last layout + per-pane cwd) ----
getSessionState(): Promise<SessionSnapshot | null>
saveSessionState(snapshot: SessionSnapshot): Promise<SessionSnapshot | null>
/** resolve a cd-style argument against the current cwd (platform-aware) */
resolveCwd(current: string | undefined, arg: string): Promise<string | null>
/** normalize a shell-reported cwd payload (OSC 7 / OSC 9;9) */
reportCwd(payload: string): Promise<string | null>
}
+1 -77
View File
@@ -90,31 +90,13 @@ export const Ipc = {
ZMODEM_DONE: 'zmodem:done',
// ---- misc ----
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',
// ---- session snapshot (last layout + per-pane cwd) ----
SESSION_STATE_GET: 'session:stateGet',
SESSION_STATE_SET: 'session:stateSet',
/** resolve a cd-style argument against the current cwd, platform-aware */
CWD_RESOLVE: 'session:cwdResolve',
/** normalize a cwd reported by the shell (OSC 7 / OSC 9;9) */
CWD_REPORT: 'session:cwdReport'
APP_INFO: 'app:info'
} as const
export interface PtyCreateOptions {
cwd?: string
/** executable; omit for platform default shell */
shell?: string
/** extra argv for the shell (shell-integration adapters use this) */
shellArgs?: string[]
env?: Record<string, string>
}
@@ -124,33 +106,6 @@ export interface PtyCreateResult {
cwd: string
}
/** One dockview panel as remembered between runs. */
export interface SessionPanelState {
panelId: string
title: string
kind: 'local' | 'ssh'
/** last known working directory (local panels; also recorded for ssh panes
* so a reconnected pane starts somewhere sensible) */
cwd?: string
/** ssh only: bookmark to offer a reconnect for */
connectionId?: string
hostLabel?: string
}
/**
* The automatic "pick up where you left off" snapshot: the dockview layouts
* plus a per-panel record. Distinct from the user-managed layout templates.
*/
export interface SessionSnapshot {
version: 1
savedAt: number
mode: 'terminal' | 'ssh'
layouts: { terminal?: unknown; ssh?: unknown }
panels: SessionPanelState[]
/** newest cwd among local panes — seeds brand-new terminals */
lastLocalCwd?: string
}
export interface PtyDataEvent {
id: string
data: string
@@ -167,37 +122,6 @@ 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
+3 -30
View File
@@ -34,14 +34,6 @@ export interface TerminalSettings {
copyOnSelect: boolean
/** confirm before pasting multi-line / suspicious text */
pasteRiskConfirm: boolean
/** inline command suggestion popup while typing (history + library) */
suggestEnabled: boolean
/** record executed commands into the history panel */
historyEnabled: boolean
/** max entries kept in command history */
historyLimit: number
/** accent color for the active tab outline, rail indicator and SSH badges */
tabAccentColor: string
}
export interface SystemSettings {
@@ -51,21 +43,6 @@ 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
/**
* Restore the previous layout and each pane's working directory on launch
* (see the session snapshot). On by default — that is the point of it.
*/
restoreSession?: boolean
/**
* Let the shell announce its cwd over OSC 7 (shell integration). Exact, but
* it wraps the user's prompt, so it is opt-in; without it the app falls back
* to parsing typed `cd` commands.
*/
shellIntegration?: boolean
}
export interface AppSettings {
@@ -112,17 +89,13 @@ export const DEFAULT_SETTINGS: AppSettings = {
cursorBlink: true,
cursorStyle: 'block',
cursorInactiveStyle: 'outline',
themeId: 'material-dark',
themeId: 'default-dark',
rendererMode: 'auto',
autoWrap: true,
copyOnSelect: false,
pasteRiskConfirm: true,
suggestEnabled: true,
historyEnabled: true,
historyLimit: 100,
tabAccentColor: '#3fb950'
pasteRiskConfirm: true
},
customThemes: [],
highlightRules: DEFAULT_HIGHLIGHT_RULES,
system: { launchAtLogin: false, preventSleep: false, globalShowHide: '', closeAction: 'tray', autoCheckUpdate: true, restoreSession: true, shellIntegration: false }
system: { launchAtLogin: false, preventSleep: false, globalShowHide: '' }
}
+269
View File
@@ -0,0 +1,269 @@
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
});
+15 -78
View File
@@ -6,11 +6,10 @@
* session log write/read/index behavior against a temp userData dir.
*
* Build: npx esbuild src/main/commands.ts --bundle --platform=node --format=cjs \
* --outfile=tests/.commands-store.cjs --alias:electron=./tests/electron-stub.cjs \
* --alias:@shared=./src/shared
* --outfile=tests/.commands-store.cjs --alias:electron=./tests/electron-stub.cjs
* Run: node tests/commands-store.mjs (must exit 0)
*/
import { mkdtempSync, readFileSync, writeFileSync } from 'fs'
import { mkdtempSync, readFileSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { createRequire } from 'module'
@@ -26,23 +25,16 @@ const ok = (cond, msg) => {
}
const wait = (ms) => new Promise((r) => setTimeout(r, ms))
// ---- 1. Temp userData must exist before the bundle loads ------------------------
// The settings store resolves its own path via app.getPath('userData'). The stub
// is bundled *into* the module under test, so requiring it here gives a different
// instance — `__setUserData` would not reach the bundle. The env var is read when
// the stub loads, so it has to be set before the bundle is required below.
const userData = mkdtempSync(join(tmpdir(), 'm5-cmd-'))
process.env.OT_STUB_USERDATA = userData
// ---- 1b. Bundle the store once (assumes tests/.commands-store.cjs exists) ------
// ---- 1. Bundle the store once (assumes tests/.commands-store.cjs exists) -------
let commandsMod
try {
commandsMod = require_('./.commands-store.cjs')
} catch {
fail('bundle not found — run: npx esbuild src/main/commands.ts --bundle --platform=node --format=cjs --outfile=tests/.commands-store.cjs --alias:electron=./tests/electron-stub.cjs --alias:@shared=./src/shared')
fail('bundle not found — run: npx esbuild src/main/commands.ts --bundle --platform=node --format=cjs --outfile=tests/.commands-store.cjs --alias:electron=./tests/electron-stub.cjs')
}
// ---- 2. Store over the temp userData; capture openDir target -------------------
// ---- 2. Temp userData; capture openDir target ----------------------------------
const userData = mkdtempSync(join(tmpdir(), 'm5-cmd-'))
let openedPath = null
const store = new commandsMod.CommandsStore(userData, async (p) => {
openedPath = p
@@ -61,78 +53,23 @@ store.recordCommand('ps aux')
hist = store.listHistory()
ok(hist.length === 3, 're-run of newest command is deduped (no new entry)')
// Re-run an OLDER command -> dedupe is over the whole history, so it moves the
// existing entry to the front instead of adding a copy.
// Re-run an OLDER command -> it is NOT the newest, so it becomes a fresh front
// entry (spec dedupes only against the newest entry) with blank-recency guard.
store.recordCommand('echo hello')
hist = store.listHistory()
ok(hist.length === 3, 'older command re-run does NOT add a copy (whole-history dedupe)')
ok(hist[0].command === 'echo hello', 'older re-run is hoisted to the front')
// The hoisted entry keeps its original createdAt (it is the same command, not a new one).
ok(new Set(hist.map((h) => h.command)).size === 3, 'history holds 3 distinct commands')
ok(hist.length === 4, 'older command re-run is a new front entry (dedupe is newest-only)')
ok(hist[0].command === 'echo hello', 'older re-run lands at front')
// A command used long ago stays single even after many others in between.
for (let i = 0; i < 20; i++) store.recordCommand(`noise-${i}`)
store.recordCommand('ls -la')
// Cap: fill until well above 500 keeps only the newest 500.
for (let i = 0; i < 520; i++) store.recordCommand(`cmd-${i}`)
hist = store.listHistory()
ok(hist.filter((h) => h.command === 'ls -la').length === 1, 'a command seen 20 entries ago is not duplicated')
ok(hist[0].command === 'ls -la', 'and it is hoisted to the front')
ok(hist.length === 500, `history capped at 500 (got ${hist.length})`)
ok(hist[0].command === 'cmd-519', 'newest filler at front')
// Clear before the cap test so the assertions below are exact.
// Clear.
store.clearHistory()
ok(store.listHistory().length === 0, 'clearHistory empties history')
// ---- 3b. History limit + the recording switch -----------------------------------
const writeSettings = (terminal) =>
writeFileSync(join(userData, 'settings.json'), JSON.stringify({ terminal, system: {} }), 'utf8')
const DEFAULT_LIMIT = 100
// The bundled default raises past the old hard cap of 500 only when configured;
// out of the box the list is trimmed to the default limit.
for (let i = 0; i < 150; i++) store.recordCommand(`filler-${i}`)
hist = store.listHistory()
ok(hist.length === DEFAULT_LIMIT, `history trimmed to the default limit of ${DEFAULT_LIMIT} (got ${hist.length})`)
ok(hist[0].command === 'filler-149', 'newest filler at front')
// Lower the limit -> takes effect immediately, without needing a new command.
writeSettings({ historyLimit: 20 })
hist = store.listHistory()
ok(hist.length === 20, `lowered limit applies on read (got ${hist.length})`)
// Raise it -> entries that survived the *disk* cap (the configured limit at write
// time) become visible again, up to the new limit. Nothing was resurrected from
// beyond the write-time cap; the write cap is the real bound on disk growth.
writeSettings({ historyLimit: 50 })
store.recordCommand('after-raise')
hist = store.listHistory()
ok(hist.length === 50, `raising the limit reveals still-stored entries (got ${hist.length})`)
ok(hist[0].command === 'after-raise', 'new command recorded after raising the limit')
// Nonsense / out-of-range limits fall back to something sane instead of throwing.
writeSettings({ historyLimit: 0 })
ok(store.listHistory().length === 1, 'limit 0 clamps to 1 rather than emptying history')
writeSettings({ historyLimit: 99999 })
ok(store.listHistory().length === 50, 'absurd limit is clamped to the hard ceiling')
// The switch: recording stops, and existing history is preserved (not cleared).
writeSettings({ historyLimit: 50, historyEnabled: false })
const before = store.listHistory()
store.recordCommand('must-not-be-recorded')
store.recordCommand('must-not-be-recorded')
hist = store.listHistory()
ok(!hist.some((h) => h.command === 'must-not-be-recorded'), 'historyEnabled=false records nothing')
ok(hist.length === before.length, 'turning the switch off keeps existing history intact')
// Turning it back on resumes recording.
writeSettings({ historyLimit: 50, historyEnabled: true })
store.recordCommand('recorded-again')
ok(store.listHistory()[0].command === 'recorded-again', 'historyEnabled=true resumes recording')
// Default (keys absent from settings.json) records, matching a fresh install.
writeSettings({})
store.recordCommand('default-on')
ok(store.listHistory()[0].command === 'default-on', 'absent switch defaults to recording')
store.clearHistory()
// ---- 4. Library CRUD + ordering ------------------------------------------------
const a = store.saveLibraryItem({ command: 'ps aux', name: 'procs', note: 'show processes' })
await wait(2) // createdAt has ms resolution; avoid a timestamp tie breaking the order assertion
+4 -57
View File
@@ -1,65 +1,12 @@
// Minimal electron stub for bundling src/main/*.ts under plain Node
// (tests/ssh-session-e2e.mjs, tests/commands-store.mjs). Only what the bundled
// modules touch.
//
// `app.getPath('userData')` is configurable via `__setUserData` so a test can
// point the settings store at a temp dir and exercise settings-driven behavior.
let userDataPath = process.env.OT_STUB_USERDATA || ''
// Minimal electron stub for bundling src/main/pty.ts under plain Node
// (tests/ssh-session-e2e.mjs). Only what broadcast.ts touches.
module.exports = {
BrowserWindow: {
getAllWindows: () => []
},
app: {
getPath: (name) => {
if (name === 'userData' && userDataPath) return userDataPath
throw new Error(`electron stub: app.getPath(${name}) is not configured`)
},
setLoginItemSettings: () => {},
isPackaged: false
},
ipcMain: {
handle: () => {},
on: () => {}
},
globalShortcut: {
register: () => true,
unregister: () => {},
unregisterAll: () => {},
isRegistered: () => false
},
webContents: {},
powerSaveBlocker: {
start: () => 1,
stop: () => {},
isStarted: () => false
},
shell: {
openPath: async () => ''
},
app: {},
ipcMain: {},
safeStorage: {
isEncryptionAvailable: () => false
},
nativeTheme: {
shouldUseDarkColors: true,
on: () => {}
},
Menu: {
buildFromTemplate: () => ({ popup: () => {} }),
setApplicationMenu: () => {}
},
Tray: class {
setToolTip() {}
setContextMenu() {}
on() {}
destroy() {}
static getBounds() {
return { x: 0, y: 0, width: 0, height: 0 }
}
},
nativeImage: {
createFromPath: () => ({ isEmpty: () => true, resize: () => ({}) })
},
__setUserData: (p) => {
userDataPath = p
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2023"],
"types": ["node", "electron-vite/node"],
"types": ["node"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,