commit c58c3198052c6c61e8d66cf72ab32b1ddd64a683 Author: zzzjy Date: Tue Sep 22 00:47:00 2026 +0800 feat: initial commit of next_term modern linux terminal diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9451024 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.DS_Store +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..7793779 --- /dev/null +++ b/README.md @@ -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 # 偏好设置及本地存储 +``` diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4b16922 --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..91a78b7 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/main/index.cjs b/src/main/index.cjs new file mode 100644 index 0000000..202a417 --- /dev/null +++ b/src/main/index.cjs @@ -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(); + } +}); diff --git a/src/main/pty-manager.cjs b/src/main/pty-manager.cjs new file mode 100644 index 0000000..d0f8578 --- /dev/null +++ b/src/main/pty-manager.cjs @@ -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 }; diff --git a/src/preload/index.cjs b/src/preload/index.cjs new file mode 100644 index 0000000..3ba1615 --- /dev/null +++ b/src/preload/index.cjs @@ -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'), +}); diff --git a/src/renderer/css/app.css b/src/renderer/css/app.css new file mode 100644 index 0000000..f74730c --- /dev/null +++ b/src/renderer/css/app.css @@ -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; +} diff --git a/src/renderer/css/components.css b/src/renderer/css/components.css new file mode 100644 index 0000000..b27dfc0 --- /dev/null +++ b/src/renderer/css/components.css @@ -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; } +} diff --git a/src/renderer/css/split.css b/src/renderer/css/split.css new file mode 100644 index 0000000..8b8d00a --- /dev/null +++ b/src/renderer/css/split.css @@ -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); +} diff --git a/src/renderer/css/tabs.css b/src/renderer/css/tabs.css new file mode 100644 index 0000000..7ca2a33 --- /dev/null +++ b/src/renderer/css/tabs.css @@ -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); +} diff --git a/src/renderer/css/variables.css b/src/renderer/css/variables.css new file mode 100644 index 0000000..2f83f09 --- /dev/null +++ b/src/renderer/css/variables.css @@ -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); +} diff --git a/src/renderer/index.html b/src/renderer/index.html new file mode 100644 index 0000000..867ce9c --- /dev/null +++ b/src/renderer/index.html @@ -0,0 +1,144 @@ + + + + + + + next_term - 现代 Linux 高性能终端 + + + + + + + + + + +
+ +
+
+
+ + + + + next_term +
+ + +
+ +
+ + +
+ + + + + + +
+ + + +
+
+
+ + +
+ + +
+
+
+ + linux-desktop +
+
+ Shell: + bash +
+
+
+
+ 编码: UTF-8 +
+
+ Tokyo Night +
+
+
+ + + + + + + + + +
+ + + + diff --git a/src/renderer/js/app.js b/src/renderer/js/app.js new file mode 100644 index 0000000..9a4e782 --- /dev/null +++ b/src/renderer/js/app.js @@ -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(); +}); diff --git a/src/renderer/js/command-palette.js b/src/renderer/js/command-palette.js new file mode 100644 index 0000000..c8b3eb0 --- /dev/null +++ b/src/renderer/js/command-palette.js @@ -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 = `
未匹配到相关命令
`; + return; + } + + this.filteredCommands.forEach((cmd, idx) => { + const item = document.createElement('div'); + item.className = `palette-item ${idx === this.selectedIndex ? 'selected' : ''}`; + item.innerHTML = ` +
+ ${cmd.title} +
+ ${cmd.shortcut ? `${cmd.shortcut}` : ''} + `; + + 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(); + } + } +} diff --git a/src/renderer/js/settings.js b/src/renderer/js/settings.js new file mode 100644 index 0000000..bdb9a58 --- /dev/null +++ b/src/renderer/js/settings.js @@ -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) => ``) + .join(''); + + this.modal.innerHTML = ` +
+

终端偏好设置 (Preferences)

+ +
+
+
+
+ 色彩主题 (Color Theme) + 选择预设的高对比度高颜值现代配色方案 +
+
+ +
+
+ +
+
+ 字体大小 (Font Size) + 网格字符栅格化大小 (11px ~ 24px) +
+
+ +
+
+ +
+
+ 字体族 (Font Family) + 优先使用的等宽编程字体 +
+
+ +
+
+ +
+
+ 光标样式 (Cursor Style) + 终端提示符光标形态 +
+
+ +
+
+ +
+
+ 光标平滑闪烁 (Cursor Blink) + 启用后光标将周期性呼吸闪烁 +
+
+ +
+
+
+ `; + + 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(); + } +} diff --git a/src/renderer/js/split-manager.js b/src/renderer/js/split-manager.js new file mode 100644 index 0000000..5365dec --- /dev/null +++ b/src/renderer/js/split-manager.js @@ -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(); + } +} diff --git a/src/renderer/js/tab-manager.js b/src/renderer/js/tab-manager.js new file mode 100644 index 0000000..958c8c4 --- /dev/null +++ b/src/renderer/js/tab-manager.js @@ -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 = ` + + + + + ${title} + × + `; + + // 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]); + } +} diff --git a/src/renderer/js/terminal-view.js b/src/renderer/js/terminal-view.js new file mode 100644 index 0000000..821a7e4 --- /dev/null +++ b/src/renderer/js/terminal-view.js @@ -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); + } + } +} diff --git a/src/renderer/js/theme-manager.js b/src/renderer/js/theme-manager.js new file mode 100644 index 0000000..bf5c354 --- /dev/null +++ b/src/renderer/js/theme-manager.js @@ -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();