feat: initial commit of next_term modern linux terminal
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
*.log
|
||||
@@ -0,0 +1,83 @@
|
||||
# next_term ⚡
|
||||
|
||||
> 一个超高颜值、极速响应、功能丰富的现代 Linux 终端模拟器。
|
||||
|
||||
基于 **Electron + 原生 PTY + xterm.js (WebGL 硬件加速)** 架构打造,兼备现代桌面应用的丝滑交互与系统底层的澎湃性能。
|
||||
|
||||
---
|
||||
|
||||
## ✨ 核心特性
|
||||
|
||||
- 🎨 **极致现代设计**:
|
||||
- 沉浸式无边框磨砂毛玻璃窗口(Acrylic / Frosted Glass)。
|
||||
- 内置 5 套精品现代主题(**Tokyo Night**、**Catppuccin Mocha**、**Dracula**、**One Dark**、**Cyberpunk**),支持全局即时热切换与调色板联动。
|
||||
- 状态栏与标签页平滑微动效、发光焦点与光标呼吸闪烁。
|
||||
- ⚡ **高性能 GPU 渲染**:
|
||||
- 挂载 `@xterm/addon-webgl` 硬件加速管线,利用 GPU 直接进行字形栅格化与批处理渲染,轻松保持 60~120 FPS 丝滑帧率。
|
||||
- 📑 **多标签页与多窗格分屏**:
|
||||
- **多标签页(Tabs)**:无上限新建、关闭与快捷切换,自动提取进程标题。
|
||||
- **多窗格分屏(Split Panes)**:支持向右水平分屏(Split Right)与向下垂直分屏(Split Down),每个窗格独立持有 Linux PTY 会话,焦点自由切换。
|
||||
- 🧭 **Command Palette 快速命令面板**:
|
||||
- 按下 `Ctrl+Shift+P` 或 `F1` 唤出类似 VS Code / Sublime 的命令搜索面板,支持键盘模糊搜索主题、分屏、快捷操作。
|
||||
- 🔍 **终端内搜索**:
|
||||
- 按下 `Ctrl+Shift+F` 唤起悬浮搜索框,支持高亮匹配、上一个/下一个遍历与即时跳转。
|
||||
- ⚙️ **丰富的偏好配置**:
|
||||
- 支持调整字号(11px ~ 24px)、字体族(JetBrains Mono / Fira Code 等)、光标形状(Block / Bar / Underline)、光标闪烁,配置自动本地持久化。
|
||||
|
||||
---
|
||||
|
||||
## ⌨️ 常用快捷键
|
||||
|
||||
| 快捷键 | 功能说明 |
|
||||
| :--- | :--- |
|
||||
| `Ctrl + Shift + P` / `F1` | 呼出快速命令面板 (Command Palette) |
|
||||
| `Ctrl + Shift + T` | 新建标签页 (New Tab) |
|
||||
| `Ctrl + Shift + W` | 关闭当前窗格 / 标签页 (Close Pane / Tab) |
|
||||
| `Ctrl + Shift + E` | 向右水平分屏 (Split Right) |
|
||||
| `Ctrl + Shift + O` | 向下垂直分屏 (Split Down) |
|
||||
| `Ctrl + Shift + F` | 唤出终端内快速搜索 (Search Buffer) |
|
||||
| `Ctrl + Tab` / `Ctrl + Shift + Tab` | 切换到下一个 / 上一个标签页 |
|
||||
| `Alt + 1` ~ `Alt + 9` | 快速直达第 1 ~ 9 个标签页 |
|
||||
| `Ctrl + ,` | 打开偏好设置 (Preferences) |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 启动与构建
|
||||
|
||||
```bash
|
||||
# 启动开发终端
|
||||
npm start
|
||||
|
||||
# 若 Node ABI 变动,重新编译底层 node-pty
|
||||
npm run rebuild
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 架构分层
|
||||
|
||||
```
|
||||
next_term/
|
||||
├── src/
|
||||
│ ├── main/
|
||||
│ │ ├── index.cjs # Electron 主进程,无边框透明窗口与 IPC 路由
|
||||
│ │ └── pty-manager.cjs # 基于 node-pty 的 Linux 伪终端管理与进程调度
|
||||
│ ├── preload/
|
||||
│ │ └── index.cjs # 安全 ContextBridge 预加载隔离脚本
|
||||
│ └── renderer/
|
||||
│ ├── index.html # 现代终端 UI DOM 结构
|
||||
│ ├── css/
|
||||
│ │ ├── variables.css # 主题调色板与设计 Token
|
||||
│ │ ├── app.css # 全局视口、标题栏、控制按钮
|
||||
│ │ ├── tabs.css # 标签栏微动效与样式
|
||||
│ │ ├── split.css # 窗格分屏容器与焦点高亮
|
||||
│ │ └── components.css # 状态栏、搜索条、命令面板与设置弹窗
|
||||
│ └── js/
|
||||
│ ├── app.js # 前端总控中枢
|
||||
│ ├── terminal-view.js # xterm + WebGL + FitAddon 实例管理
|
||||
│ ├── tab-manager.js # 标签页生命周期
|
||||
│ ├── split-manager.js # 多窗格分屏树与动态尺寸自适应
|
||||
│ ├── theme-manager.js # 主题系统与 xterm 颜色映射
|
||||
│ ├── command-palette.js # 快速命令面板交互
|
||||
│ └── settings.js # 偏好设置及本地存储
|
||||
```
|
||||
Generated
+626
@@ -0,0 +1,626 @@
|
||||
{
|
||||
"name": "next_term",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "next_term",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"lucide": "^1.47.0",
|
||||
"node-pty": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^4.2.0",
|
||||
"electron": "^44.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron-internal/extract-zip": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz",
|
||||
"integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz",
|
||||
"integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1",
|
||||
"env-paths": "^3.0.0",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"progress": "^2.0.3",
|
||||
"semver": "^7.6.3",
|
||||
"sumchecker": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"undici": "^7.24.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/rebuild": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.0.tgz",
|
||||
"integrity": "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@malept/cross-spawn-promise": "^2.0.0",
|
||||
"debug": "^4.1.1",
|
||||
"node-abi": "^4.2.0",
|
||||
"node-api-version": "^0.2.1",
|
||||
"node-gyp": "^12.2.0",
|
||||
"read-binary-file-arch": "^1.0.6"
|
||||
},
|
||||
"bin": {
|
||||
"electron-rebuild": "lib/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"minipass": "^7.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@malept/cross-spawn-promise": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz",
|
||||
"integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/malept"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund"
|
||||
}
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.13.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz",
|
||||
"integrity": "sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-search": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz",
|
||||
"integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-web-links": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
|
||||
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/addon-webgl": {
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
|
||||
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/abbrev": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz",
|
||||
"integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/electron": {
|
||||
"version": "44.4.3",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-44.4.3.tgz",
|
||||
"integrity": "sha512-LTpSFTB40qVCXIX5xMo+cgHI/Jjkbjw7VpB26PccEbroqOn72LBukeaDwPVo1fBYzSzs0c9iPuAucFCO7Tw81Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@electron-internal/extract-zip": "^1.0.1",
|
||||
"@electron/get": "^5.0.0",
|
||||
"@types/node": "^24.9.0"
|
||||
},
|
||||
"bin": {
|
||||
"electron": "cli.js",
|
||||
"install-electron": "install.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/env-paths": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
|
||||
"integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/exponential-backoff": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
|
||||
"integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/lucide": {
|
||||
"version": "1.47.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide/-/lucide-1.47.0.tgz",
|
||||
"integrity": "sha512-gJoVJM5fvpBDGZTvhpJS9IEurz85HbD+wgOlCkXCIw35tHrqTxSdIYUN9sXvs7Aw3NCl67A+nlotNyoU4u7zOQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/minizlib": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
|
||||
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "4.35.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.35.0.tgz",
|
||||
"integrity": "sha512-ymk4aIzxdPopw2giv8Fs1Ec6vybGkjmyxUwVqhkI4MCy2tVfXdkOGGWieWVjL0THgH+7a8lRdevyupoYj3Js/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-api-version": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz",
|
||||
"integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.3.5"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp": {
|
||||
"version": "12.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz",
|
||||
"integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"env-paths": "^2.2.0",
|
||||
"exponential-backoff": "^3.1.1",
|
||||
"graceful-fs": "^4.2.6",
|
||||
"nopt": "^9.0.0",
|
||||
"proc-log": "^6.0.0",
|
||||
"semver": "^7.3.5",
|
||||
"tar": "^7.5.4",
|
||||
"tinyglobby": "^0.2.12",
|
||||
"undici": "^6.25.0",
|
||||
"which": "^6.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-gyp": "bin/node-gyp.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp/node_modules/env-paths": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp/node_modules/isexe": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz",
|
||||
"integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp/node_modules/undici": {
|
||||
"version": "6.28.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz",
|
||||
"integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp/node_modules/which": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz",
|
||||
"integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/which.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-pty": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
|
||||
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nopt": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz",
|
||||
"integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"abbrev": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"nopt": "bin/nopt.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/proc-log": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz",
|
||||
"integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/progress": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/read-binary-file-arch": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
|
||||
"integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"bin": {
|
||||
"read-binary-file-arch": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/sumchecker": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
|
||||
"integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.22",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
|
||||
"integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
"chownr": "^3.0.0",
|
||||
"minipass": "^7.1.2",
|
||||
"minizlib": "^3.1.0",
|
||||
"yallist": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.29.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz",
|
||||
"integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "next_term",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "src/main/index.cjs",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"rebuild": "electron-rebuild -f -w node-pty"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"lucide": "^1.47.0",
|
||||
"node-pty": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^4.2.0",
|
||||
"electron": "^44.4.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
const { app, BrowserWindow, ipcMain } = require('electron');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { PtyManager } = require('./pty-manager.cjs');
|
||||
|
||||
let mainWindow = null;
|
||||
let ptyManager = null;
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1060,
|
||||
height: 680,
|
||||
minWidth: 600,
|
||||
minHeight: 400,
|
||||
frame: false, // 无边框现代窗口
|
||||
transparent: true,
|
||||
backgroundColor: '#00000000', // 允许 CSS 毛玻璃及半透明背景
|
||||
hasShadow: true,
|
||||
titleBarStyle: 'hidden',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, '../preload/index.cjs'),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
spellcheck: false,
|
||||
backgroundThrottling: false,
|
||||
},
|
||||
});
|
||||
|
||||
ptyManager = new PtyManager(mainWindow);
|
||||
|
||||
mainWindow.webContents.on('console-message', (event, ...args) => {
|
||||
const params = typeof args[0] === 'object' && args[0] !== null ? args[0] : { level: args[0], message: args[1], sourceId: args[3], line: args[2] };
|
||||
if (params.message && !params.message.includes('task queue exceeded')) {
|
||||
console.log(`[Renderer] ${params.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription) => {
|
||||
console.error(`[Load Error] ${errorCode}: ${errorDescription}`);
|
||||
});
|
||||
|
||||
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'));
|
||||
|
||||
mainWindow.on('maximize', () => {
|
||||
mainWindow.webContents.send('window:maximized-change', true);
|
||||
});
|
||||
|
||||
mainWindow.on('unmaximize', () => {
|
||||
mainWindow.webContents.send('window:maximized-change', false);
|
||||
});
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
if (ptyManager) {
|
||||
ptyManager.killAll();
|
||||
}
|
||||
mainWindow = null;
|
||||
});
|
||||
}
|
||||
|
||||
// 窗口控制 IPC
|
||||
ipcMain.on('window:minimize', () => {
|
||||
if (mainWindow) mainWindow.minimize();
|
||||
});
|
||||
|
||||
ipcMain.on('window:maximize', () => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMaximized()) {
|
||||
mainWindow.unmaximize();
|
||||
} else {
|
||||
mainWindow.maximize();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('window:close', () => {
|
||||
if (mainWindow) mainWindow.close();
|
||||
});
|
||||
|
||||
ipcMain.handle('window:is-maximized', () => {
|
||||
return mainWindow ? mainWindow.isMaximized() : false;
|
||||
});
|
||||
|
||||
// PTY IPC
|
||||
ipcMain.handle('pty:create', (event, options) => {
|
||||
if (!ptyManager) return { success: false, error: 'PtyManager not ready' };
|
||||
return ptyManager.create(options);
|
||||
});
|
||||
|
||||
ipcMain.on('pty:write', (event, { id, data }) => {
|
||||
if (ptyManager) ptyManager.write({ id, data });
|
||||
});
|
||||
|
||||
ipcMain.on('pty:resize', (event, { id, cols, rows }) => {
|
||||
if (ptyManager) ptyManager.resize({ id, cols, rows });
|
||||
});
|
||||
|
||||
ipcMain.on('pty:kill', (event, { id }) => {
|
||||
if (ptyManager) ptyManager.kill({ id });
|
||||
});
|
||||
|
||||
ipcMain.handle('app:get-system-info', () => {
|
||||
return {
|
||||
platform: os.platform(),
|
||||
release: os.release(),
|
||||
arch: os.arch(),
|
||||
shell: process.env.SHELL || '/bin/bash',
|
||||
user: os.userInfo().username,
|
||||
hostname: os.hostname(),
|
||||
};
|
||||
});
|
||||
|
||||
// 增强 Linux GPU 稳定性
|
||||
app.commandLine.appendSwitch('disable-gpu-sandbox');
|
||||
app.commandLine.appendSwitch('enable-gpu-rasterization');
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createWindow();
|
||||
|
||||
if (process.env.TEST_EXIT) {
|
||||
console.log('[Test Mode] Window created successfully, auto-exiting in 3s...');
|
||||
setTimeout(() => {
|
||||
app.quit();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
if (ptyManager) {
|
||||
ptyManager.killAll();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
const pty = require('node-pty');
|
||||
const os = require('os');
|
||||
|
||||
class PtyManager {
|
||||
constructor(mainWindow) {
|
||||
this.mainWindow = mainWindow;
|
||||
this.ptys = new Map();
|
||||
this.defaultShell = process.env.SHELL || (os.platform() === 'win32' ? 'powershell.exe' : '/bin/bash');
|
||||
}
|
||||
|
||||
create({ id, cols = 80, rows = 24, cwd = process.env.HOME || process.cwd() }) {
|
||||
if (this.ptys.has(id)) {
|
||||
this.kill({ id });
|
||||
}
|
||||
|
||||
try {
|
||||
const ptyProcess = pty.spawn(this.defaultShell, [], {
|
||||
name: 'xterm-256color',
|
||||
cols,
|
||||
rows,
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
},
|
||||
});
|
||||
|
||||
ptyProcess.onData((data) => {
|
||||
if (!this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('pty:data', { id, data });
|
||||
}
|
||||
});
|
||||
|
||||
ptyProcess.onExit(({ exitCode, signal }) => {
|
||||
if (!this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('pty:exit', { id, exitCode, signal });
|
||||
}
|
||||
this.ptys.delete(id);
|
||||
});
|
||||
|
||||
this.ptys.set(id, ptyProcess);
|
||||
return { success: true, pid: ptyProcess.pid, shell: this.defaultShell };
|
||||
} catch (err) {
|
||||
console.error(`Failed to spawn pty [${id}]:`, err);
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
write({ id, data }) {
|
||||
const ptyProcess = this.ptys.get(id);
|
||||
if (ptyProcess) {
|
||||
ptyProcess.write(data);
|
||||
}
|
||||
}
|
||||
|
||||
resize({ id, cols, rows }) {
|
||||
const ptyProcess = this.ptys.get(id);
|
||||
if (ptyProcess && cols > 0 && rows > 0) {
|
||||
try {
|
||||
ptyProcess.resize(Math.floor(cols), Math.floor(rows));
|
||||
} catch (err) {
|
||||
console.warn(`Failed to resize pty [${id}]:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kill({ id }) {
|
||||
const ptyProcess = this.ptys.get(id);
|
||||
if (ptyProcess) {
|
||||
try {
|
||||
ptyProcess.kill();
|
||||
} catch (err) {
|
||||
console.warn(`Failed to kill pty [${id}]:`, err.message);
|
||||
}
|
||||
this.ptys.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
killAll() {
|
||||
for (const [id, ptyProcess] of this.ptys.entries()) {
|
||||
try {
|
||||
ptyProcess.kill();
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
this.ptys.clear();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PtyManager };
|
||||
@@ -0,0 +1,35 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('terminalAPI', {
|
||||
// PTY 管理
|
||||
createPty: (options) => ipcRenderer.invoke('pty:create', options),
|
||||
writePty: (id, data) => ipcRenderer.send('pty:write', { id, data }),
|
||||
resizePty: (id, cols, rows) => ipcRenderer.send('pty:resize', { id, cols, rows }),
|
||||
killPty: (id) => ipcRenderer.send('pty:kill', { id }),
|
||||
|
||||
// 事件监听
|
||||
onPtyData: (callback) => {
|
||||
const listener = (event, args) => callback(args);
|
||||
ipcRenderer.on('pty:data', listener);
|
||||
return () => ipcRenderer.removeListener('pty:data', listener);
|
||||
},
|
||||
onPtyExit: (callback) => {
|
||||
const listener = (event, args) => callback(args);
|
||||
ipcRenderer.on('pty:exit', listener);
|
||||
return () => ipcRenderer.removeListener('pty:exit', listener);
|
||||
},
|
||||
|
||||
// 窗口控制
|
||||
minimize: () => ipcRenderer.send('window:minimize'),
|
||||
maximize: () => ipcRenderer.send('window:maximize'),
|
||||
close: () => ipcRenderer.send('window:close'),
|
||||
isMaximized: () => ipcRenderer.invoke('window:is-maximized'),
|
||||
onMaximizedChange: (callback) => {
|
||||
const listener = (event, isMaximized) => callback(isMaximized);
|
||||
ipcRenderer.on('window:maximized-change', listener);
|
||||
return () => ipcRenderer.removeListener('window:maximized-change', listener);
|
||||
},
|
||||
|
||||
// 系统信息
|
||||
getSystemInfo: () => ipcRenderer.invoke('app:get-system-info'),
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: transparent;
|
||||
color: var(--text-main);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: var(--bg-app);
|
||||
backdrop-filter: blur(24px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(140%);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
/* 顶部无边框拖拽标题栏 */
|
||||
.titlebar {
|
||||
height: var(--titlebar-height);
|
||||
background: var(--bg-titlebar);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
-webkit-app-region: drag; /* 允许窗口拖拽 */
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.titlebar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.app-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-color);
|
||||
padding: 0 8px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.app-brand-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.titlebar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
.btn-icon:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* 窗口控制按钮 (最小化、最大化、关闭) */
|
||||
.window-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: 6px;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.win-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
width: 32px;
|
||||
height: 28px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.win-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
.win-btn.close:hover {
|
||||
background: #f7768e;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
.main-viewport {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
/* 底部状态栏 */
|
||||
.statusbar {
|
||||
height: var(--statusbar-height);
|
||||
background: var(--bg-titlebar);
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.statusbar-left,
|
||||
.statusbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.statusbar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.statusbar-item.clickable {
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.statusbar-item.clickable:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #9ece6a;
|
||||
box-shadow: 0 0 6px rgba(158, 206, 106, 0.6);
|
||||
}
|
||||
|
||||
/* 模态框遮罩层 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(4px);
|
||||
display: none;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 12vh;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
.modal-overlay.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* 快速命令面板 (Command Palette) */
|
||||
.command-palette {
|
||||
width: 520px;
|
||||
max-width: 90vw;
|
||||
background: var(--bg-app);
|
||||
border: 1px solid var(--border-active);
|
||||
border-radius: var(--border-radius-md);
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.6), 0 0 20px var(--accent-glow);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.palette-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.palette-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text-bright);
|
||||
font-size: 14px;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.palette-list {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.palette-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-main);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.palette-item.selected,
|
||||
.palette-item:hover {
|
||||
background: var(--accent-color);
|
||||
color: #0f141c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.palette-item-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.palette-shortcut {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
font-family: var(--font-mono);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* 设置弹窗 */
|
||||
.settings-modal {
|
||||
width: 560px;
|
||||
max-width: 90vw;
|
||||
background: var(--bg-app);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.65);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.settings-header h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
.settings-body {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-height: 480px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.setting-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
.setting-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.setting-control select,
|
||||
.setting-control input[type="text"],
|
||||
.setting-control input[type="number"] {
|
||||
background: var(--bg-tab-inactive);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-bright);
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.setting-control select:focus,
|
||||
.setting-control input:focus {
|
||||
border-color: var(--border-active);
|
||||
}
|
||||
|
||||
/* 终端内搜索条 */
|
||||
.search-bar {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 20px;
|
||||
background: var(--bg-titlebar);
|
||||
border: 1px solid var(--border-active);
|
||||
border-radius: var(--border-radius-md);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
display: none;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
gap: 6px;
|
||||
z-index: 200;
|
||||
animation: slideDown 0.15s ease;
|
||||
}
|
||||
|
||||
.search-bar.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text-bright);
|
||||
font-size: 12px;
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.search-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from { transform: translateY(-10px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/* 分屏视图与窗格容器 */
|
||||
.tab-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.tab-view.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.split-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.split-container.horizontal {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.split-container.vertical {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.split-pane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid transparent;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
min-width: 120px;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.split-pane.focused {
|
||||
border-color: var(--border-active);
|
||||
box-shadow: inset 0 0 0 1px var(--border-active), 0 0 12px var(--accent-glow);
|
||||
}
|
||||
|
||||
.terminal-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 6px 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xterm {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.xterm-viewport {
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
/* 分割线 */
|
||||
.split-divider {
|
||||
background: var(--border-color);
|
||||
z-index: 10;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.split-container.horizontal > .split-divider {
|
||||
width: 2px;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.split-container.vertical > .split-divider {
|
||||
height: 2px;
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
.split-divider:hover {
|
||||
background: var(--accent-color);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/* 标签栏 */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
max-width: calc(100vw - 320px);
|
||||
}
|
||||
|
||||
.tab-bar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
height: 28px;
|
||||
background: var(--bg-tab-inactive);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
max-width: 180px;
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.tab-item:hover {
|
||||
background: var(--bg-tab-hover);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
background: var(--bg-tab-active);
|
||||
color: var(--text-bright);
|
||||
border-color: var(--border-active);
|
||||
box-shadow: 0 0 10px var(--accent-glow);
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
transition: all 0.12s ease;
|
||||
}
|
||||
|
||||
.tab-item:hover .tab-close,
|
||||
.tab-item.active .tab-close {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
opacity: 1 !important;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #f7768e;
|
||||
}
|
||||
|
||||
.new-tab-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.new-tab-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
:root {
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', 'Courier New', monospace;
|
||||
|
||||
/* 基础设计变量 */
|
||||
--bg-app: rgba(22, 22, 30, 0.88);
|
||||
--bg-titlebar: rgba(16, 16, 22, 0.92);
|
||||
--bg-tab-active: rgba(36, 40, 59, 0.95);
|
||||
--bg-tab-inactive: rgba(26, 27, 38, 0.6);
|
||||
--bg-tab-hover: rgba(34, 36, 54, 0.8);
|
||||
--border-color: rgba(255, 255, 255, 0.08);
|
||||
--border-active: #7aa2f7;
|
||||
--text-main: #c0caf5;
|
||||
--text-muted: #565f89;
|
||||
--text-bright: #ffffff;
|
||||
--accent-color: #7aa2f7;
|
||||
--accent-glow: rgba(122, 162, 247, 0.35);
|
||||
|
||||
--titlebar-height: 38px;
|
||||
--statusbar-height: 26px;
|
||||
--border-radius-sm: 4px;
|
||||
--border-radius-md: 8px;
|
||||
--border-radius-lg: 12px;
|
||||
|
||||
--terminal-font-size: 14px;
|
||||
--terminal-line-height: 1.25;
|
||||
}
|
||||
|
||||
/* Tokyo Night */
|
||||
[data-theme="tokyo-night"] {
|
||||
--bg-app: rgba(26, 27, 38, 0.92);
|
||||
--bg-titlebar: rgba(22, 22, 30, 0.96);
|
||||
--bg-tab-active: rgba(36, 40, 59, 0.98);
|
||||
--bg-tab-inactive: rgba(26, 27, 38, 0.6);
|
||||
--border-color: rgba(122, 162, 247, 0.15);
|
||||
--border-active: #7aa2f7;
|
||||
--text-main: #c0caf5;
|
||||
--text-muted: #565f89;
|
||||
--accent-color: #7aa2f7;
|
||||
--accent-glow: rgba(122, 162, 247, 0.3);
|
||||
}
|
||||
|
||||
/* Catppuccin Mocha */
|
||||
[data-theme="catppuccin"] {
|
||||
--bg-app: rgba(30, 30, 46, 0.92);
|
||||
--bg-titlebar: rgba(24, 24, 37, 0.96);
|
||||
--bg-tab-active: rgba(49, 50, 68, 0.98);
|
||||
--bg-tab-inactive: rgba(30, 30, 46, 0.6);
|
||||
--border-color: rgba(203, 166, 247, 0.15);
|
||||
--border-active: #cba6f7;
|
||||
--text-main: #cdd6f4;
|
||||
--text-muted: #6c7086;
|
||||
--accent-color: #cba6f7;
|
||||
--accent-glow: rgba(203, 166, 247, 0.3);
|
||||
}
|
||||
|
||||
/* Dracula */
|
||||
[data-theme="dracula"] {
|
||||
--bg-app: rgba(40, 42, 54, 0.92);
|
||||
--bg-titlebar: rgba(33, 34, 44, 0.96);
|
||||
--bg-tab-active: rgba(68, 71, 90, 0.98);
|
||||
--bg-tab-inactive: rgba(40, 42, 54, 0.6);
|
||||
--border-color: rgba(189, 147, 249, 0.15);
|
||||
--border-active: #bd93f9;
|
||||
--text-main: #f8f8f2;
|
||||
--text-muted: #6272a4;
|
||||
--accent-color: #ff79c6;
|
||||
--accent-glow: rgba(255, 121, 198, 0.3);
|
||||
}
|
||||
|
||||
/* One Dark */
|
||||
[data-theme="one-dark"] {
|
||||
--bg-app: rgba(40, 44, 52, 0.92);
|
||||
--bg-titlebar: rgba(33, 37, 43, 0.96);
|
||||
--bg-tab-active: rgba(53, 59, 69, 0.98);
|
||||
--bg-tab-inactive: rgba(40, 44, 52, 0.6);
|
||||
--border-color: rgba(97, 175, 239, 0.15);
|
||||
--border-active: #61afef;
|
||||
--text-main: #abb2bf;
|
||||
--text-muted: #5c6370;
|
||||
--accent-color: #61afef;
|
||||
--accent-glow: rgba(97, 175, 239, 0.3);
|
||||
}
|
||||
|
||||
/* Cyberpunk Neon */
|
||||
[data-theme="cyberpunk"] {
|
||||
--bg-app: rgba(13, 18, 30, 0.94);
|
||||
--bg-titlebar: rgba(9, 12, 20, 0.98);
|
||||
--bg-tab-active: rgba(26, 35, 58, 0.98);
|
||||
--bg-tab-inactive: rgba(13, 18, 30, 0.6);
|
||||
--border-color: rgba(0, 255, 204, 0.2);
|
||||
--border-active: #00ffcc;
|
||||
--text-main: #e2f0fb;
|
||||
--text-muted: #4e6a88;
|
||||
--accent-color: #ff007f;
|
||||
--accent-glow: rgba(255, 0, 127, 0.4);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="tokyo-night">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' data:; style-src 'self' 'unsafe-inline';">
|
||||
<title>next_term - 现代 Linux 高性能终端</title>
|
||||
|
||||
<!-- 引入 xterm 基础样式与各功能模块样式 -->
|
||||
<link rel="stylesheet" href="../../node_modules/@xterm/xterm/css/xterm.css">
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/app.css">
|
||||
<link rel="stylesheet" href="css/tabs.css">
|
||||
<link rel="stylesheet" href="css/split.css">
|
||||
<link rel="stylesheet" href="css/components.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- 顶部现代拖拽式标题栏 -->
|
||||
<header class="titlebar">
|
||||
<div class="titlebar-left">
|
||||
<div class="app-brand">
|
||||
<svg class="app-brand-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="4 17 10 11 4 5"></polyline>
|
||||
<line x1="12" y1="19" x2="20" y2="19"></line>
|
||||
</svg>
|
||||
<span>next_term</span>
|
||||
</div>
|
||||
|
||||
<!-- 标签页栏 -->
|
||||
<div class="tab-bar" id="tab-bar"></div>
|
||||
<button class="new-tab-btn" id="btn-new-tab" title="新建标签页 (Ctrl+Shift+T)">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 右侧快速操作按钮与窗口控制 -->
|
||||
<div class="titlebar-actions">
|
||||
<button class="btn-icon" id="btn-split-h" title="向右水平分屏 (Ctrl+Shift+E)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
|
||||
<line x1="12" y1="3" x2="12" y2="21"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon" id="btn-split-v" title="向下垂直分屏 (Ctrl+Shift+O)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
|
||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon" id="btn-palette" title="快速命令面板 (Ctrl+Shift+P / F1)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"></polygon>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-icon" id="btn-settings" title="外观与配置设置 (Ctrl+,)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- 窗口最小化、最大化与关闭按钮 -->
|
||||
<div class="window-controls">
|
||||
<button class="win-btn" id="win-min" title="最小化">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="win-btn" id="win-max" title="最大化">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="win-btn close" id="win-close" title="关闭">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 终端主视口 -->
|
||||
<main class="main-viewport" id="main-viewport"></main>
|
||||
|
||||
<!-- 底部状态栏 -->
|
||||
<footer class="statusbar">
|
||||
<div class="statusbar-left">
|
||||
<div class="statusbar-item">
|
||||
<span class="status-dot"></span>
|
||||
<span id="status-host">linux-desktop</span>
|
||||
</div>
|
||||
<div class="statusbar-item">
|
||||
<span>Shell:</span>
|
||||
<strong id="status-shell" style="color: var(--accent-color);">bash</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="statusbar-right">
|
||||
<div class="statusbar-item">
|
||||
<span>编码: UTF-8</span>
|
||||
</div>
|
||||
<div class="statusbar-item clickable" id="status-theme" title="点击快速切换主题">
|
||||
Tokyo Night
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- 终端搜索悬浮条 -->
|
||||
<div class="search-bar" id="search-bar">
|
||||
<input type="text" class="search-input" id="search-input" placeholder="在终端中搜索...">
|
||||
<button class="btn-icon" id="search-prev" title="查找上一个 (Shift+Enter)">▲</button>
|
||||
<button class="btn-icon" id="search-next" title="查找下一个 (Enter)">▼</button>
|
||||
<button class="btn-icon" id="search-close" title="关闭搜索 (Esc)">×</button>
|
||||
</div>
|
||||
|
||||
<!-- 快速命令面板模态框 -->
|
||||
<div class="modal-overlay" id="command-palette-overlay">
|
||||
<div class="command-palette">
|
||||
<div class="palette-input-wrapper">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<input type="text" class="palette-input" id="command-palette-input" placeholder="输入操作名称或搜索快捷键 (支持上下方向键与回车)..." autocomplete="off">
|
||||
</div>
|
||||
<div class="palette-list" id="command-palette-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 偏好设置模态框 -->
|
||||
<div class="modal-overlay" id="settings-overlay">
|
||||
<div class="settings-modal" id="settings-modal"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,291 @@
|
||||
import { themeManager } from './theme-manager.js';
|
||||
import { TabManager } from './tab-manager.js';
|
||||
import { CommandPalette } from './command-palette.js';
|
||||
import { SettingsModal } from './settings.js';
|
||||
|
||||
class TerminalApp {
|
||||
constructor() {
|
||||
this.initElements();
|
||||
this.initTheme();
|
||||
this.initTabManager();
|
||||
this.initModals();
|
||||
this.initSearch();
|
||||
this.initWindowControls();
|
||||
this.initIpcListeners();
|
||||
this.initKeyboardShortcuts();
|
||||
this.initStatusBar();
|
||||
|
||||
// 创建初始标签页
|
||||
this.tabManager.createTab();
|
||||
}
|
||||
|
||||
initElements() {
|
||||
this.tabBar = document.getElementById('tab-bar');
|
||||
this.viewport = document.getElementById('main-viewport');
|
||||
this.newTabBtn = document.getElementById('btn-new-tab');
|
||||
this.commandPaletteOverlay = document.getElementById('command-palette-overlay');
|
||||
this.commandPaletteInput = document.getElementById('command-palette-input');
|
||||
this.commandPaletteList = document.getElementById('command-palette-list');
|
||||
this.settingsOverlay = document.getElementById('settings-overlay');
|
||||
this.settingsModal = document.getElementById('settings-modal');
|
||||
this.searchBar = document.getElementById('search-bar');
|
||||
this.searchInput = document.getElementById('search-input');
|
||||
this.searchClose = document.getElementById('search-close');
|
||||
this.searchNext = document.getElementById('search-next');
|
||||
this.searchPrev = document.getElementById('search-prev');
|
||||
|
||||
this.statusTheme = document.getElementById('status-theme');
|
||||
this.statusShell = document.getElementById('status-shell');
|
||||
this.statusHost = document.getElementById('status-host');
|
||||
}
|
||||
|
||||
initTheme() {
|
||||
themeManager.init();
|
||||
if (this.statusTheme) {
|
||||
this.statusTheme.textContent = themeManager.getCurrentTheme().name;
|
||||
}
|
||||
themeManager.onThemeChange((theme) => {
|
||||
if (this.statusTheme) {
|
||||
this.statusTheme.textContent = theme.name;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initTabManager() {
|
||||
this.tabManager = new TabManager({
|
||||
tabBar: this.tabBar,
|
||||
viewport: this.viewport,
|
||||
onActiveChange: (activeTerm) => {
|
||||
if (activeTerm && this.statusShell) {
|
||||
this.statusShell.textContent = activeTerm.title || 'bash';
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this.newTabBtn.addEventListener('click', () => {
|
||||
this.tabManager.createTab();
|
||||
});
|
||||
}
|
||||
|
||||
initModals() {
|
||||
this.commandPalette = new CommandPalette({
|
||||
overlay: this.commandPaletteOverlay,
|
||||
input: this.commandPaletteInput,
|
||||
list: this.commandPaletteList,
|
||||
tabManager: this.tabManager,
|
||||
onOpenSettings: () => this.settings.open(),
|
||||
onToggleSearch: () => this.toggleSearch(),
|
||||
});
|
||||
|
||||
this.settings = new SettingsModal({
|
||||
overlay: this.settingsOverlay,
|
||||
modal: this.settingsModal,
|
||||
tabManager: this.tabManager,
|
||||
});
|
||||
|
||||
// 快捷按钮绑定
|
||||
document.getElementById('btn-palette')?.addEventListener('click', () => {
|
||||
this.commandPalette.toggle();
|
||||
});
|
||||
|
||||
document.getElementById('btn-settings')?.addEventListener('click', () => {
|
||||
this.settings.open();
|
||||
});
|
||||
|
||||
document.getElementById('btn-split-h')?.addEventListener('click', () => {
|
||||
const tab = this.tabManager.getActiveTab();
|
||||
if (tab) tab.splitTree.splitCurrent('horizontal');
|
||||
});
|
||||
|
||||
document.getElementById('btn-split-v')?.addEventListener('click', () => {
|
||||
const tab = this.tabManager.getActiveTab();
|
||||
if (tab) tab.splitTree.splitCurrent('vertical');
|
||||
});
|
||||
|
||||
document.getElementById('status-theme')?.addEventListener('click', () => {
|
||||
this.commandPalette.open();
|
||||
this.commandPalette.input.value = '切换主题';
|
||||
this.commandPalette.filter('切换主题');
|
||||
});
|
||||
}
|
||||
|
||||
initSearch() {
|
||||
const doSearch = (direction = 'next') => {
|
||||
const term = this.tabManager.getActiveTerminal();
|
||||
const query = this.searchInput.value;
|
||||
if (!term || !query) return;
|
||||
|
||||
if (direction === 'next') {
|
||||
term.searchAddon.findNext(query, { incremental: true });
|
||||
} else {
|
||||
term.searchAddon.findPrevious(query);
|
||||
}
|
||||
};
|
||||
|
||||
this.searchInput.addEventListener('input', () => doSearch('next'));
|
||||
this.searchInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
doSearch(e.shiftKey ? 'prev' : 'next');
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.closeSearch();
|
||||
}
|
||||
});
|
||||
|
||||
this.searchNext?.addEventListener('click', () => doSearch('next'));
|
||||
this.searchPrev?.addEventListener('click', () => doSearch('prev'));
|
||||
this.searchClose?.addEventListener('click', () => this.closeSearch());
|
||||
}
|
||||
|
||||
toggleSearch() {
|
||||
if (this.searchBar.classList.contains('active')) {
|
||||
this.closeSearch();
|
||||
} else {
|
||||
this.searchBar.classList.add('active');
|
||||
this.searchInput.value = '';
|
||||
this.searchInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
closeSearch() {
|
||||
this.searchBar.classList.remove('active');
|
||||
const term = this.tabManager.getActiveTerminal();
|
||||
if (term) {
|
||||
term.searchAddon.clearDecorations();
|
||||
term.focus();
|
||||
}
|
||||
}
|
||||
|
||||
initWindowControls() {
|
||||
document.getElementById('win-min')?.addEventListener('click', () => {
|
||||
window.terminalAPI.minimize();
|
||||
});
|
||||
|
||||
document.getElementById('win-max')?.addEventListener('click', () => {
|
||||
window.terminalAPI.maximize();
|
||||
});
|
||||
|
||||
document.getElementById('win-close')?.addEventListener('click', () => {
|
||||
window.terminalAPI.close();
|
||||
});
|
||||
}
|
||||
|
||||
initIpcListeners() {
|
||||
// 监听 PTY 数据并派发给对应 terminal
|
||||
window.terminalAPI.onPtyData(({ id, data }) => {
|
||||
for (const tab of this.tabManager.tabs.values()) {
|
||||
const info = tab.splitTree.terminals.get(id);
|
||||
if (info) {
|
||||
info.termView.xterm.write(data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 监听 PTY 退出事件
|
||||
window.terminalAPI.onPtyExit(({ id, exitCode }) => {
|
||||
for (const tab of this.tabManager.tabs.values()) {
|
||||
if (tab.splitTree.terminals.has(id)) {
|
||||
tab.splitTree.closeTerminal(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initKeyboardShortcuts() {
|
||||
window.addEventListener('keydown', (e) => {
|
||||
// Ctrl+Shift+P 或 F1: 命令面板
|
||||
if ((e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'p') || e.key === 'F1') {
|
||||
e.preventDefault();
|
||||
this.commandPalette.toggle();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Shift+T: 新建标签
|
||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 't') {
|
||||
e.preventDefault();
|
||||
this.tabManager.createTab();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Shift+W: 关闭窗格
|
||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'w') {
|
||||
e.preventDefault();
|
||||
const tab = this.tabManager.getActiveTab();
|
||||
if (tab && tab.splitTree.activeTerminal) {
|
||||
tab.splitTree.closeTerminal(tab.splitTree.activeTerminal.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Shift+E: 水平分屏 (左右)
|
||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'e') {
|
||||
e.preventDefault();
|
||||
const tab = this.tabManager.getActiveTab();
|
||||
if (tab) tab.splitTree.splitCurrent('horizontal');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Shift+O: 垂直分屏 (上下)
|
||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'o') {
|
||||
e.preventDefault();
|
||||
const tab = this.tabManager.getActiveTab();
|
||||
if (tab) tab.splitTree.splitCurrent('vertical');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Shift+F: 搜索
|
||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'f') {
|
||||
e.preventDefault();
|
||||
this.toggleSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+,: 设置
|
||||
if (e.ctrlKey && e.key === ',') {
|
||||
e.preventDefault();
|
||||
this.settings.open();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Tab / Ctrl+Shift+Tab: 标签页切换
|
||||
if (e.ctrlKey && e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
this.tabManager.prevTab();
|
||||
} else {
|
||||
this.tabManager.nextTab();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Alt+1 .. Alt+9: 快速直达标签页
|
||||
if (e.altKey && e.key >= '1' && e.key <= '9') {
|
||||
e.preventDefault();
|
||||
const index = parseInt(e.key, 10) - 1;
|
||||
const ids = Array.from(this.tabManager.tabs.keys());
|
||||
if (ids[index]) {
|
||||
this.tabManager.switchTab(ids[index]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async initStatusBar() {
|
||||
try {
|
||||
const info = await window.terminalAPI.getSystemInfo();
|
||||
if (info && this.statusHost) {
|
||||
this.statusHost.textContent = `${info.user}@${info.hostname}`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load system info:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载就绪后启动终端
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
new TerminalApp();
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import { themeManager, THEMES } from './theme-manager.js';
|
||||
|
||||
export class CommandPalette {
|
||||
constructor(options = {}) {
|
||||
this.overlay = options.overlay;
|
||||
this.input = options.input;
|
||||
this.list = options.list;
|
||||
this.tabManager = options.tabManager;
|
||||
this.onOpenSettings = options.onOpenSettings || (() => {});
|
||||
this.onToggleSearch = options.onToggleSearch || (() => {});
|
||||
|
||||
this.selectedIndex = 0;
|
||||
this.isOpen = false;
|
||||
this.filteredCommands = [];
|
||||
|
||||
this.initCommands();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
initCommands() {
|
||||
this.baseCommands = [
|
||||
{
|
||||
id: 'new-tab',
|
||||
title: '新建标签页 (New Tab)',
|
||||
shortcut: 'Ctrl+Shift+T',
|
||||
icon: 'plus',
|
||||
action: () => this.tabManager.createTab(),
|
||||
},
|
||||
{
|
||||
id: 'split-horizontal',
|
||||
title: '向右水平分屏 (Split Right)',
|
||||
shortcut: 'Ctrl+Shift+E',
|
||||
icon: 'columns',
|
||||
action: () => {
|
||||
const tab = this.tabManager.getActiveTab();
|
||||
if (tab) tab.splitTree.splitCurrent('horizontal');
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'split-vertical',
|
||||
title: '向下垂直分屏 (Split Down)',
|
||||
shortcut: 'Ctrl+Shift+O',
|
||||
icon: 'rows',
|
||||
action: () => {
|
||||
const tab = this.tabManager.getActiveTab();
|
||||
if (tab) tab.splitTree.splitCurrent('vertical');
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'close-pane',
|
||||
title: '关闭当前窗格/标签页 (Close Pane)',
|
||||
shortcut: 'Ctrl+Shift+W',
|
||||
icon: 'x',
|
||||
action: () => {
|
||||
const tab = this.tabManager.getActiveTab();
|
||||
if (tab && tab.splitTree.activeTerminal) {
|
||||
tab.splitTree.closeTerminal(tab.splitTree.activeTerminal.id);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'search',
|
||||
title: '终端内容快速搜索 (Search Buffer)',
|
||||
shortcut: 'Ctrl+Shift+F',
|
||||
icon: 'search',
|
||||
action: () => this.onToggleSearch(),
|
||||
},
|
||||
{
|
||||
id: 'clear',
|
||||
title: '清空当前屏幕缓冲区 (Clear Buffer)',
|
||||
shortcut: 'Ctrl+L',
|
||||
icon: 'trash',
|
||||
action: () => {
|
||||
const term = this.tabManager.getActiveTerminal();
|
||||
if (term) term.xterm.clear();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
title: '打开终端外观与核心配置 (Settings)',
|
||||
shortcut: 'Ctrl+,',
|
||||
icon: 'settings',
|
||||
action: () => this.onOpenSettings(),
|
||||
},
|
||||
];
|
||||
|
||||
// 添加预设主题快速切换项
|
||||
for (const [key, theme] of Object.entries(THEMES)) {
|
||||
this.baseCommands.push({
|
||||
id: `theme-${key}`,
|
||||
title: `切换主题: ${theme.name}`,
|
||||
shortcut: '',
|
||||
icon: 'palette',
|
||||
action: () => themeManager.applyTheme(key),
|
||||
});
|
||||
}
|
||||
|
||||
this.filteredCommands = [...this.baseCommands];
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
this.input.addEventListener('input', () => {
|
||||
this.filter(this.input.value.trim().toLowerCase());
|
||||
});
|
||||
|
||||
this.input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
this.selectedIndex = (this.selectedIndex + 1) % this.filteredCommands.length;
|
||||
this.renderList();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
this.selectedIndex = (this.selectedIndex - 1 + this.filteredCommands.length) % this.filteredCommands.length;
|
||||
this.renderList();
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.executeSelected();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
|
||||
this.overlay.addEventListener('click', (e) => {
|
||||
if (e.target === this.overlay) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
open() {
|
||||
this.isOpen = true;
|
||||
this.overlay.classList.add('active');
|
||||
this.input.value = '';
|
||||
this.selectedIndex = 0;
|
||||
this.filteredCommands = [...this.baseCommands];
|
||||
this.renderList();
|
||||
setTimeout(() => this.input.focus(), 50);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.overlay.classList.remove('active');
|
||||
const term = this.tabManager.getActiveTerminal();
|
||||
if (term) term.focus();
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.isOpen) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
filter(query) {
|
||||
if (!query) {
|
||||
this.filteredCommands = [...this.baseCommands];
|
||||
} else {
|
||||
this.filteredCommands = this.baseCommands.filter((cmd) =>
|
||||
cmd.title.toLowerCase().includes(query) || cmd.id.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
this.selectedIndex = 0;
|
||||
this.renderList();
|
||||
}
|
||||
|
||||
renderList() {
|
||||
this.list.innerHTML = '';
|
||||
if (this.filteredCommands.length === 0) {
|
||||
this.list.innerHTML = `<div style="padding: 12px; color: var(--text-muted); text-align: center; font-size: 12px;">未匹配到相关命令</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.filteredCommands.forEach((cmd, idx) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = `palette-item ${idx === this.selectedIndex ? 'selected' : ''}`;
|
||||
item.innerHTML = `
|
||||
<div class="palette-item-left">
|
||||
<span>${cmd.title}</span>
|
||||
</div>
|
||||
${cmd.shortcut ? `<span class="palette-shortcut">${cmd.shortcut}</span>` : ''}
|
||||
`;
|
||||
|
||||
item.addEventListener('click', () => {
|
||||
this.selectedIndex = idx;
|
||||
this.executeSelected();
|
||||
});
|
||||
|
||||
this.list.appendChild(item);
|
||||
});
|
||||
|
||||
// 确保选中项在视口内
|
||||
const selectedEl = this.list.children[this.selectedIndex];
|
||||
if (selectedEl) {
|
||||
selectedEl.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
executeSelected() {
|
||||
const cmd = this.filteredCommands[this.selectedIndex];
|
||||
if (cmd) {
|
||||
this.close();
|
||||
cmd.action();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { themeManager, THEMES } from './theme-manager.js';
|
||||
|
||||
export class SettingsModal {
|
||||
constructor(options = {}) {
|
||||
this.overlay = options.overlay;
|
||||
this.modal = options.modal;
|
||||
this.tabManager = options.tabManager;
|
||||
|
||||
this.settings = this.loadSettings();
|
||||
this.initUI();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
loadSettings() {
|
||||
const defaults = {
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
|
||||
fontSize: 14,
|
||||
cursorStyle: 'block',
|
||||
cursorBlink: true,
|
||||
theme: themeManager.currentThemeKey,
|
||||
};
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('next_term:settings') || '{}');
|
||||
return { ...defaults, ...saved };
|
||||
} catch {
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
saveSettings() {
|
||||
localStorage.setItem('next_term:settings', JSON.stringify(this.settings));
|
||||
this.applyAll();
|
||||
}
|
||||
|
||||
initUI() {
|
||||
// 渲染设置项
|
||||
const themeOptions = Object.keys(THEMES)
|
||||
.map((key) => `<option value="${key}" ${this.settings.theme === key ? 'selected' : ''}>${THEMES[key].name}</option>`)
|
||||
.join('');
|
||||
|
||||
this.modal.innerHTML = `
|
||||
<div class="settings-header">
|
||||
<h3>终端偏好设置 (Preferences)</h3>
|
||||
<button class="btn-icon close-settings">×</button>
|
||||
</div>
|
||||
<div class="settings-body">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">色彩主题 (Color Theme)</span>
|
||||
<span class="setting-desc">选择预设的高对比度高颜值现代配色方案</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select id="setting-theme">${themeOptions}</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">字体大小 (Font Size)</span>
|
||||
<span class="setting-desc">网格字符栅格化大小 (11px ~ 24px)</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input type="number" id="setting-fontSize" min="11" max="24" value="${this.settings.fontSize}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">字体族 (Font Family)</span>
|
||||
<span class="setting-desc">优先使用的等宽编程字体</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select id="setting-fontFamily">
|
||||
<option value="'JetBrains Mono', monospace">JetBrains Mono</option>
|
||||
<option value="'Fira Code', monospace">Fira Code</option>
|
||||
<option value="'Cascadia Code', monospace">Cascadia Code</option>
|
||||
<option value="monospace">系统默认等宽 (Monospace)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">光标样式 (Cursor Style)</span>
|
||||
<span class="setting-desc">终端提示符光标形态</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select id="setting-cursorStyle">
|
||||
<option value="block" ${this.settings.cursorStyle === 'block' ? 'selected' : ''}>方块光标 (Block)</option>
|
||||
<option value="bar" ${this.settings.cursorStyle === 'bar' ? 'selected' : ''}>细竖线光标 (Bar)</option>
|
||||
<option value="underline" ${this.settings.cursorStyle === 'underline' ? 'selected' : ''}>下划线光标 (Underline)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">光标平滑闪烁 (Cursor Blink)</span>
|
||||
<span class="setting-desc">启用后光标将周期性呼吸闪烁</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input type="checkbox" id="setting-cursorBlink" ${this.settings.cursorBlink ? 'checked' : ''} style="width: 18px; height: 18px; cursor: pointer;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const fontSelect = this.modal.querySelector('#setting-fontFamily');
|
||||
if (fontSelect) fontSelect.value = this.settings.fontFamily;
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
this.overlay.addEventListener('click', (e) => {
|
||||
if (e.target === this.overlay) this.close();
|
||||
});
|
||||
|
||||
this.modal.querySelector('.close-settings')?.addEventListener('click', () => {
|
||||
this.close();
|
||||
});
|
||||
|
||||
// 监听表单变更
|
||||
this.modal.querySelector('#setting-theme')?.addEventListener('change', (e) => {
|
||||
this.settings.theme = e.target.value;
|
||||
themeManager.applyTheme(this.settings.theme);
|
||||
this.saveSettings();
|
||||
});
|
||||
|
||||
this.modal.querySelector('#setting-fontSize')?.addEventListener('change', (e) => {
|
||||
this.settings.fontSize = parseInt(e.target.value, 10) || 14;
|
||||
this.saveSettings();
|
||||
});
|
||||
|
||||
this.modal.querySelector('#setting-fontFamily')?.addEventListener('change', (e) => {
|
||||
this.settings.fontFamily = e.target.value;
|
||||
this.saveSettings();
|
||||
});
|
||||
|
||||
this.modal.querySelector('#setting-cursorStyle')?.addEventListener('change', (e) => {
|
||||
this.settings.cursorStyle = e.target.value;
|
||||
this.saveSettings();
|
||||
});
|
||||
|
||||
this.modal.querySelector('#setting-cursorBlink')?.addEventListener('change', (e) => {
|
||||
this.settings.cursorBlink = e.target.checked;
|
||||
this.saveSettings();
|
||||
});
|
||||
}
|
||||
|
||||
applyAll() {
|
||||
for (const tab of this.tabManager.tabs.values()) {
|
||||
for (const info of tab.splitTree.terminals.values()) {
|
||||
info.termView.updateSettings(this.settings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
this.overlay.classList.add('active');
|
||||
}
|
||||
|
||||
close() {
|
||||
this.overlay.classList.remove('active');
|
||||
const term = this.tabManager.getActiveTerminal();
|
||||
if (term) term.focus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { TerminalView } from './terminal-view.js';
|
||||
|
||||
export class TabSplitTree {
|
||||
constructor(options = {}) {
|
||||
this.container = options.container;
|
||||
this.tabId = options.tabId;
|
||||
this.onActiveTerminalChange = options.onActiveTerminalChange || (() => {});
|
||||
this.onEmpty = options.onEmpty || (() => {});
|
||||
this.onTitleChange = options.onTitleChange || (() => {});
|
||||
|
||||
this.terminals = new Map(); // id -> TerminalView
|
||||
this.activeTerminal = null;
|
||||
|
||||
this.rootElement = document.createElement('div');
|
||||
this.rootElement.className = 'split-container horizontal';
|
||||
this.container.appendChild(this.rootElement);
|
||||
|
||||
// 创建初始窗格
|
||||
this.addPane(this.rootElement);
|
||||
}
|
||||
|
||||
addPane(parentElement, orientation = 'horizontal') {
|
||||
const paneElement = document.createElement('div');
|
||||
paneElement.className = 'split-pane';
|
||||
|
||||
parentElement.appendChild(paneElement);
|
||||
|
||||
const termView = new TerminalView({
|
||||
container: paneElement,
|
||||
onFocus: (term) => {
|
||||
this.setActiveTerminal(term);
|
||||
},
|
||||
onTitleChange: (title) => {
|
||||
if (this.activeTerminal === termView) {
|
||||
this.onTitleChange(title);
|
||||
}
|
||||
},
|
||||
onExit: () => {
|
||||
this.closeTerminal(termView.id);
|
||||
},
|
||||
});
|
||||
|
||||
this.terminals.set(termView.id, {
|
||||
termView,
|
||||
paneElement,
|
||||
parentElement,
|
||||
});
|
||||
|
||||
paneElement.addEventListener('click', () => {
|
||||
termView.focus();
|
||||
});
|
||||
|
||||
this.setActiveTerminal(termView);
|
||||
return termView;
|
||||
}
|
||||
|
||||
splitCurrent(direction = 'horizontal') {
|
||||
if (!this.activeTerminal) return;
|
||||
const currentInfo = this.terminals.get(this.activeTerminal.id);
|
||||
if (!currentInfo) return;
|
||||
|
||||
const currentPane = currentInfo.paneElement;
|
||||
const parent = currentPane.parentElement;
|
||||
|
||||
// 创建一个新的 split-container 容器替换原有 pane,实现嵌套分屏
|
||||
const splitWrapper = document.createElement('div');
|
||||
splitWrapper.className = `split-container ${direction}`;
|
||||
|
||||
parent.replaceChild(splitWrapper, currentPane);
|
||||
splitWrapper.appendChild(currentPane);
|
||||
|
||||
// 添加分割线
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'split-divider';
|
||||
splitWrapper.appendChild(divider);
|
||||
|
||||
// 添加新窗格
|
||||
const newTerm = this.addPane(splitWrapper, direction);
|
||||
|
||||
// 刷新自适应尺寸
|
||||
setTimeout(() => {
|
||||
this.fitAll();
|
||||
newTerm.focus();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
setActiveTerminal(termView) {
|
||||
this.activeTerminal = termView;
|
||||
|
||||
// 清除所有聚焦样式
|
||||
for (const info of this.terminals.values()) {
|
||||
info.paneElement.classList.remove('focused');
|
||||
}
|
||||
|
||||
const currentInfo = this.terminals.get(termView.id);
|
||||
if (currentInfo) {
|
||||
currentInfo.paneElement.classList.add('focused');
|
||||
}
|
||||
|
||||
this.onActiveTerminalChange(termView);
|
||||
this.onTitleChange(termView.title);
|
||||
}
|
||||
|
||||
closeTerminal(termId) {
|
||||
const info = this.terminals.get(termId);
|
||||
if (!info) return;
|
||||
|
||||
info.termView.destroy();
|
||||
const pane = info.paneElement;
|
||||
const parent = pane.parentElement;
|
||||
|
||||
this.terminals.delete(termId);
|
||||
pane.remove();
|
||||
|
||||
if (this.terminals.size === 0) {
|
||||
this.onEmpty();
|
||||
return;
|
||||
}
|
||||
|
||||
// 焦点转移给另一个终端
|
||||
const nextTerm = this.terminals.values().next().value.termView;
|
||||
this.setActiveTerminal(nextTerm);
|
||||
nextTerm.focus();
|
||||
this.fitAll();
|
||||
}
|
||||
|
||||
fitAll() {
|
||||
for (const info of this.terminals.values()) {
|
||||
info.termView.fit();
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
for (const info of this.terminals.values()) {
|
||||
info.termView.destroy();
|
||||
}
|
||||
this.terminals.clear();
|
||||
this.rootElement.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { TabSplitTree } from './split-manager.js';
|
||||
|
||||
export class TabManager {
|
||||
constructor(options = {}) {
|
||||
this.tabBar = options.tabBar;
|
||||
this.viewport = options.viewport;
|
||||
this.onActiveChange = options.onActiveChange || (() => {});
|
||||
|
||||
this.tabs = new Map(); // tabId -> { tabElement, viewElement, splitTree, title }
|
||||
this.activeTabId = null;
|
||||
}
|
||||
|
||||
createTab(title = 'bash') {
|
||||
const tabId = 'tab_' + Math.random().toString(36).substring(2, 9);
|
||||
|
||||
// 1. 创建标签页 DOM
|
||||
const tabElement = document.createElement('div');
|
||||
tabElement.className = 'tab-item';
|
||||
tabElement.innerHTML = `
|
||||
<svg class="tab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="4 17 10 11 4 5"></polyline>
|
||||
<line x1="12" y1="19" x2="20" y2="19"></line>
|
||||
</svg>
|
||||
<span class="tab-title">${title}</span>
|
||||
<span class="tab-close">×</span>
|
||||
`;
|
||||
|
||||
// 2. 创建视口 DOM
|
||||
const viewElement = document.createElement('div');
|
||||
viewElement.className = 'tab-view';
|
||||
this.viewport.appendChild(viewElement);
|
||||
|
||||
// 3. 创建分屏树
|
||||
const splitTree = new TabSplitTree({
|
||||
container: viewElement,
|
||||
tabId,
|
||||
onActiveTerminalChange: (term) => {
|
||||
if (this.activeTabId === tabId) {
|
||||
this.onActiveChange(term);
|
||||
}
|
||||
},
|
||||
onTitleChange: (newTitle) => {
|
||||
this.updateTabTitle(tabId, newTitle);
|
||||
},
|
||||
onEmpty: () => {
|
||||
this.closeTab(tabId);
|
||||
},
|
||||
});
|
||||
|
||||
// 4. 标签页点击与关闭事件
|
||||
tabElement.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('tab-close')) {
|
||||
this.closeTab(tabId);
|
||||
} else {
|
||||
this.switchTab(tabId);
|
||||
}
|
||||
});
|
||||
|
||||
this.tabBar.appendChild(tabElement);
|
||||
|
||||
this.tabs.set(tabId, {
|
||||
tabId,
|
||||
tabElement,
|
||||
viewElement,
|
||||
splitTree,
|
||||
title,
|
||||
});
|
||||
|
||||
this.switchTab(tabId);
|
||||
return tabId;
|
||||
}
|
||||
|
||||
switchTab(tabId) {
|
||||
if (!this.tabs.has(tabId)) return;
|
||||
|
||||
for (const [id, tab] of this.tabs.entries()) {
|
||||
const isActive = id === tabId;
|
||||
tab.tabElement.classList.toggle('active', isActive);
|
||||
tab.viewElement.classList.toggle('active', isActive);
|
||||
if (isActive) {
|
||||
this.activeTabId = tabId;
|
||||
setTimeout(() => {
|
||||
tab.splitTree.fitAll();
|
||||
if (tab.splitTree.activeTerminal) {
|
||||
tab.splitTree.activeTerminal.focus();
|
||||
this.onActiveChange(tab.splitTree.activeTerminal);
|
||||
}
|
||||
}, 30);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closeTab(tabId) {
|
||||
const tab = this.tabs.get(tabId);
|
||||
if (!tab) return;
|
||||
|
||||
tab.splitTree.destroy();
|
||||
tab.tabElement.remove();
|
||||
tab.viewElement.remove();
|
||||
this.tabs.delete(tabId);
|
||||
|
||||
// 如果还有其他标签页,切换到上一个或下一个
|
||||
if (this.tabs.size > 0) {
|
||||
const remainingIds = Array.from(this.tabs.keys());
|
||||
const nextId = remainingIds[remainingIds.length - 1];
|
||||
this.switchTab(nextId);
|
||||
} else {
|
||||
// 没有任何标签页时自动新建一个
|
||||
this.createTab();
|
||||
}
|
||||
}
|
||||
|
||||
updateTabTitle(tabId, newTitle) {
|
||||
const tab = this.tabs.get(tabId);
|
||||
if (tab && newTitle) {
|
||||
tab.title = newTitle;
|
||||
const titleEl = tab.tabElement.querySelector('.tab-title');
|
||||
if (titleEl) {
|
||||
titleEl.textContent = newTitle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getActiveTab() {
|
||||
return this.tabs.get(this.activeTabId);
|
||||
}
|
||||
|
||||
getActiveTerminal() {
|
||||
const activeTab = this.getActiveTab();
|
||||
return activeTab ? activeTab.splitTree.activeTerminal : null;
|
||||
}
|
||||
|
||||
nextTab() {
|
||||
const ids = Array.from(this.tabs.keys());
|
||||
if (ids.length <= 1) return;
|
||||
const currentIndex = ids.indexOf(this.activeTabId);
|
||||
const nextIndex = (currentIndex + 1) % ids.length;
|
||||
this.switchTab(ids[nextIndex]);
|
||||
}
|
||||
|
||||
prevTab() {
|
||||
const ids = Array.from(this.tabs.keys());
|
||||
if (ids.length <= 1) return;
|
||||
const currentIndex = ids.indexOf(this.activeTabId);
|
||||
const prevIndex = (currentIndex - 1 + ids.length) % ids.length;
|
||||
this.switchTab(ids[prevIndex]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { Terminal } from '../../../node_modules/@xterm/xterm/lib/xterm.mjs';
|
||||
import { FitAddon } from '../../../node_modules/@xterm/addon-fit/lib/addon-fit.mjs';
|
||||
import { WebglAddon } from '../../../node_modules/@xterm/addon-webgl/lib/addon-webgl.mjs';
|
||||
import { WebLinksAddon } from '../../../node_modules/@xterm/addon-web-links/lib/addon-web-links.mjs';
|
||||
import { SearchAddon } from '../../../node_modules/@xterm/addon-search/lib/addon-search.mjs';
|
||||
import { themeManager } from './theme-manager.js';
|
||||
|
||||
export class TerminalView {
|
||||
constructor(options = {}) {
|
||||
this.id = 'term_' + Math.random().toString(36).substring(2, 9);
|
||||
this.container = options.container;
|
||||
this.cwd = options.cwd || '';
|
||||
this.onTitleChange = options.onTitleChange || (() => {});
|
||||
this.onFocus = options.onFocus || (() => {});
|
||||
this.onExit = options.onExit || (() => {});
|
||||
|
||||
this.title = 'bash';
|
||||
this.isFocused = false;
|
||||
|
||||
this.initTerminal();
|
||||
}
|
||||
|
||||
initTerminal() {
|
||||
const currentTheme = themeManager.getCurrentTheme();
|
||||
|
||||
const config = JSON.parse(localStorage.getItem('next_term:settings') || '{}');
|
||||
const fontSize = config.fontSize || 14;
|
||||
const fontFamily = config.fontFamily || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace";
|
||||
const cursorStyle = config.cursorStyle || 'block';
|
||||
const cursorBlink = config.cursorBlink !== false;
|
||||
|
||||
this.xterm = new Terminal({
|
||||
cursorBlink,
|
||||
cursorStyle,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
lineHeight: 1.22,
|
||||
letterSpacing: 0,
|
||||
scrollback: 10000,
|
||||
allowTransparency: true,
|
||||
theme: currentTheme.xterm,
|
||||
macOptionIsMeta: true,
|
||||
rightClickSelectsWord: true,
|
||||
convertEol: true,
|
||||
});
|
||||
|
||||
// 插件初始化
|
||||
this.fitAddon = new FitAddon();
|
||||
this.xterm.loadAddon(this.fitAddon);
|
||||
|
||||
this.searchAddon = new SearchAddon();
|
||||
this.xterm.loadAddon(this.searchAddon);
|
||||
|
||||
this.webLinksAddon = new WebLinksAddon();
|
||||
this.xterm.loadAddon(this.webLinksAddon);
|
||||
|
||||
// 挂载 DOM
|
||||
this.terminalWrapper = document.createElement('div');
|
||||
this.terminalWrapper.className = 'terminal-wrapper';
|
||||
this.container.appendChild(this.terminalWrapper);
|
||||
|
||||
this.xterm.open(this.terminalWrapper);
|
||||
|
||||
// 启用 WebGL 硬件加速 (GPU 渲染)
|
||||
try {
|
||||
this.webglAddon = new WebglAddon();
|
||||
this.webglAddon.onContextLoss(() => {
|
||||
console.warn('WebGL context lost, disposing webgl addon');
|
||||
this.webglAddon.dispose();
|
||||
});
|
||||
this.xterm.loadAddon(this.webglAddon);
|
||||
} catch (e) {
|
||||
console.warn('WebGL addon not supported, fallback to default Canvas renderer:', e);
|
||||
}
|
||||
|
||||
// 监听输入
|
||||
this.xterm.onData((data) => {
|
||||
window.terminalAPI.writePty(this.id, data);
|
||||
});
|
||||
|
||||
// 监听标题变更 (OSC 0/2)
|
||||
this.xterm.onTitleChange((title) => {
|
||||
if (title) {
|
||||
this.title = title;
|
||||
this.onTitleChange(title);
|
||||
}
|
||||
});
|
||||
|
||||
// 监听终端聚焦
|
||||
this.xterm.textarea?.addEventListener('focus', () => {
|
||||
this.isFocused = true;
|
||||
this.onFocus(this);
|
||||
});
|
||||
|
||||
// 主题监听
|
||||
this.unsubscribeTheme = themeManager.onThemeChange((theme) => {
|
||||
this.xterm.options.theme = theme.xterm;
|
||||
});
|
||||
|
||||
// 自适应尺寸监听
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.fit();
|
||||
});
|
||||
this.resizeObserver.observe(this.terminalWrapper);
|
||||
|
||||
// 启动 PTY
|
||||
this.fit();
|
||||
const cols = this.xterm.cols || 80;
|
||||
const rows = this.xterm.rows || 24;
|
||||
|
||||
window.terminalAPI.createPty({
|
||||
id: this.id,
|
||||
cols,
|
||||
rows,
|
||||
cwd: this.cwd,
|
||||
}).then((res) => {
|
||||
if (res && res.shell) {
|
||||
this.title = res.shell.split('/').pop() || 'bash';
|
||||
this.onTitleChange(this.title);
|
||||
}
|
||||
});
|
||||
|
||||
// 聚焦
|
||||
setTimeout(() => this.focus(), 100);
|
||||
}
|
||||
|
||||
fit() {
|
||||
if (!this.terminalWrapper || this.terminalWrapper.clientWidth === 0) return;
|
||||
try {
|
||||
this.fitAddon.fit();
|
||||
if (this.xterm.cols && this.xterm.rows) {
|
||||
window.terminalAPI.resizePty(this.id, this.xterm.cols, this.xterm.rows);
|
||||
}
|
||||
} catch (err) {
|
||||
// 容器不可见时可能会静默捕获
|
||||
}
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.xterm.focus();
|
||||
this.isFocused = true;
|
||||
this.onFocus(this);
|
||||
}
|
||||
|
||||
updateSettings(settings) {
|
||||
if (settings.fontSize) this.xterm.options.fontSize = settings.fontSize;
|
||||
if (settings.fontFamily) this.xterm.options.fontFamily = settings.fontFamily;
|
||||
if (settings.cursorStyle) this.xterm.options.cursorStyle = settings.cursorStyle;
|
||||
if (settings.cursorBlink !== undefined) this.xterm.options.cursorBlink = settings.cursorBlink;
|
||||
this.fit();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.unsubscribeTheme) this.unsubscribeTheme();
|
||||
if (this.resizeObserver) this.resizeObserver.disconnect();
|
||||
window.terminalAPI.killPty(this.id);
|
||||
this.xterm.dispose();
|
||||
if (this.terminalWrapper && this.terminalWrapper.parentNode) {
|
||||
this.terminalWrapper.parentNode.removeChild(this.terminalWrapper);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// 现代高颜值终端主题库
|
||||
export const THEMES = {
|
||||
'tokyo-night': {
|
||||
name: 'Tokyo Night',
|
||||
themeName: 'tokyo-night',
|
||||
xterm: {
|
||||
background: '#1a1b26',
|
||||
foreground: '#c0caf5',
|
||||
cursor: '#c0caf5',
|
||||
cursorAccent: '#1a1b26',
|
||||
selectionBackground: '#33467c',
|
||||
selectionForeground: '#ffffff',
|
||||
black: '#15161e',
|
||||
red: '#f7768e',
|
||||
green: '#9ece6a',
|
||||
yellow: '#e0af68',
|
||||
blue: '#7aa2f7',
|
||||
magenta: '#bb9af7',
|
||||
cyan: '#7dcfff',
|
||||
white: '#a9b1d6',
|
||||
brightBlack: '#414868',
|
||||
brightRed: '#f7768e',
|
||||
brightGreen: '#9ece6a',
|
||||
brightYellow: '#e0af68',
|
||||
brightBlue: '#7aa2f7',
|
||||
brightMagenta: '#bb9af7',
|
||||
brightCyan: '#7dcfff',
|
||||
brightWhite: '#c0caf5',
|
||||
},
|
||||
},
|
||||
'catppuccin': {
|
||||
name: 'Catppuccin Mocha',
|
||||
themeName: 'catppuccin',
|
||||
xterm: {
|
||||
background: '#1e1e2e',
|
||||
foreground: '#cdd6f4',
|
||||
cursor: '#f5e0dc',
|
||||
cursorAccent: '#1e1e2e',
|
||||
selectionBackground: '#585b70',
|
||||
selectionForeground: '#cdd6f4',
|
||||
black: '#45475a',
|
||||
red: '#f38ba8',
|
||||
green: '#a6e3a1',
|
||||
yellow: '#f9e2af',
|
||||
blue: '#89b4fa',
|
||||
magenta: '#f5c2e7',
|
||||
cyan: '#94e2d5',
|
||||
white: '#bac2de',
|
||||
brightBlack: '#585b70',
|
||||
brightRed: '#f38ba8',
|
||||
brightGreen: '#a6e3a1',
|
||||
brightYellow: '#f9e2af',
|
||||
brightBlue: '#89b4fa',
|
||||
brightMagenta: '#f5c2e7',
|
||||
brightCyan: '#94e2d5',
|
||||
brightWhite: '#a6adc8',
|
||||
},
|
||||
},
|
||||
'dracula': {
|
||||
name: 'Dracula Dark',
|
||||
themeName: 'dracula',
|
||||
xterm: {
|
||||
background: '#282a36',
|
||||
foreground: '#f8f8f2',
|
||||
cursor: '#f8f8f2',
|
||||
cursorAccent: '#282a36',
|
||||
selectionBackground: '#44475a',
|
||||
selectionForeground: '#ffffff',
|
||||
black: '#21222c',
|
||||
red: '#ff5555',
|
||||
green: '#50fa7b',
|
||||
yellow: '#f1fa8c',
|
||||
blue: '#bd93f9',
|
||||
magenta: '#ff79c6',
|
||||
cyan: '#8be9fd',
|
||||
white: '#f8f8f2',
|
||||
brightBlack: '#6272a4',
|
||||
brightRed: '#ff6e6e',
|
||||
brightGreen: '#69ff94',
|
||||
brightYellow: '#ffffa5',
|
||||
brightBlue: '#d6acff',
|
||||
brightMagenta: '#ff92df',
|
||||
brightCyan: '#a4ffff',
|
||||
brightWhite: '#ffffff',
|
||||
},
|
||||
},
|
||||
'one-dark': {
|
||||
name: 'One Dark Pro',
|
||||
themeName: 'one-dark',
|
||||
xterm: {
|
||||
background: '#282c34',
|
||||
foreground: '#abb2bf',
|
||||
cursor: '#528bff',
|
||||
cursorAccent: '#282c34',
|
||||
selectionBackground: '#3e4451',
|
||||
selectionForeground: '#ffffff',
|
||||
black: '#1e2127',
|
||||
red: '#e06c75',
|
||||
green: '#98c379',
|
||||
yellow: '#d19a66',
|
||||
blue: '#61afef',
|
||||
magenta: '#c678dd',
|
||||
cyan: '#56b6c2',
|
||||
white: '#abb2bf',
|
||||
brightBlack: '#5c6370',
|
||||
brightRed: '#be5046',
|
||||
brightGreen: '#98c379',
|
||||
brightYellow: '#d19a66',
|
||||
brightBlue: '#61afef',
|
||||
brightMagenta: '#c678dd',
|
||||
brightCyan: '#56b6c2',
|
||||
brightWhite: '#ffffff',
|
||||
},
|
||||
},
|
||||
'cyberpunk': {
|
||||
name: 'Cyberpunk Neon',
|
||||
themeName: 'cyberpunk',
|
||||
xterm: {
|
||||
background: '#0d121e',
|
||||
foreground: '#e2f0fb',
|
||||
cursor: '#00ffcc',
|
||||
cursorAccent: '#0d121e',
|
||||
selectionBackground: '#ff007f',
|
||||
selectionForeground: '#ffffff',
|
||||
black: '#1b2234',
|
||||
red: '#ff0055',
|
||||
green: '#00ff9f',
|
||||
yellow: '#ffe600',
|
||||
blue: '#00b8ff',
|
||||
magenta: '#ff007f',
|
||||
cyan: '#00ffcc',
|
||||
white: '#e2f0fb',
|
||||
brightBlack: '#38435d',
|
||||
brightRed: '#ff3377',
|
||||
brightGreen: '#33ffb2',
|
||||
brightYellow: '#ffeb33',
|
||||
brightBlue: '#33c6ff',
|
||||
brightMagenta: '#ff3399',
|
||||
brightCyan: '#33ffd6',
|
||||
brightWhite: '#ffffff',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
class ThemeManager {
|
||||
constructor() {
|
||||
this.currentThemeKey = localStorage.getItem('next_term:theme') || 'tokyo-night';
|
||||
this.listeners = new Set();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.applyTheme(this.currentThemeKey);
|
||||
}
|
||||
|
||||
getCurrentTheme() {
|
||||
return THEMES[this.currentThemeKey] || THEMES['tokyo-night'];
|
||||
}
|
||||
|
||||
applyTheme(themeKey) {
|
||||
if (!THEMES[themeKey]) themeKey = 'tokyo-night';
|
||||
this.currentThemeKey = themeKey;
|
||||
localStorage.setItem('next_term:theme', themeKey);
|
||||
|
||||
const theme = THEMES[themeKey];
|
||||
document.documentElement.setAttribute('data-theme', theme.themeName);
|
||||
|
||||
// 通知所有监听者(xterm 实例)
|
||||
for (const callback of this.listeners) {
|
||||
callback(theme);
|
||||
}
|
||||
}
|
||||
|
||||
onThemeChange(callback) {
|
||||
this.listeners.add(callback);
|
||||
return () => this.listeners.delete(callback);
|
||||
}
|
||||
}
|
||||
|
||||
export const themeManager = new ThemeManager();
|
||||
Reference in New Issue
Block a user