commit 551723c2d49b0d85883ee75d3ce1ce63078d85be Author: 954801926@qq.com Date: Sun Sep 6 11:18:56 2026 +0800 OpenTerminal v0.2.1: terminal suite with SSH, split panes, themes and keyword highlighting - Local terminal (node-pty) + SSH sessions (ssh2) with unified data plane - dockview split panes, per-group '+' tab bar button, layout templates - Session replay buffer (late-subscriber catch-up), host key pinning, credential encryption via safeStorage - 12 builtin themes + custom theme editor, system font enumeration - Keyword highlighting: regex rules with priority, ANSI truecolor injection, chunk-boundary-safe stream (carry-over of partial escape sequences) - Dark antd theme app-wide, settings dialog redesign, error boundary - Tests: ssh loopback, session e2e, highlight split-chunk regression diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..052796b --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# dependencies +node_modules/ + +# build output +out/ +dist/ +tests/.session-e2e.cjs + +# research artifacts (XTerminal reverse-engineering notes, not part of the app) +research/ + +# logs / misc +*.log +.DS_Store + +# ts build info +*.tsbuildinfo diff --git a/README.md b/README.md new file mode 100644 index 0000000..096725b --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# OpenTerminal + +一个受 XTerminal 启发的开源终端应用:本地终端 + 分屏管理 + 字体/主题定制。Electron + React + TypeScript。 + +对应调研背景与逆向分析记录见 `research/`(XTerminal 5.7.17 解包产物与功能清单)。 + +## 快速开始 + +```bash +npm install # 已安装可跳过 +npm run dev # 开发模式(热更新) +npm run build # 生产构建到 out/ +npm run typecheck # 全量类型检查 +``` + +> Electron 二进制下载失败时:`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ node node_modules/electron/install.js` + +## 已实现功能(M1+M2) + +**终端核心** +- 本地终端(node-pty,Windows PowerShell / macOS zsh / Linux bash 自动探测) +- xterm.js 渲染:WebGL 高性能模式,加载失败/上下文丢失自动降级 DOM +- 会话生命周期:退出遮罩显示退出码、关闭按钮;窗口关闭时回收全部会话 +- 搜索(Ctrl+F 内嵌搜索条、全部高亮、n/N 计数) +- 剪贴板(Ctrl+Shift+C/V、可选选中即复制) +- 粘贴风险确认(多行/长文本需手动确认,可关闭) +- 字体/字号/字重/字距/行高/回滚行数/光标样式实时生效 + +**SSH 远程会话(M2)** +- ssh2 实现:密码 / 私钥(文件路径或粘贴内容)/ SSH Agent 三种认证 +- 连接管理器:左侧书签栏(分组手风琴)、双击连接、新建/编辑/删除对话框 +- 凭据安全:密码/私钥/口令经 Electron safeStorage(DPAPI)加密后落盘,渲染层永远接触不到明文密钥;支持「连接时询问密码」模式 +- 主机指纹校验(known hosts 固定):首次连接确认、指纹变更红色告警、30s 无响应自动拒绝 +- 连接中状态展示、失败原因提示、lastConnectedAt 记录 +- 会话数据面与本地终端完全统一(写/缩放/关闭/退出事件同通道) + +**分屏与布局** +- dockview 标签系统:水平/垂直分屏、拖拽重排、标签右键菜单(关闭/关闭其他/关闭右侧) +- 布局模板:把当前分屏保存为模板、一键应用(自动重建会话)、可删除 +- 空态引导页 + +**设置与主题** +- 12 款内置主题(Dracula / One Half / Solarized / Gruvbox / Nord / Monokai / GitHub / Afterglow / Material 等) +- 自定义主题编辑器:16 色 ANSI 调色板 + 前景/背景/光标/选区色,实时预览 +- 系统字体枚举(font-list)+ 内置默认字体栈 +- 设置持久化(userData/settings.json,原子写入)+ 多窗口实时同步 + +**测试** +- `node tests/ssh-loopback.mjs` — ssh2 客户端/服务端回环(认证、shell、数据、resize、指纹) +- `node tests/ssh-session-e2e.mjs` — 真实会话路由层端到端(需先跑 esbuild 打包命令,见脚本头注释) + +## 架构 + +``` +src/ +├── shared/ # 主/渲染进程共享契约(IPC 通道、设置、主题模型) +│ ├── ipc.ts # 通道名 + 负载类型 +│ ├── api.ts # window.api 接口(preload 暴露面) +│ ├── settings.ts # AppSettings / 默认值 +│ └── theme.ts # TerminalTheme / 内置主题 +├── main/ # 主进程 +│ ├── pty.ts # PTY 会话池(node-pty)→ 广播 PTY_DATA/PTY_EXIT +│ ├── settingsStore.ts # JSON 持久化 + 深合并兜底 +│ ├── layouts.ts # 布局模板存储(userData/layouts/*.json) +│ └── ipc.ts # 全部 ipcMain 注册 +├── preload/index.ts # contextBridge → window.api +└── renderer/src/ + ├── terminal/ # TerminalView:xterm 封装(渲染器/搜索/剪贴板/粘贴确认) + ├── workspace/ # dockview 工作区:工具栏/分屏/会话生命周期/布局模板 + ├── settings/ # zustand store + 设置对话框 + └── theme/editor/ # 自定义主题编辑器 +``` + +关键设计:**契约先行** —— `src/shared/` 是唯一契约源,主/预载/渲染三端各自实现,模块间零横向依赖(terminal 不感知 workspace,settings 不感知 terminal)。 + +## 数据位置 + +- 设置:`%APPDATA%/OpenTerminal/settings.json` +- 布局模板:`%APPDATA%/OpenTerminal/layouts/*.json` +- SSH 连接书签:`%APPDATA%/OpenTerminal/connections.json`(密钥字段 DPAPI 加密) +- 主机指纹:`%APPDATA%/OpenTerminal/ssh_known_hosts.json` diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..46da035 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,86 @@ +# OpenTerminal 长期路线图 + +目标:以开源组件组合复刻 XTerminal 的核心终端能力(调研依据见 `research/i18n-extracted.txt` 功能清单),按里程碑推进,每个里程碑可独立交付使用。 + +## 里程碑总览 + +| 阶段 | 内容 | 状态 | +|---|---|---| +| M1 | 本地终端 + 分屏 + 主题/字体 + 设置持久化 | ✅ 已完成(v0.1) | +| M2 | SSH 远程会话 + 连接管理器 | ✅ 已完成(v0.2) | +| M3 | SFTP 文件管理 + 传输面板 | 📋 下一步 | +| M3 | SFTP 文件管理 + 传输面板 | | +| M4 | 命令历史/命令库/补全 + 会话日志 + 关键词高亮 | | +| M5 | 广播输入 + ZMODEM + 快捷输入面板 | | +| M6 | AI 助手(终端内建议 + 独立对话) | | +| M7 | 打包分发(electron-builder + 自动更新) | | + +## M2 — SSH 远程会话 ✅ + +- [x] `src/main/ssh.ts`:ssh2 连接池(connect/shell/resize/close),PTY_DATA/PTY_EXIT 广播通道统一复用(本地与 SSH 会话同数据面) +- [x] 连接配置模型(`shared/connections.ts`):host/port/username、密码 vs 私钥(keyPath/keyContent)vs Agent、keepalive +- [x] 凭据加密存储:safeStorage(DPAPI) + `plain:` 兜底;渲染层只见 savedAuth 标志 +- [x] 连接管理器 UI:书签侧栏(分组手风琴)+ 新建/编辑对话框 + 双击连接 +- [x] Host key 校验:known_hosts 固定、首次确认、变更告警、30s 超时拒绝 +- [x] 测试:tests/ssh-loopback.mjs(ssh2 回环)+ tests/ssh-session-e2e.mjs(真实会话路由层 e2e) +- 待解锁后的 GUI 验收:双击书签连接、指纹弹窗交互、断线提示 +- 已知限制:SSH 面板暂不支持作为分屏参照(总在新标签打开);连接中 Modal 不可取消;agent 认证未在本机验证 + +## M3 — SFTP 文件管理(下一步) + +- [ ] `src/main/sftp.ts`:ssh2 SFTP 封装(readdir/stat/rename/mkdir/rm/mv,大文件分块传输 + 进度事件) +- [ ] 双栏界面(本地 ↔ 远程)+ 拖拽上传/下载 + 传输面板(队列/暂停/取消) +- [ ] 右键菜单:压缩/解压(tar.gz)、编辑远程文件(Monaco,下载-编辑-回传) +- 验收:100MB 文件传输进度流畅、可取消续传 + +## M4 — 命令系统与会话日志 + +- [ ] 命令历史(sqlite + better-sqlite3,按会话去重) +- [ ] 命令库(分组 + 参数变量 `{{param}}` + 一键运行/粘贴) +- [ ] 终端补全:历史 + 命令库 + 内置命令表(XTerminal 内置 607 个 Linux 命令文档,可从 tldr 页面生成) +- [ ] 会话日志:手动启停、纯文本落盘、断线暂停指示 +- [ ] 关键词高亮:正则规则 → 颜色(前景/背景 + 优先级),预设规则集(错误/警告/IP) +- 验收:补全 ↑↓/Tab/Esc 交互与 XTerminal 一致 + +## M5 — 多终端协同 + +- [ ] 广播输入(toggleBroadcast):选中多个会话同步键入 +- [ ] 快捷输入面板(Quick Input):右下角浮层 = 命令输入 + 快捷命令 + 最近命令 +- [ ] ZMODEM(rz/sz):渲染层检测 ZRQINIT/ZRINIT 序列,触发文件选择 +- [ ] 快捷键系统:增减字号、切换上下个终端、全局快捷键(globalShortcut) +- 验收:3 个分屏同步执行命令;rz 传输成功 + +## M6 — AI 助手 + +XTerminal 的模式(已从逆向确认):终端悬浮助手 + `fill_terminal_input` 工具(AI 生成命令 → 用户选「填充」或「回车执行」,危险命令标红确认)。 + +- [ ] Provider 抽象(OpenAI/Anthropic/本地 Ollama 兼容接口),API Key 本地加密存储 +- [ ] 选中即问(解释/翻译/修复错误)、命令建议 → 填充/执行二段式交互 +- [ ] 独立对话面板(多会话历史,本地存储) +- 验收:断网优雅降级;流式输出;不做任何未确认的自动执行 + +## M7 — 打包与分发 + +- [ ] electron-builder:nsis 安装包(Windows 优先,x64/arm64) +- [ ] 原生模块处理:node-pty 已用 @lydell 预编译版,无需 rebuild +- [ ] 自动更新(electron-updater + generic provider 或 GitHub Releases) +- [ ] 代码签名(可选) + +## 工程约定(贯穿各阶段) + +1. **契约先行**:新增功能先在 `src/shared/` 定义通道与模型,再实现两端 +2. **原生依赖**:优先选带预编译二进制的包(@lydell/node-pty 模式),避免用户机器装构建链 +3. **渲染层加密不可取**:不做代码混淆/加密(XTerminal 加密渲染层的做法牺牲了可调试性,收益仅是防抄) +4. **测试**:main 进程服务(pty/ssh/sftp)用 vitest 单测;渲染层交互靠 dev 手测 +5. **里程碑验收后打 git tag**(引入 git 后) + +## 已知待办(M1 遗留) + +- [x] ~~布局塌陷~~(v0.2.1 修复:antd `` 包裹层 `.ant-app` 无高度导致百分比高度链断裂) +- [x] ~~关闭标签页整窗崩溃~~(v0.2.1 修复:xterm IDisposable 对象被当函数调用;并新增 ErrorBoundary 兜底 + 渲染层报错转发主进程日志) +- [x] ~~首启动空屏~~(v0.2.1:启动自动打开一个终端) +- [ ] autoWrap=false 的固定列宽模式(xterm.js 无原生 wrapMode,需自定义列数控制) +- [ ] 终端标题跟随 OSC 序列(窗格标题显示当前目录/命令) +- [ ] 内置等宽字体打包(OFL 许可:JetBrains Mono / Fira Code 等,XTerminal 内置 19 款可作清单参考) +- [ ] rendererMode webgl 的终端数量上限自动降级(XTerminal:7 个后强制 DOM) +- [ ] 会话数据在面板间拖拽迁移(dockview 原生支持面板拖动,已具备) diff --git a/electron.vite.config.ts b/electron.vite.config.ts new file mode 100644 index 0000000..38e5182 --- /dev/null +++ b/electron.vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, externalizeDepsPlugin } from 'electron-vite' +import react from '@vitejs/plugin-react' +import { resolve } from 'path' + +const sharedAlias = { '@shared': resolve('src/shared') } + +export default defineConfig({ + main: { + plugins: [externalizeDepsPlugin()], + resolve: { alias: sharedAlias } + }, + preload: { + plugins: [externalizeDepsPlugin()], + resolve: { alias: sharedAlias } + }, + renderer: { + plugins: [react()], + resolve: { + alias: { + '@shared': resolve('src/shared'), + '@renderer': resolve('src/renderer/src') + } + } + } +}) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..57fb75f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3816 @@ +{ + "name": "open-terminal", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "open-terminal", + "version": "0.1.0", + "dependencies": { + "@lydell/node-pty": "^1.2.0-beta.15", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-serialize": "^0.14.0", + "@xterm/addon-webgl": "^0.19.0", + "@xterm/xterm": "^6.0.0", + "antd": "^6.6.2", + "dockview-react": "^8.2.0", + "font-list": "^2.1.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "ssh2": "^1.17.0", + "zustand": "^5.0.15" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.0", + "electron": "^44.2.0", + "electron-vite": "^5.0.0", + "typescript": "~5.9.0", + "vite": "^7.0.0" + } + }, + "node_modules/@ant-design/colors": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/@ant-design/colors/-/colors-8.0.1.tgz", + "integrity": "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.0" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/@ant-design/cssinjs/-/cssinjs-2.1.2.tgz", + "integrity": "sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/@ant-design/cssinjs-utils/-/cssinjs-utils-2.1.2.tgz", + "integrity": "sha512-5fTHQ158jJJ5dC/ECeyIdZUzKxE/mpEMRZxthyG1sw/AKRHKgJBg00Yi6ACVXgycdje7KahRNvNET/uBccwCnA==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^2.1.2", + "@babel/runtime": "^7.23.2", + "@rc-component/util": "^1.4.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/@ant-design/fast-color/-/fast-color-3.0.1.tgz", + "integrity": "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==", + "license": "MIT", + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "6.3.4", + "resolved": "https://registry.npmmirror.com/@ant-design/icons/-/icons-6.3.4.tgz", + "integrity": "sha512-kDoUlppczVyCUTFthF6cHmPzMBYqNZQhu9j6EyIX/YIdM9j9CZNZoJUjzVw7/1v1VO8WwVMmPp15r/uwIAS0AQ==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.1", + "@ant-design/icons-svg": "^4.6.0", + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.6.0", + "resolved": "https://registry.npmmirror.com/@ant-design/icons-svg/-/icons-svg-4.6.0.tgz", + "integrity": "sha512-PRomU725ABMf/lnQp5HiB7my1kjEbFY0D10N4lXYxK6TIB1gKjIVD5MRThpDaezLgw1D774J8eOeeDB0M6wHrQ==", + "license": "MIT" + }, + "node_modules/@ant-design/react-slick": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@ant-design/react-slick/-/react-slick-2.0.0.tgz", + "integrity": "sha512-HMS9sRoEmZey8LsE/Yo6+klhlzU12PisjrVcydW3So7RdklyEd2qehyU6a7Yp+OYN72mgsYs3NFCyP2lCPFVqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "clsx": "^2.1.1", + "json2mq": "^0.2.0", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", + "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/@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.npmmirror.com/@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/get/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmmirror.com/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/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lydell/node-pty": { + "version": "1.2.0-beta.15", + "resolved": "https://registry.npmmirror.com/@lydell/node-pty/-/node-pty-1.2.0-beta.15.tgz", + "integrity": "sha512-Br8wBxzbxFwdWgk9uQ+rdzE0xfoxOK4QuGH54swhRwc5IxP6H9Y1/bcyazRGvNUs6XkB5qNVkezuKSRxUwZe7A==", + "license": "MIT", + "optionalDependencies": { + "@lydell/node-pty-darwin-arm64": "1.2.0-beta.15", + "@lydell/node-pty-darwin-x64": "1.2.0-beta.15", + "@lydell/node-pty-linux-arm64": "1.2.0-beta.15", + "@lydell/node-pty-linux-x64": "1.2.0-beta.15", + "@lydell/node-pty-win32-arm64": "1.2.0-beta.15", + "@lydell/node-pty-win32-x64": "1.2.0-beta.15" + } + }, + "node_modules/@lydell/node-pty-darwin-arm64": { + "version": "1.2.0-beta.15", + "resolved": "https://registry.npmmirror.com/@lydell/node-pty-darwin-arm64/-/node-pty-darwin-arm64-1.2.0-beta.15.tgz", + "integrity": "sha512-6TSBbzdcLiNTHl1mTuzflqXrkmcC36USVGvERoDgvHk2ItEDaMaFZuAJ1CqPmwYj0DyhCS16TVS8OGK9xZnjyQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lydell/node-pty-darwin-x64": { + "version": "1.2.0-beta.15", + "resolved": "https://registry.npmmirror.com/@lydell/node-pty-darwin-x64/-/node-pty-darwin-x64-1.2.0-beta.15.tgz", + "integrity": "sha512-yDT2oqPqYMBScyuk1U9Rg5VKcrbMOD9o9jWYYamDADA3NSbUISroPChrqYRQ74Y7BQtNH4gqYAiWOZRi5uQZ0Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lydell/node-pty-linux-arm64": { + "version": "1.2.0-beta.15", + "resolved": "https://registry.npmmirror.com/@lydell/node-pty-linux-arm64/-/node-pty-linux-arm64-1.2.0-beta.15.tgz", + "integrity": "sha512-wkbNF7dYAmtJv+o2+iztVlNwnUB4B0uX0wh/UD+mwMcmE2gNMnW9GChXO7fEE5XJokD0vB5idiHpGegaN+G/sg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lydell/node-pty-linux-x64": { + "version": "1.2.0-beta.15", + "resolved": "https://registry.npmmirror.com/@lydell/node-pty-linux-x64/-/node-pty-linux-x64-1.2.0-beta.15.tgz", + "integrity": "sha512-+U/5AVvHT6W+8OCYcnJgN0Qgc0ycO3TfD6aaFJHK+WHij797f8gsi5dV1HEO9l6YQmWCD+VL5gaLDhx3mxHwCA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lydell/node-pty-win32-arm64": { + "version": "1.2.0-beta.15", + "resolved": "https://registry.npmmirror.com/@lydell/node-pty-win32-arm64/-/node-pty-win32-arm64-1.2.0-beta.15.tgz", + "integrity": "sha512-pyAk91w7wnnKrD4mrHXtIXRfmzSWV5bEzvRhurXcMCtCc2TJ424ciUskIgWMhAPP6y3KyUnqElj+U6kY3iOt0A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@lydell/node-pty-win32-x64": { + "version": "1.2.0-beta.15", + "resolved": "https://registry.npmmirror.com/@lydell/node-pty-win32-x64/-/node-pty-win32-x64-1.2.0-beta.15.tgz", + "integrity": "sha512-2f8twEmDVxZ7drchAXjtevpmSPhFok0avAnzXro4t5gmz0xsPNKkoZvymwtuIS3xo7PzQqZOPQ/YzwEMb7oIzQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rc-component/async-validator": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/@rc-component/async-validator/-/async-validator-6.0.0.tgz", + "integrity": "sha512-D3AGQwdyE58gmvx6waVSXJ80JGO+IY5L2O8HDnSOex7JNlzB3GuN/4hyHNTdhy2qtOhkpbIjmeAN3tL993wKbA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/cascader": { + "version": "1.22.0", + "resolved": "https://registry.npmmirror.com/@rc-component/cascader/-/cascader-1.22.0.tgz", + "integrity": "sha512-SffrA57aS9oub3VuI7ajPhJTPtaNxngSvtRhD40Rd8dwJ5vfWPSrVanWgeepdWFGBt7EHftIK5RUU0u3rCTwWw==", + "license": "MIT", + "dependencies": { + "@rc-component/select": "~1.10.0", + "@rc-component/tree": "~1.4.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/checkbox": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@rc-component/checkbox/-/checkbox-2.0.0.tgz", + "integrity": "sha512-3CXGPpAR9gsPKeO2N78HAPOzU30UdemD6HGJoWVJOpa6WleaGB5kzZj3v6bdTZab31YuWgY/RxV3VKPctn0DwQ==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/collapse": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/@rc-component/collapse/-/collapse-1.2.0.tgz", + "integrity": "sha512-ZRYSKSS39qsFx93p26bde7JUZJshsUBEQRlRXPuJYlAiNX0vyYlF5TsAm8JZN3LcF8XvKikdzPbgAtXSbkLUkw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/@rc-component/color-picker/-/color-picker-3.1.1.tgz", + "integrity": "sha512-OHaCHLHszCegdXmIq2ZRIZBN/EtpT6Wm8SG/gpzLATHbVKc/avvuKi+zlOuk05FTWvgaMmpxAko44uRJ3M+2pg==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.1", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/context": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/@rc-component/context/-/context-2.0.2.tgz", + "integrity": "sha512-uiGpAlblCNlziHPwj4S4Iy/oemeuz/hR03mbiEjTCXwsqOIN3BOzsRMyDwpyO5Fm0vIEEJRUf9ZtbRLbhksuTA==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/dialog": { + "version": "1.10.0", + "resolved": "https://registry.npmmirror.com/@rc-component/dialog/-/dialog-1.10.0.tgz", + "integrity": "sha512-eDukNlz9vNszAGv7i3zKXdxEd3wgVmNxuJijYt8zvTh17QwTu8KK/bdURRd/lU4qaMzhO1HKKmMrwOnkaw0BvQ==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.3.3", + "@rc-component/portal": "^2.1.0", + "@rc-component/util": "^1.9.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/drawer": { + "version": "1.4.2", + "resolved": "https://registry.npmmirror.com/@rc-component/drawer/-/drawer-1.4.2.tgz", + "integrity": "sha512-1ib+fZEp6FBu+YvcIktm+nCQ+Q+qIpwpoaJH6opGr4ofh2QMq+qdr5DLC4oCf5qf3pcWX9lUWPYX652k4ini8Q==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/portal": "^2.1.3", + "@rc-component/util": "^1.9.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/dropdown": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/@rc-component/dropdown/-/dropdown-1.0.3.tgz", + "integrity": "sha512-YTST/N6kpqpDz3IMuM/PSSZnrDpSOA6dgHv12gPA90ZTSLv2CoqkZ0+9NtwTY6BeO7dstPblSic2QJg7dSFy/g==", + "license": "MIT", + "dependencies": { + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/@rc-component/form": { + "version": "1.8.6", + "resolved": "https://registry.npmmirror.com/@rc-component/form/-/form-1.8.6.tgz", + "integrity": "sha512-1EXVsSKPZC6kGrIqxVck7kAWM6T65b6et/n5wIcyiaIa41VrDshD0nVLHoBDEIZr2ATQsOP6icb4XQkNFMLBAw==", + "license": "MIT", + "dependencies": { + "@rc-component/async-validator": "^6.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/image": { + "version": "1.10.0", + "resolved": "https://registry.npmmirror.com/@rc-component/image/-/image-1.10.0.tgz", + "integrity": "sha512-BjeZCRQ+hw+4WAhvrw8rJvy5fckA2xpf/X2XQEOABUHvLTNB9inB98X3Mp54jYQ7g10DfWERQWHXeC4ylxp1Uw==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.0.0", + "@rc-component/portal": "^2.1.2", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/input": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/@rc-component/input/-/input-1.3.1.tgz", + "integrity": "sha512-iFvTUT9W+JC/MSin2aGAk8NqsVlTzcExNC9DZariON1IWirju9NoNeEk47an4Q8iHazkoVI/y1LnDi88+CPcig==", + "license": "MIT", + "dependencies": { + "@rc-component/resize-observer": "^1.1.1", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@rc-component/input-number": { + "version": "1.6.2", + "resolved": "https://registry.npmmirror.com/@rc-component/input-number/-/input-number-1.6.2.tgz", + "integrity": "sha512-Gjcq7meZlCOiWN1t1xCC+7/s85humHVokTBI7PJgTfoyw5OWF74y3e6P8PHX104g9+b54jsodFIzyaj6p8LI9w==", + "license": "MIT", + "dependencies": { + "@rc-component/mini-decimal": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/listy": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/@rc-component/listy/-/listy-1.2.3.tgz", + "integrity": "sha512-IXiMjV5s0rczLBlfh7G5nB4M3365mrEeedjwKtf5I+Ns3PqRUsebR2h5u8CeFarsVfLUPC2I5p0h09TNoOWyvQ==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/portal": "^2.0.0", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.3.1", + "@rc-component/virtual-list": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/mentions": { + "version": "1.12.0", + "resolved": "https://registry.npmmirror.com/@rc-component/mentions/-/mentions-1.12.0.tgz", + "integrity": "sha512-v5MBx8zDcqCT+uybJgKpcx3Fgrvj0m3u+A/15RlSBaMx12lLoJZTTYggC3pbYYfohcKujWWUyBz6O6Gp2vGiNA==", + "license": "MIT", + "dependencies": { + "@rc-component/input": "~1.3.1", + "@rc-component/menu": "~1.5.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/menu": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/@rc-component/menu/-/menu-1.5.0.tgz", + "integrity": "sha512-RjvzPsZkwVEg3xu+/jDDqDeUCBJAnWylONFlGeQgg5enlNAnlpybqhaMGE/UPEIvr6hL90PGhjFBguJqj0Qb2Q==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/overflow": "^1.0.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/@rc-component/mini-decimal/-/mini-decimal-1.1.4.tgz", + "integrity": "sha512-xiuXcaCwyOWpD8a8scdExFl+bntNphAW8XeenL1ig2en0AAZY0Pcp4pC0dI22qJ+NvxKn9RoNIoRdqYU3BLH4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/motion": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/@rc-component/motion/-/motion-1.3.3.tgz", + "integrity": "sha512-Xh3IszxvlSv3/PLYFyC2UZi9LNB83yOnkB/LNmRzaypZLvkhqUIPS7MQpGZcCMWrNsXV2p6YTSWbSGvFpEle9A==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/@rc-component/mutate-observer/-/mutate-observer-2.0.1.tgz", + "integrity": "sha512-AyarjoLU5YlxuValRi+w8JRH2Z84TBbFO2RoGWz9d8bSu0FqT8DtugH3xC3BV7mUwlmROFauyWuXFuq4IFbH+w==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/notification": { + "version": "2.0.8", + "resolved": "https://registry.npmmirror.com/@rc-component/notification/-/notification-2.0.8.tgz", + "integrity": "sha512-MTRA3b8eHE14gh5R9nlXzbWZEDEBFVkEHj/FI1VOXw2zrSnSxqbuuU3drsq+k53e6MHwtegoFay1tnNYhtl61Q==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/overflow": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@rc-component/overflow/-/overflow-1.0.1.tgz", + "integrity": "sha512-syfmgAABaHCnCDzPwHZ/2tuvIcpOO3jefYZMmfkN+pmo8HKTzsfhS57vxo4ksPdN0By+uWVJhJWNFozNBxi2eA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@rc-component/resize-observer": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/pagination": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/@rc-component/pagination/-/pagination-1.4.0.tgz", + "integrity": "sha512-CW1g7P9V8u+e8JQdUsl2RWg+GCsoee0mtJjZUCCxn/vb3jzOwDKm6hAdwddHCVBfWJ58eGUBZz3IvnU8rRktjw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/picker": { + "version": "1.12.2", + "resolved": "https://registry.npmmirror.com/@rc-component/picker/-/picker-1.12.2.tgz", + "integrity": "sha512-ZB1A+tzTQkgtR4flPrAMvxIAOelIIOOOsp22fU2Nad6PbZcJacnyGJUJqUH7ZS9mwxeUwurf1pLMKXrVsK1tlA==", + "license": "MIT", + "dependencies": { + "@rc-component/overflow": "^1.0.0", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/trigger": "^3.6.15", + "@rc-component/util": "^1.13.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=12.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/@rc-component/portal": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/@rc-component/portal/-/portal-2.2.1.tgz", + "integrity": "sha512-ck+r1kW/JSv0wxPji3KN2ss9K6Z0qqwusw/mf/0JobXhZ8hC2ejZwCJObW/SvDi0uhA0VzmCnx0CaCci95tcmA==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=12.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/progress": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/@rc-component/progress/-/progress-1.0.3.tgz", + "integrity": "sha512-Q1nPLIvKr95el/q76yaBh5nv1hqCsWaHQb8U7vXEDMefEhHgM7n3CPoN+o4LHnTyANQHmMFgoS/naAT4Bhxuaw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@rc-component/qrcode/-/qrcode-2.0.0.tgz", + "integrity": "sha512-aAv3QhPP1xyafuTZOxub6a54pCeBnN3IwQkpETrBtthq4BL5IgxnCbuoBWPDpdLw1y1j6BgBUCAKV92+yX06Dw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/rate": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@rc-component/rate/-/rate-1.0.1.tgz", + "integrity": "sha512-bkXxeBqDpl5IOC7yL7GcSYjQx9G8H+6kLYQnNZWeBYq2OYIv1MONd6mqKTjnnJYpV0cQIU2z3atdW0j1kttpTw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/resize-observer": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@rc-component/resize-observer/-/resize-observer-1.1.2.tgz", + "integrity": "sha512-t/Bb0W8uvL4PYKAB3YcChC+DlHh0Wt5kM7q/J+0qpVEUMLe7Hk5zuvc9km0hMnTFPSx5Z7Wu/fzCLN6erVLE8Q==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/segmented": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/@rc-component/segmented/-/segmented-1.3.0.tgz", + "integrity": "sha512-5J/bJ01mbDnoA6P/FW8SxUvKn+OgUSTZJPzCNnTBntG50tzoP7DydGhqxp7ggZXZls7me3mc2EQDXakU3iTVFg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@rc-component/select": { + "version": "1.10.1", + "resolved": "https://registry.npmmirror.com/@rc-component/select/-/select-1.10.1.tgz", + "integrity": "sha512-H+yQsl+qED9NilQ3g6zdpsMwUgwVjrcMTkNHAWRVU/MoNCYgTbDgU+MIMgZDK+rVdd2JUfI/MkysMcZZ0cyQKw==", + "license": "MIT", + "dependencies": { + "@rc-component/overflow": "^1.0.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.11.1", + "@rc-component/virtual-list": "^1.2.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/slider": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@rc-component/slider/-/slider-1.1.1.tgz", + "integrity": "sha512-LSzgWGYDgeCDgR4r1XlU29gbYws6HpLnvJd/uMhLeW/vQgxldeR+Wb4uzHDCHiYEbr1bnEHWdjkPxjJRHxuiig==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/steps": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/@rc-component/steps/-/steps-1.2.3.tgz", + "integrity": "sha512-/b9gHcvDGjTDWJZW7+1kKFvzs0jrEeQ8mFvvIgX+h7xNT0FsOYODJYKjyJR4fPbvMBCfCOEqaBK66HBxov5BQA==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/switch": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/@rc-component/switch/-/switch-1.0.3.tgz", + "integrity": "sha512-Jgi+EbOBquje/XNdofr7xbJQZPYJP+BlPfR0h+WN4zFkdtB2EWqEfvkXJWeipflwjWip0/17rNbxEAqs8hVHfw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/table": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/@rc-component/table/-/table-1.11.1.tgz", + "integrity": "sha512-OWdS6DMmeWb7bJBGqPxYZpQbzBlBiXZUu2sqo6Ii7Sjs9GeK1IsrXrWk26SL2c6KEseabswdxrRj7WUm9LdECw==", + "license": "MIT", + "dependencies": { + "@rc-component/context": "^2.0.1", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.11.1", + "@rc-component/virtual-list": "^1.0.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/tabs": { + "version": "1.13.0", + "resolved": "https://registry.npmmirror.com/@rc-component/tabs/-/tabs-1.13.0.tgz", + "integrity": "sha512-3FXr+9ZII8tFLWuDWrgjRaxZd2mazkKJAGRFEfVmp4r1qnBglOlxQ78l54Td1aj6/iFaaEReKSAF2HToztU5uw==", + "license": "MIT", + "dependencies": { + "@rc-component/dropdown": "~1.0.0", + "@rc-component/menu": "~1.5.0", + "@rc-component/motion": "^1.1.3", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tooltip": { + "version": "1.5.2", + "resolved": "https://registry.npmmirror.com/@rc-component/tooltip/-/tooltip-1.5.2.tgz", + "integrity": "sha512-VLq4BclU3LgRe46g2hTr2bdsM8tFYs7dVwpuD88rPNNg4lCGe8mBSS4FiRzYzS+ffnZ46y4SI8KYKo9K7qBnhw==", + "license": "MIT", + "dependencies": { + "@rc-component/trigger": "^3.10.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "2.4.0", + "resolved": "https://registry.npmmirror.com/@rc-component/tour/-/tour-2.4.0.tgz", + "integrity": "sha512-aui4r4TqmTzwaBgcQxHYep8kM8PTjZFufjokObpy35KfFeZ0k9ArquWFZqegQlH24P14t+F0qO0mGTgzlav1yg==", + "license": "MIT", + "dependencies": { + "@rc-component/portal": "^2.2.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.7.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tree": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/@rc-component/tree/-/tree-1.4.0.tgz", + "integrity": "sha512-dGsJGDJQedA0BqqVgj3F8BvHXTSZijyhTXdbAdkcx8lynzZkty/CV3Z3LOm/fxz+BCfl3dfGiAQpb7Q5XNvl0Q==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.0.0", + "@rc-component/util": "^1.11.1", + "@rc-component/virtual-list": "^1.2.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/tree-select": { + "version": "1.16.1", + "resolved": "https://registry.npmmirror.com/@rc-component/tree-select/-/tree-select-1.16.1.tgz", + "integrity": "sha512-a1Oi6EJhqAhdOxxupdJi6fP0RPHMKn5TcfkX2+llaQ4lF4nwfH7b6SCHcnsybaa2s+pk1yZYwVyeOYkDnEBRdg==", + "license": "MIT", + "dependencies": { + "@rc-component/select": "~1.10.0", + "@rc-component/tree": "~1.4.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/trigger": { + "version": "3.10.1", + "resolved": "https://registry.npmmirror.com/@rc-component/trigger/-/trigger-3.10.1.tgz", + "integrity": "sha512-mXlDN0IXdtV8Yqqm8195ECCyrbmfvvfKvwVvSlH0+qvKD6BUF8gRhEjSy0FOcD1+CcDRHgTiX99LoxfQrmh3Cw==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.3.3", + "@rc-component/portal": "^2.2.1", + "@rc-component/resize-observer": "^1.1.2", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/upload": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@rc-component/upload/-/upload-1.1.1.tgz", + "integrity": "sha512-GvYWSKeaJTOxxC5p6+nOSadzfvXA1h8C/iHFPFZX+szH3JUXrvs+DLiW8YUTBgvMh8m63mJeHrlYlJzAlg+pDA==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/util": { + "version": "1.13.0", + "resolved": "https://registry.npmmirror.com/@rc-component/util/-/util-1.13.0.tgz", + "integrity": "sha512-IXf2zBfZrbQGT+G8LxnoICDBGnMHIPIiq5HCRn3EDfKUx28x1rnbZBj46ULJf4sM8ImTG1SepqfNBdxZziqQ4w==", + "license": "MIT", + "dependencies": { + "is-mobile": "^5.0.0", + "react-is": "^19.2.7" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/virtual-list": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/@rc-component/virtual-list/-/virtual-list-1.5.1.tgz", + "integrity": "sha512-boqHxdtyWC88u8quYgEO49bcBy5fzRiOcnBge+N4nLzs2k8hUQ/yw7JE9dM6yCBE4jSm5YSHVCVMS+suBuJGKA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^8.0.0", + "@rc-component/resize-observer": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/virtual-list/node_modules/@babel/runtime": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-8.0.0.tgz", + "integrity": "sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmmirror.com/@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.npmmirror.com/@xterm/addon-search/-/addon-search-0.16.0.tgz", + "integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==", + "license": "MIT" + }, + "node_modules/@xterm/addon-serialize": { + "version": "0.14.0", + "resolved": "https://registry.npmmirror.com/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz", + "integrity": "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==", + "license": "MIT" + }, + "node_modules/@xterm/addon-webgl": { + "version": "0.19.0", + "resolved": "https://registry.npmmirror.com/@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.npmmirror.com/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, + "node_modules/antd": { + "version": "6.6.2", + "resolved": "https://registry.npmmirror.com/antd/-/antd-6.6.2.tgz", + "integrity": "sha512-aTOPWXsqfWrlSiI0a1qR9UWR4jBCTJ8FNZfZmYQP9/aGGLJSrw9/c5uE+4vSVSt3riQlIPAwR3BcAgEHmE7GMg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.1", + "@ant-design/cssinjs": "^2.1.2", + "@ant-design/cssinjs-utils": "^2.1.2", + "@ant-design/fast-color": "^3.0.1", + "@ant-design/icons": "^6.3.2", + "@ant-design/react-slick": "~2.0.0", + "@babel/runtime": "^7.29.2", + "@rc-component/cascader": "~1.22.0", + "@rc-component/checkbox": "~2.0.0", + "@rc-component/collapse": "~1.2.0", + "@rc-component/color-picker": "~3.1.1", + "@rc-component/dialog": "~1.10.0", + "@rc-component/drawer": "~1.4.2", + "@rc-component/dropdown": "~1.0.3", + "@rc-component/form": "~1.8.6", + "@rc-component/image": "~1.10.0", + "@rc-component/input": "~1.3.1", + "@rc-component/input-number": "~1.6.2", + "@rc-component/listy": "~1.2.3", + "@rc-component/mentions": "~1.12.0", + "@rc-component/menu": "~1.5.0", + "@rc-component/motion": "^1.3.3", + "@rc-component/mutate-observer": "^2.0.1", + "@rc-component/notification": "~2.0.8", + "@rc-component/pagination": "~1.4.0", + "@rc-component/picker": "~1.12.0", + "@rc-component/progress": "~1.0.3", + "@rc-component/qrcode": "~2.0.0", + "@rc-component/rate": "~1.0.1", + "@rc-component/resize-observer": "^1.1.2", + "@rc-component/segmented": "~1.3.0", + "@rc-component/select": "~1.10.1", + "@rc-component/slider": "~1.1.1", + "@rc-component/steps": "~1.2.3", + "@rc-component/switch": "~1.0.3", + "@rc-component/table": "~1.11.1", + "@rc-component/tabs": "~1.13.0", + "@rc-component/tooltip": "~1.5.2", + "@rc-component/tour": "~2.4.0", + "@rc-component/tree": "~1.4.0", + "@rc-component/tree-select": "~1.16.1", + "@rc-component/trigger": "^3.10.1", + "@rc-component/upload": "~1.1.1", + "@rc-component/util": "^1.12.0", + "clsx": "^2.1.1", + "dayjs": "^1.11.11", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmmirror.com/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.21", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmmirror.com/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmmirror.com/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/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/dockview": { + "version": "8.2.0", + "resolved": "https://registry.npmmirror.com/dockview/-/dockview-8.2.0.tgz", + "integrity": "sha512-Jj+fnFWSrdEhF+1DmpGNYjEvyqf7J0FHkDqsD98wL4WOYB2CSKavDCL49B3LHIGYvWNN7Q1O1j4p7Pts38CM5A==", + "license": "MIT", + "dependencies": { + "dockview-core": "^8.2.0" + } + }, + "node_modules/dockview-core": { + "version": "8.2.0", + "resolved": "https://registry.npmmirror.com/dockview-core/-/dockview-core-8.2.0.tgz", + "integrity": "sha512-+L+xdvmO1b4in8rVkFtQM13IkPDKqDLoHJvq5HtAIsMxsPptSVveKeViYBqquaoHF+t0xb9f6692gTeKnjCKGw==", + "license": "MIT" + }, + "node_modules/dockview-react": { + "version": "8.2.0", + "resolved": "https://registry.npmmirror.com/dockview-react/-/dockview-react-8.2.0.tgz", + "integrity": "sha512-OHa/4uuaw3VpHVkGophdYh2U1f5tOvp/ANf5CAE3QQVh2oSMKNimaWaFXEg/R1wGBdZogQyYOKQSU+wLrE0taw==", + "license": "MIT", + "dependencies": { + "dockview": "^8.2.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/electron": { + "version": "44.2.0", + "resolved": "https://registry.npmmirror.com/electron/-/electron-44.2.0.tgz", + "integrity": "sha512-oK1icjhapp3xsUZycO9WZaLmd3hJnA7ieobC4mpdtO1pEN+PPGWpA3m1Mgzzuc1wzSD2Wai4zcH4yfpGJqxFMA==", + "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/electron-to-chromium": { + "version": "1.5.422", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz", + "integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==", + "dev": true, + "license": "ISC" + }, + "node_modules/electron-vite": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/electron-vite/-/electron-vite-5.0.0.tgz", + "integrity": "sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.4", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "cac": "^6.7.14", + "esbuild": "^0.25.11", + "magic-string": "^0.30.19", + "picocolors": "^1.1.1" + }, + "bin": { + "electron-vite": "bin/electron-vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@swc/core": "^1.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + } + } + }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/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/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/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/font-list": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/font-list/-/font-list-2.1.0.tgz", + "integrity": "sha512-GMDcD8Wymmhz7pxOzu+Gm/bbVzxvUHJJSiLPCF/rt02g2W7P08Nhz6XiaYjXo8xklXWhLoCiyi1cBPwwASC2fQ==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmmirror.com/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/is-mobile": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/is-mobile/-/is-mobile-5.0.0.tgz", + "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "license": "MIT", + "dependencies": { + "string-convert": "^0.2.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nan": { + "version": "2.28.0", + "resolved": "https://registry.npmmirror.com/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", + "license": "MIT", + "optional": true + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.54", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/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/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmmirror.com/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmmirror.com/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "license": "MIT" + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/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/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmmirror.com/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/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmmirror.com/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.29.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.2", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmmirror.com/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "5.0.15", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c58ed02 --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "open-terminal", + "productName": "OpenTerminal", + "version": "0.1.0", + "description": "Terminal with split panes, themes and fonts (XTerminal-inspired)", + "main": "out/main/index.js", + "author": "Bill", + "private": true, + "scripts": { + "dev": "electron-vite dev", + "build": "electron-vite build", + "preview": "electron-vite preview", + "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json" + }, + "dependencies": { + "@lydell/node-pty": "^1.2.0-beta.15", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-serialize": "^0.14.0", + "@xterm/addon-webgl": "^0.19.0", + "@xterm/xterm": "^6.0.0", + "antd": "^6.6.2", + "dockview-react": "^8.2.0", + "font-list": "^2.1.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "ssh2": "^1.17.0", + "zustand": "^5.0.15" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.0", + "electron": "^44.2.0", + "electron-vite": "^5.0.0", + "typescript": "~5.9.0", + "vite": "^7.0.0" + } +} diff --git a/src/main/broadcast.ts b/src/main/broadcast.ts new file mode 100644 index 0000000..c2ed2bb --- /dev/null +++ b/src/main/broadcast.ts @@ -0,0 +1,8 @@ +import { BrowserWindow } from 'electron' + +/** Send `payload` to every live BrowserWindow. */ +export function broadcast(channel: string, ...args: unknown[]): void { + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) win.webContents.send(channel, ...args) + } +} \ No newline at end of file diff --git a/src/main/connectionsStore.ts b/src/main/connectionsStore.ts new file mode 100644 index 0000000..c9281a3 --- /dev/null +++ b/src/main/connectionsStore.ts @@ -0,0 +1,219 @@ +/** + * SSH connection bookmarks. Persists to /connections.json as an array. + * + * Secret fields (password / keyContent / passphrase) are persisted only + * encrypted via Electron safeStorage (base64 blob in `*_enc` fields). The + * renderer contracts (`SshConnection`) never contain a secret: `*_enc` fields + * are stripped from the returned objects and replaced with `savedAuth` flags. + * + * When safeStorage is unavailable (e.g. during headless dev) the plaintext + * secret is base64'd and prefixed with `plain:` so the record stays + * serialisable — a dev-only fallback surfaced via console.warn. + */ + +import { safeStorage } from 'electron' +import { randomUUID } from 'crypto' +import { mkdirSync, readFileSync, writeFileSync } from 'fs' +import { dirname } from 'path' +import type { SshAuthMethod, SshConnection, SshConnectionInput } from '../shared/connections' + +const ENC_SUFFIX = '_enc' +const PLAIN_PREFIX = 'plain:' +const SECRET_KEYS = ['password', 'keyContent', 'passphrase'] as const + +/** Internal persisted record: public fields + encrypted secret fields. */ +interface StoredConnection { + id: string + name: string + group?: string + host: string + port: number + username: string + auth: SshAuthMethod + askPasswordAtConnect: boolean + askPassphraseAtConnect: boolean + keyPath?: string + keepaliveIntervalSec: number + createdAt: number + lastConnectedAt?: number + password_enc?: string + keyContent_enc?: string + passphrase_enc?: string +} + +/** Public fields of SshConnectionInput reflected onto a StoredConnection. */ +const PUBLIC_KEYS = [ + 'name', + 'group', + 'host', + 'port', + 'username', + 'auth', + 'askPasswordAtConnect', + 'askPassphraseAtConnect', + 'keyPath', + 'keepaliveIntervalSec' +] as const satisfies readonly (keyof Omit)[] + +function encrypt(plain: string): string { + if (safeStorage.isEncryptionAvailable()) { + return safeStorage.encryptString(plain).toString('base64') + } + // Dev fallback: no OS keychain, store a reversible base64 marker so the + // field still round-trips through JSON. + console.warn( + '[connections] safeStorage unavailable - storing plain: prefixed base64 secret (dev only)' + ) + return PLAIN_PREFIX + Buffer.from(plain, 'utf8').toString('base64') +} + +function decrypt(stored: string | undefined): string | undefined { + if (typeof stored !== 'string' || stored.length === 0) return undefined + if (stored.startsWith(PLAIN_PREFIX)) { + return Buffer.from(stored.slice(PLAIN_PREFIX.length), 'base64').toString('utf8') + } + if (safeStorage.isEncryptionAvailable()) { + try { + return safeStorage.decryptString(Buffer.from(stored, 'base64')) + } catch { + return undefined + } + } + return undefined +} + +/** Strip secret fields from an internal record into the renderer contract. */ +function toPublic(stored: StoredConnection): SshConnection { + return { + id: stored.id, + name: stored.name, + group: stored.group, + host: stored.host, + port: stored.port, + username: stored.username, + auth: stored.auth, + askPasswordAtConnect: stored.askPasswordAtConnect, + askPassphraseAtConnect: stored.askPassphraseAtConnect, + keyPath: stored.keyPath, + keepaliveIntervalSec: stored.keepaliveIntervalSec, + createdAt: stored.createdAt, + lastConnectedAt: stored.lastConnectedAt, + savedAuth: { + hasPassword: stored.password_enc !== undefined && stored.password_enc.length > 0, + hasKeyContent: stored.keyContent_enc !== undefined && stored.keyContent_enc.length > 0, + hasPassphrase: stored.passphrase_enc !== undefined && stored.passphrase_enc.length > 0 + } + } +} + +export class ConnectionsStore { + constructor(private readonly filePath: string) {} + + private load(): StoredConnection[] { + try { + const raw: unknown = JSON.parse(readFileSync(this.filePath, 'utf8')) + if (Array.isArray(raw)) { + return raw.filter( + (x): x is StoredConnection => + x !== null && + typeof x === 'object' && + typeof (x as StoredConnection).id === 'string' && + typeof (x as StoredConnection).host === 'string' + ) + } + } catch { + // missing / corrupted file -> start fresh + } + return [] + } + + private save(list: StoredConnection[]): void { + mkdirSync(dirname(this.filePath), { recursive: true }) + writeFileSync(this.filePath, JSON.stringify(list, null, 2), 'utf8') + } + + listConnections(): SshConnection[] { + return this.load().map(toPublic) + } + + /** Decrypt a stored secret for a connection (undefined when absent). */ + + /** Decrypt a stored secret for a connection (undefined when absent). */ + getSecret( + conn: SshConnection, + field: 'password' | 'keyContent' | 'passphrase' + ): string | undefined { + const encField = `${field}${ENC_SUFFIX}` as keyof StoredConnection + const stored = this.load().find((c) => c.id === conn.id) + return stored ? decrypt(stored[encField] as string | undefined) : undefined + } + + saveConnection(input: SshConnectionInput): SshConnection { + const list = this.load() + let stored: StoredConnection | undefined + + if (typeof input.id === 'string' && input.id.length > 0) { + stored = list.find((c) => c.id === input.id) + } + + if (stored) { + // Update in place; omitted secrets keep their previously stored value. + const target = stored as unknown as Record + const source = input as unknown as Record + for (const key of PUBLIC_KEYS) { + target[key] = source[key] + } + } else { + stored = { + id: input.id && input.id.length > 0 ? input.id : randomUUID(), + name: input.name, + group: input.group, + host: input.host, + port: input.port, + username: input.username, + auth: input.auth, + askPasswordAtConnect: input.askPasswordAtConnect, + askPassphraseAtConnect: input.askPassphraseAtConnect, + keyPath: input.keyPath, + keepaliveIntervalSec: input.keepaliveIntervalSec, + createdAt: Date.now() + } + list.push(stored) + } + + // Encrypt new secret values; absent secrets leave the previous value intact. + const target = stored as unknown as Record + for (const key of SECRET_KEYS) { + const value = input[key] + if (value === undefined) continue + if (value === '') { + delete target[`${key}${ENC_SUFFIX}`] + } else { + target[`${key}${ENC_SUFFIX}`] = encrypt(value) + } + } + + this.save(list) + return toPublic(stored) + } + + deleteConnection(id: string): void { + const list = this.load() + const next = list.filter((c) => c.id !== id) + if (next.length < list.length) this.save(next) + } + + /** Mark a connection as recently used. */ + touch(id: string, at = Date.now()): void { + const list = this.load() + const conn = list.find((c) => c.id === id) + if (!conn) return + conn.lastConnectedAt = at + this.save(list) + } +} + +/** Default location: /connections.json */ +export function defaultConnectionsPath(userDataPath: string): string { + return `${userDataPath}/connections.json` +} \ No newline at end of file diff --git a/src/main/index.ts b/src/main/index.ts new file mode 100644 index 0000000..89048a5 --- /dev/null +++ b/src/main/index.ts @@ -0,0 +1,58 @@ +import { app, BrowserWindow, shell } from 'electron' +import { join } from 'path' +import { registerIpc } from './ipc' +import { killAllPtys } from './pty' + +function createWindow(): void { + const win = new BrowserWindow({ + width: 1280, + height: 800, + minWidth: 720, + minHeight: 480, + show: false, + backgroundColor: '#1e1e1e', + autoHideMenuBar: true, + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + sandbox: false + } + }) + + win.on('ready-to-show', () => win.show()) + + // Surface renderer console errors in the dev terminal for diagnosis. + win.webContents.on('console-message', (event) => { + if (event.level === 'error') { + console.error(`[renderer] ${event.message}`) + } + }) + + win.webContents.setWindowOpenHandler((details) => { + shell.openExternal(details.url) + return { action: 'deny' } + }) + + const devUrl = process.env['ELECTRON_RENDERER_URL'] + if (devUrl) { + win.loadURL(devUrl) + } else { + win.loadFile(join(__dirname, '../renderer/index.html')) + } +} + +app.whenReady().then(() => { + registerIpc() + createWindow() + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow() + }) +}) + +app.on('before-quit', () => { + killAllPtys() +}) + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') app.quit() +}) \ No newline at end of file diff --git a/src/main/ipc.ts b/src/main/ipc.ts new file mode 100644 index 0000000..e10f3ed --- /dev/null +++ b/src/main/ipc.ts @@ -0,0 +1,80 @@ +import { app, ipcMain } from 'electron' +import fontList from 'font-list' +import { homedir } from 'os' +import { Ipc, type AppInfo, type LayoutMeta, type PtyCreateOptions } from '../shared/ipc' +import type { HostKeyAction, SessionOpenOptions, SshConnection, SshConnectionInput } from '../shared/connections' +import { getLayout, listLayouts, saveLayout, deleteLayout } from './layouts' +import { createPty, killPty, resizePty, writePty, openSession, configureSessionRuntime, getSessionReplay } from './pty' +import { registerSettingsIpc } from './settingsStore' +import { ConnectionsStore, defaultConnectionsPath } from './connectionsStore' +import { KnownHostsStore, defaultKnownHostsPath } from './knownHosts' +import { resolveHostKey } from './ssh' +import { broadcast } from './broadcast' + +export function registerIpc(): void { + const connectionsStore = new ConnectionsStore(defaultConnectionsPath(app.getPath('userData'))) + const knownHostsStore = new KnownHostsStore(defaultKnownHostsPath(app.getPath('userData'))) + + // Runtime deps for the session layer (pty.ts routes into ssh.ts, which stays + // Electron-free). + configureSessionRuntime({ + broadcast: (channel, ...args) => broadcast(channel, ...args), + getConnection: (connectionId) => { + const found = connectionsStore.listConnections().find((c) => c.id === connectionId) + if (!found) throw new Error(`连接书签不存在 (${connectionId})`) + return found + }, + getSecret: (conn, field) => connectionsStore.getSecret(conn, field), + touch: (id) => connectionsStore.touch(id), + knownHosts: { + check: (host, port, key) => knownHostsStore.check(host, port, key), + accept: (host, port, key, fingerprint) => knownHostsStore.accept(host, port, key, fingerprint) + }, + promptHostKey: (prompt) => broadcast(Ipc.HOSTKEY_PROMPT, prompt) + }) + + ipcMain.handle( + Ipc.APP_INFO, + (): AppInfo => ({ platform: process.platform, appVersion: app.getVersion(), homeDir: homedir() }) + ) + + ipcMain.handle(Ipc.PTY_CREATE, (_event, opts?: PtyCreateOptions) => createPty(opts)) + ipcMain.handle(Ipc.SESSION_OPEN, (_event, opts: SessionOpenOptions) => openSession(opts)) + ipcMain.handle(Ipc.SESSION_REPLAY, (_event, id: string) => getSessionReplay(id)) + ipcMain.on(Ipc.PTY_WRITE, (_event, id: string, data: string) => writePty(id, data)) + ipcMain.on(Ipc.PTY_RESIZE, (_event, id: string, cols: number, rows: number) => + resizePty(id, cols, rows) + ) + ipcMain.on(Ipc.PTY_KILL, (_event, id: string) => killPty(id)) + + // ---- ssh connections (bookmarks) ---- + ipcMain.handle(Ipc.CONNECTIONS_LIST, (): SshConnection[] => connectionsStore.listConnections()) + ipcMain.handle(Ipc.CONNECTIONS_SAVE, (_event, input: SshConnectionInput): SshConnection => + connectionsStore.saveConnection(input) + ) + ipcMain.handle(Ipc.CONNECTIONS_DELETE, (_event, id: string) => { + connectionsStore.deleteConnection(id) + }) + + // HOSTKEY_PROMPT is broadcast; the renderer answers here (send, not handle). + ipcMain.on(Ipc.HOSTKEY_RESPOND, (_event, promptId: string, action: HostKeyAction) => { + resolveHostKey(promptId, action) + }) + + ipcMain.handle(Ipc.FONTS_LIST, async () => { + try { + return await fontList.getFonts({ disableQuoting: true }) + } catch { + return [] + } + }) + + ipcMain.handle(Ipc.LAYOUTS_LIST, (): LayoutMeta[] => listLayouts()) + ipcMain.handle(Ipc.LAYOUTS_GET, (_event, id: string) => getLayout(id)) + ipcMain.handle(Ipc.LAYOUTS_SAVE, (_event, meta: LayoutMeta, json: string) => + saveLayout(meta, json) + ) + ipcMain.handle(Ipc.LAYOUTS_DELETE, (_event, id: string) => deleteLayout(id)) + + registerSettingsIpc() +} \ No newline at end of file diff --git a/src/main/knownHosts.ts b/src/main/knownHosts.ts new file mode 100644 index 0000000..1852eb2 --- /dev/null +++ b/src/main/knownHosts.ts @@ -0,0 +1,111 @@ +/** + * Host key pinning store. Persists to /ssh_known_hosts.json. + * No Electron imports here: the file location is injected so the module can be + * reused by the loopback test harness (and any future main-process unit tests). + */ + +import { createHash, randomUUID } from 'crypto' +import { mkdirSync, readFileSync, writeFileSync } from 'fs' +import { dirname } from 'path' + +export interface KnownHostEntry { + /** uuid; addedAt is split out so fingerprint clash updates can be precise */ + id: string + host: string + port: number + /** raw ssh wire-format host key (base64, no padding) */ + keyBase64: string + /** 'SHA256:' + base64(sha256(key)) without padding, OpenSSH style */ + fingerprint: string + addedAt: number +} + +export interface KnownHostsStoreShape { + version: 1 + entries: KnownHostEntry[] +} + +export type HostKeyCheckResult = + | { status: 'match'; entry: KnownHostEntry } + | { status: 'new' } + | { status: 'changed'; stored: KnownHostEntry } + +/** sha256 fingerprint in ssh "SHA256:..." style (no padding) */ +export function fingerprintOf(key: Buffer): string { + return 'SHA256:' + createHash('sha256').update(key).digest('base64').replace(/=+$/, '') +} + +export class KnownHostsStore { + constructor(private readonly filePath: string) {} + + private load(): KnownHostsStoreShape { + try { + const raw: unknown = JSON.parse(readFileSync(this.filePath, 'utf8')) + if (raw !== null && typeof raw === 'object') { + const shape = raw as Partial + if (Array.isArray(shape.entries)) { + return { + version: 1, + entries: shape.entries.filter( + (e): e is KnownHostEntry => + e !== null && + typeof e === 'object' && + typeof (e as KnownHostEntry).host === 'string' && + typeof (e as KnownHostEntry).keyBase64 === 'string' + ) + } + } + } + } catch { + // missing / corrupted file -> start fresh + } + return { version: 1, entries: [] } + } + + private save(shape: KnownHostsStoreShape): void { + mkdirSync(dirname(this.filePath), { recursive: true }) + writeFileSync(this.filePath, JSON.stringify(shape, null, 2), 'utf8') + } + + /** + * Compare the live host key against the stored entry for (host, port). + */ + check(host: string, port: number, key: Buffer): HostKeyCheckResult { + const entries = this.load().entries.filter((e) => e.host === host && e.port === port) + if (entries.length === 0) return { status: 'new' } + + const fingerprint = fingerprintOf(key) + const keyBase64 = key.toString('base64') + const stored = entries[0] + if (stored.keyBase64 === keyBase64 || stored.fingerprint === fingerprint) { + return { status: 'match', entry: stored } + } + return { status: 'changed', stored } + } + + /** Record a new host key (accept of a 'new' or 'changed' prompt). */ + accept(host: string, port: number, key: Buffer, fingerprint: string): KnownHostEntry { + const shape = this.load() + const entry: KnownHostEntry = { + id: randomUUID(), + host, + port, + keyBase64: key.toString('base64'), + fingerprint, + addedAt: Date.now() + } + shape.entries = shape.entries.filter((e) => !(e.host === host && e.port === port)) + shape.entries.push(entry) + this.save(shape) + return entry + } + + list(): KnownHostEntry[] { + return [...this.load().entries] + } +} + +/** Default location: /ssh_known_hosts.json */ +export function defaultKnownHostsPath(userDataPath: string): string { + return `${userDataPath}/ssh_known_hosts.json` +} \ No newline at end of file diff --git a/src/main/layouts.ts b/src/main/layouts.ts new file mode 100644 index 0000000..73570fb --- /dev/null +++ b/src/main/layouts.ts @@ -0,0 +1,61 @@ +import { app } from 'electron' +import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs' +import { join } from 'path' +import type { LayoutMeta } from '../shared/ipc' + +const layoutsDir = (): string => { + const dir = join(app.getPath('userData'), 'layouts') + mkdirSync(dir, { recursive: true }) + return dir +} + +function layoutPath(id: string): string { + return join(layoutsDir(), `${id}.json`) +} + +interface LayoutFile { + id: string + name: string + createdAt: number + json: string +} + +export function listLayouts(): LayoutMeta[] { + const dir = layoutsDir() + const metas: LayoutMeta[] = [] + for (const entry of readdirSync(dir)) { + if (!entry.endsWith('.json')) continue + try { + const file = JSON.parse(readFileSync(join(dir, entry), 'utf8')) as Partial + const id = typeof file.id === 'string' ? file.id : entry.slice(0, -'.json'.length) + const name = typeof file.name === 'string' ? file.name : id + const createdAt = typeof file.createdAt === 'number' ? file.createdAt : 0 + metas.push({ id, name, createdAt }) + } catch { + // skip corrupted file + } + } + return metas.sort((a, b) => b.createdAt - a.createdAt) +} + +export function getLayout(id: string): string | null { + try { + const file = JSON.parse(readFileSync(layoutPath(id), 'utf8')) as Partial + return typeof file.json === 'string' ? file.json : null + } catch { + return null + } +} + +export function saveLayout(meta: LayoutMeta, json: string): void { + const file: LayoutFile = { id: meta.id, name: meta.name, createdAt: meta.createdAt, json } + writeFileSync(layoutPath(meta.id), JSON.stringify(file, null, 2), 'utf8') +} + +export function deleteLayout(id: string): void { + try { + rmSync(layoutPath(id)) + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err + } +} \ No newline at end of file diff --git a/src/main/pty.ts b/src/main/pty.ts new file mode 100644 index 0000000..99a6803 --- /dev/null +++ b/src/main/pty.ts @@ -0,0 +1,239 @@ +import { spawn, type IPty } from '@lydell/node-pty' +import { randomUUID } from 'crypto' +import { homedir } from 'os' +import { Ipc, type PtyCreateOptions, type PtyCreateResult } from '../shared/ipc' +import type { SessionOpenOptions, HostKeyPromptEvent, SshConnection } from '../shared/connections' +import { broadcast } from './broadcast' +import { connectSsh, type SshSessionHandle } from './ssh' +import type { HostKeyCheckResult } from './knownHosts' + +/** + * Session routing table. A session is either a local pty or an established ssh + * shell; the generic PTY_* (data plane) channels address both. PTY_DATA / + * PTY_EXIT broadcasts are identical for both kinds. + */ +type Session = { kind: 'local'; pty: IPty } | { kind: 'ssh'; ssh: SshSessionHandle } + +const sessions = new Map() + +/** + * Per-session replay of recent output (capped). Late subscribers — a terminal + * view mounting after the shell already printed its banner, or a panel rebound + * by template apply — read this instead of losing the head of the stream. + */ +const REPLAY_CAP = 64 * 1024 +const replayBuffers = new Map() + +function appendReplay(id: string, data: string): void { + const prev = replayBuffers.get(id) ?? '' + const next = prev.length + data.length > REPLAY_CAP + ? (prev + data).slice(prev.length + data.length - REPLAY_CAP) + : prev + data + replayBuffers.set(id, next) +} + +/** Recent output of a session ('' when unknown). */ +export function getSessionReplay(id: string): string { + return replayBuffers.get(id) ?? '' +} + +/** + * Dependencies injected once by ipc.ts (configureSessionRuntime) so pty.ts + * stays free of store / known-hosts imports and the ssh service can remain + * decoupled from Electron. + */ +export interface SessionRuntimeDeps { + /** resolve a bookmark by id (throws a Chinese message when missing) */ + getConnection(connectionId: string): SshConnection + /** decrypt a stored secret for a connection */ + getSecret(conn: SshConnection, field: 'password' | 'keyContent' | 'passphrase'): string | undefined + /** mark a bookmark as recently connected */ + touch(connectionId: string): void + /** host key pinning: check against the store, record an accepted key */ + knownHosts: { + check(host: string, port: number, key: Buffer): HostKeyCheckResult + accept(host: string, port: number, key: Buffer, fingerprint: string): void + } + /** ask the renderer to decide an unknown / changed host key */ + promptHostKey(prompt: HostKeyPromptEvent): void + broadcast(channel: string, ...args: unknown[]): void +} + +let runtimeDeps: SessionRuntimeDeps | undefined + +/** Wire the real stores + renderer prompt. Called once during app startup. */ +export function configureSessionRuntime(deps: SessionRuntimeDeps): void { + runtimeDeps = deps +} + +function defaultShell(): string { + switch (process.platform) { + case 'win32': + return 'powershell.exe' + case 'darwin': + return process.env.SHELL || '/bin/zsh' + default: + return process.env.SHELL || '/bin/bash' + } +} + +export function createPty(opts: PtyCreateOptions = {}): PtyCreateResult { + const id = randomUUID() + const shell = opts.shell ?? defaultShell() + const cwd = opts.cwd ?? homedir() + const env = { ...process.env, ...opts.env } as Record + + const pty = spawn(shell, [], { name: 'xterm-256color', cols: 80, rows: 24, cwd, env }) + sessions.set(id, { kind: 'local', pty }) + replayBuffers.set(id, '') + + pty.onData((data) => { + try { + appendReplay(id, data) + broadcast(Ipc.PTY_DATA, { id, data }) + } catch { + // never crash the event loop + } + }) + + pty.onExit(({ exitCode }) => { + try { + sessions.delete(id) + broadcast(Ipc.PTY_EXIT, { id, exitCode }) + } catch { + // never crash the event loop + } + }) + + return { id, shell, cwd } +} + +/** + * Open a session. `local` reuses createPty; `ssh` goes through the ssh service + * and resolves only once the shell stream is ready to stream data. + */ +export async function openSession(opts: SessionOpenOptions): Promise<{ id: string }> { + if (opts.kind === 'local') { + return { id: createPty().id } + } + + const deps = runtimeDeps + if (!deps) { + throw new Error('SSH 会话服务尚未初始化') + } + if (!opts.connectionId) { + throw new Error('SSH 会话缺少 connectionId') + } + + const conn = deps.getConnection(opts.connectionId) + const handle = await connectSsh(conn, opts.secretOverride, { + connections: { + getSecret: (c, field) => deps.getSecret(c, field), + touch: (id: string) => { + // Successful connect: record lastConnectedAt on the bookmark. + try { + deps.touch(id) + } catch { + // store write failure must not break the session + } + } + }, + knownHosts: deps.knownHosts, + broadcast: deps.broadcast, + promptHostKey: deps.promptHostKey + }) + sessions.set(handle.id, { kind: 'ssh', ssh: handle }) + + handle.stream.on('data', (data: Buffer) => { + try { + const text = data.toString('utf8') + appendReplay(handle.id, text) + deps.broadcast(Ipc.PTY_DATA, { id: handle.id, data: text }) + } catch { + // never crash the event loop + } + }) + + handle.stream.on('close', () => { + try { + sessions.delete(handle.id) + deps.broadcast(Ipc.PTY_EXIT, { id: handle.id, exitCode: 0 }) + } catch { + // never crash the event loop + } + }) + + return { id: handle.id } +} + +export function writePty(id: string, data: string): void { + const session = sessions.get(id) + if (!session) return + try { + if (session.kind === 'local') session.pty.write(data) + else session.ssh.stream.write(data) + } catch { + // session may already be dead + } +} + +export function resizePty(id: string, cols: number, rows: number): void { + const session = sessions.get(id) + if (!session) return + try { + if (session.kind === 'local') session.pty.resize(cols, rows) + // ssh2 Channel.setWindow(rows, cols, height, width) + else session.ssh.stream.setWindow(rows, cols, 0, 0) + } catch { + // session may already be dead + } +} + +export function killPty(id: string): void { + replayBuffers.delete(id) + const session = sessions.get(id) + if (!session) return + if (session.kind === 'ssh') { + try { + session.ssh.stream.close() + } catch { + // best effort + } + try { + session.ssh.client.end() + } catch { + // best effort + } + } else { + try { + session.pty.kill() + } catch { + // best effort + } + } + sessions.delete(id) +} + +export function killAllPtys(): void { + for (const session of sessions.values()) { + if (session.kind === 'ssh') { + try { + session.ssh.stream.close() + } catch { + // best effort + } + try { + session.ssh.client.end() + } catch { + // best effort + } + } else { + try { + session.pty.kill() + } catch { + // best effort + } + } + } + sessions.clear() +} \ No newline at end of file diff --git a/src/main/settingsStore.ts b/src/main/settingsStore.ts new file mode 100644 index 0000000..afa05e6 --- /dev/null +++ b/src/main/settingsStore.ts @@ -0,0 +1,109 @@ +import { app, ipcMain } from 'electron' +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs' +import { join } from 'path' +import { Ipc } from '../shared/ipc' +import { + DEFAULT_HIGHLIGHT_RULES, + DEFAULT_SETTINGS, + type AppSettings, + type HighlightRule, + type TerminalSettings +} from '../shared/settings' +import type { TerminalTheme } from '../shared/theme' +import { broadcast } from './broadcast' + +const settingsPath = (): string => join(app.getPath('userData'), 'settings.json') + +const TERMINAL_KEYS = new Set(Object.keys(DEFAULT_SETTINGS.terminal) as (keyof TerminalSettings)[]) + +/** Light structural check for a persisted highlight rule. */ +function isHighlightRule(value: unknown): value is HighlightRule { + if (value === null || typeof value !== 'object') return false + const rule = value as Record + return ( + typeof rule.id === 'string' && + typeof rule.pattern === 'string' && + typeof rule.enabled === 'boolean' && + typeof rule.priority === 'number' && + rule.color !== null && + typeof rule.color === 'object' && + typeof (rule.color as { fg?: unknown }).fg === 'string' + ) +} + +function sanitizeRules(value: unknown): HighlightRule[] { + if (!Array.isArray(value)) return DEFAULT_HIGHLIGHT_RULES + const rules = value.filter(isHighlightRule) + return rules.length > 0 ? rules : DEFAULT_HIGHLIGHT_RULES +} + +function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } { + const errors: string[] = [] + let terminal: TerminalSettings = { ...DEFAULT_SETTINGS.terminal } + let customThemes: unknown = DEFAULT_SETTINGS.customThemes + let highlightRules: unknown = DEFAULT_HIGHLIGHT_RULES + + if (raw !== null && typeof raw === 'object') { + const packageSettings = raw as { terminal?: unknown; customThemes?: unknown; highlightRules?: unknown } + if (packageSettings.terminal !== null && typeof packageSettings.terminal === 'object') { + const candidate = packageSettings.terminal as Record + const merged: Record = { ...terminal } + for (const key of TERMINAL_KEYS) { + const keyType = typeof DEFAULT_SETTINGS.terminal[key] + if (candidate[key] !== undefined && typeof candidate[key] === keyType) { + merged[key] = candidate[key] + } else if (candidate[key] !== undefined) { + errors.push(`terminal.${key}`) + } + } + terminal = merged as unknown as TerminalSettings + } + if (Array.isArray(packageSettings.customThemes)) { + customThemes = packageSettings.customThemes + } + if (packageSettings.highlightRules !== undefined) { + highlightRules = packageSettings.highlightRules + } + } + + const themes: TerminalTheme[] = Array.isArray(customThemes) ? (customThemes as TerminalTheme[]) : [] + + return { settings: { terminal, customThemes: themes, highlightRules: sanitizeRules(highlightRules) }, errors } +} + +export function loadSettings(): AppSettings { + try { + const raw: unknown = JSON.parse(readFileSync(settingsPath(), 'utf8')) + const { settings } = deepMerge(raw) + return settings + } catch { + return { + terminal: { ...DEFAULT_SETTINGS.terminal }, + customThemes: [...DEFAULT_SETTINGS.customThemes], + highlightRules: DEFAULT_HIGHLIGHT_RULES.map((rule) => ({ ...rule })) + } + } +} + +export function saveSettings(next: AppSettings): AppSettings { + const merged = deepMerge({ + terminal: next.terminal, + customThemes: next.customThemes, + highlightRules: next.highlightRules + }).settings + const pretty = JSON.stringify(merged, null, 2) + + mkdirSync(app.getPath('userData'), { recursive: true }) + const path = settingsPath() + const tmp = `${path}.tmp` + writeFileSync(tmp, pretty, 'utf8') + renameSync(tmp, path) + + broadcast(Ipc.SETTINGS_CHANGED, merged) + return merged +} + +export function registerSettingsIpc(): void { + ipcMain.handle(Ipc.SETTINGS_GET, () => loadSettings()) + ipcMain.handle(Ipc.SETTINGS_SET, (_event, next: AppSettings) => saveSettings(next)) +} \ No newline at end of file diff --git a/src/main/ssh.ts b/src/main/ssh.ts new file mode 100644 index 0000000..b5bc92b --- /dev/null +++ b/src/main/ssh.ts @@ -0,0 +1,303 @@ +/** + * SSH session service (M2). + * + * Deliberately decoupled from Electron: broadcast and host-key store are + * injected (`SshServiceDeps`), so the connect / verify / shell flow can be + * exercised by the pure-node loopback harness (tests/ssh-loopback.mjs) with a + * plain ssh2 client and no Chromium runtime. + * + * Host-key verification pauses the handshake inside the `hostVerifier` + * callback: ssh2's kex suspends until `verify()` is called, which is exactly + * what we need for store-check + renderer prompt + accept-record. The 15s + * connect timeout is paused while a host-key prompt is outstanding so the + * prompt's own 30s budget governs that wait. + */ + +import type { SshConnection, SshSecretOverride } from '../shared/connections' +import { Ipc } from '../shared/ipc' +import { randomUUID } from 'crypto' +import { readFileSync } from 'fs' +import { fingerprintOf, type HostKeyCheckResult } from './knownHosts' +import type { ConnectionsStore } from './connectionsStore' +import type { Client, ClientChannel, ConnectConfig } from 'ssh2' + +export interface SshSessionHandle { + id: string + /** established ssh connection; powers the session routing in pty.ts */ + client: Client + /** open interactive shell channel (ClientChannel, a Duplex) */ + stream: ClientChannel +} + +export interface SshServiceDeps { + /** resolves saved secrets (service never writes them) */ + connections: Pick + /** host key pinning store access */ + knownHosts: { + check(host: string, port: number, key: Buffer): HostKeyCheckResult + accept(host: string, port: number, key: Buffer, fingerprint: string): void + } + /** electron-free broadcast; matches main/broadcast.ts */ + broadcast(channel: string, ...args: unknown[]): void + /** + * Ask the renderer to decide on an unknown / changed host key. The prompt + * resolves via the module-level `resolveHostKey(promptId, action)`. + */ + promptHostKey(prompt: { + promptId: string + host: string + port: number + fingerprint: string + reason: 'new' | 'changed' + }): void + timeoutMs?: { prompt: number; connect: number } +} + +const DEFAULT_TIMEOUTS = { prompt: 30_000, connect: 15_000 } + +/** + * Open an ssh session (connect + auth + open shell) for `conn`. + * + * Resolves with `{ id, client, stream }` once the shell stream is ready to + * stream data. Rejects with a human-readable Chinese Error when anything goes + * wrong before the shell is ready. + */ +export async function connectSsh( + conn: SshConnection, + secretOverride: SshSecretOverride | undefined, + deps: SshServiceDeps +): Promise { + const mod = await import('ssh2') + const Client = mod.Client + const timeouts = { ...DEFAULT_TIMEOUTS, ...deps.timeoutMs } + + const sessionId = randomUUID() + const handshake = new Client() + + let settled = false + let connectTimer: NodeJS.Timeout | undefined + let verifierErr: string | undefined + let resolvePromise!: (handle: SshSessionHandle) => void + let rejectPromise!: (err: Error) => void + + const armConnectTimer = (): void => { + if (connectTimer) clearTimeout(connectTimer) + connectTimer = setTimeout(() => { + fail(new Error(`连接超时 (${conn.host}:${conn.port})`)) + }, timeouts.connect) + } + + const fail = (err: Error): void => { + if (settled) return + settled = true + if (connectTimer) clearTimeout(connectTimer) + rejectPromise(err) + try { + handshake.destroy() + } catch { + // best effort + } + } + + // --- host key verification ------------------------------------------------ + // Called by ssh2 during kex; return undefined => async verdict via verify(). + const hostVerifier = (hostKey: Buffer, verify: (permitted: boolean) => void): void => { + let fingerprint: string + let status: 'new' | 'changed' | 'match' + try { + fingerprint = fingerprintOf(hostKey) + status = deps.knownHosts.check(conn.host, conn.port, hostKey).status + } catch { + fingerprint = fingerprintOf(hostKey) + status = 'new' + } + + if (status === 'match') { + verify(true) + return + } + + // Pause the connect timeout; the user's decision owns this wait. + if (connectTimer) clearTimeout(connectTimer) + + promptUser(conn.host, conn.port, fingerprint, status, timeouts.prompt, deps) + .then((accepted) => { + if (accepted) { + try { + deps.knownHosts.accept(conn.host, conn.port, hostKey, fingerprint) + } catch (err) { + verifierErr = `保存主机指纹失败: ${(err as Error).message}` + verify(false) + return + } + verify(true) + } else { + verifierErr = '用户拒绝了主机指纹' + verify(false) + } + }) + .finally(() => { + // Resume the overall connect timer once the decision lands. + if (!settled) armConnectTimer() + }) + } + + return new Promise((resolve, reject) => { + resolvePromise = resolve + rejectPromise = reject + + // failure paths before resolution: `fail` and the connect timer + armConnectTimer() + + handshake.on('error', (err: Error) => { + const message = verifierErr ?? err.message + if (!settled) { + fail(new Error(`连接失败 ${conn.host}:${conn.port}: ${message}`)) + return + } + // Session already established: surface as a session exit and clean up. + try { + deps.broadcast(Ipc.PTY_EXIT, { id: sessionId, exitCode: 1 }) + } catch { + // never crash the event loop + } + try { + handshake.destroy() + } catch { + // best effort + } + }) + + handshake.on('ready', () => { + handshake.shell( + { term: 'xterm-256color', cols: 80, rows: 24 }, + (err: Error | undefined, shell: ClientChannel) => { + if (err) { + fail(new Error(`无法打开 SSH shell (${conn.host}:${conn.port}): ${err.message}`)) + return + } + if (settled) { + try { + shell.end() + } catch { + // best effort + } + return + } + settled = true + if (connectTimer) clearTimeout(connectTimer) + // Successful connect: record lastConnectedAt on the bookmark. + try { + deps.connections.touch(conn.id) + } catch { + // store write failure must not break the session + } + resolvePromise({ id: sessionId, client: handshake, stream: shell }) + } + ) + }) + + const cfg: ConnectConfig = { + host: conn.host, + port: conn.port, + username: conn.username, + // SshConnection keeps it in seconds; ssh2 expects milliseconds. + keepaliveInterval: Math.round(conn.keepaliveIntervalSec * 1000), + hostVerifier + } + + // Password auth + const password = secretOverride?.password ?? deps.connections.getSecret(conn, 'password') + if (password !== undefined) cfg.password = password + + // Private key auth (keyPath takes precedence over stored keyContent) + if (conn.auth === 'privateKey') { + const keyContent = deps.connections.getSecret(conn, 'keyContent') + const privateKey: string | undefined = + conn.keyPath !== undefined && conn.keyPath.length > 0 + ? readKeyFile(conn.keyPath) + : keyContent !== undefined && keyContent.length > 0 + ? keyContent + : undefined + if (privateKey !== undefined) { + cfg.privateKey = privateKey + const passphrase = secretOverride?.passphrase ?? deps.connections.getSecret(conn, 'passphrase') + if (passphrase !== undefined) cfg.passphrase = passphrase + } + } + + // Agent auth (defaults supplied only when an agent socket is reachable) + if (conn.auth === 'agent') { + const agent = process.env.SSH_AUTH_SOCK ?? (process.platform === 'win32' ? 'pageant' : undefined) + if (agent !== undefined) cfg.agent = agent + } + + try { + handshake.connect(cfg) + } catch (err) { + // e.g. unparseable privateKey is thrown synchronously by ssh2 + fail(new Error(`SSH 连接初始化失败 (${conn.host}:${conn.port}): ${(err as Error).message}`)) + } + }) +} + +// --- host-key prompt routing ------------------------------------------------- + +interface PendingPrompt { + resolve: (accepted: boolean) => void + timer: NodeJS.Timeout +} + +const pendingPrompts = new Map() + +/** + * Ask the renderer to approve / reject a host key. Resolves `true`/`false`. + * Times out (default 30s) -> treated as reject. + */ +function promptUser( + host: string, + port: number, + fingerprint: string, + reason: 'new' | 'changed', + timeoutMs: number, + deps: SshServiceDeps +): Promise { + const promptId = randomUUID() + return new Promise((resolve) => { + const timer = setTimeout(() => { + pendingPrompts.delete(promptId) + resolve(false) + }, timeoutMs) + + pendingPrompts.set(promptId, { resolve, timer }) + try { + deps.promptHostKey({ promptId, host, port, fingerprint, reason }) + } catch { + // broadcast failure must not hang the attempt forever + pendingPrompts.delete(promptId) + clearTimeout(timer) + resolve(false) + } + }) +} + +/** + * Route a renderer host-key decision to the matching pending prompt. + * Wired by the main process: ipcMain.on(Ipc.HOSTKEY_RESPOND, (…, promptId, action)). + */ +export function resolveHostKey(promptId: string, action: 'accept' | 'reject'): void { + const pending = pendingPrompts.get(promptId) + if (!pending) return + clearTimeout(pending.timer) + pendingPrompts.delete(promptId) + pending.resolve(action === 'accept') +} + +/** Read a private key file; surfaces a descriptive error on failure. */ +function readKeyFile(keyPath: string): string { + try { + return readFileSync(keyPath, 'utf8') + } catch (err) { + throw new Error(`无法读取私钥文件 ${keyPath}: ${(err as Error).message}`) + } +} \ No newline at end of file diff --git a/src/main/ssh2.d.ts b/src/main/ssh2.d.ts new file mode 100644 index 0000000..692d0bc --- /dev/null +++ b/src/main/ssh2.d.ts @@ -0,0 +1,100 @@ +/** + * Minimal ambient typings for the `ssh2` package (v1.17.0). + * + * ssh2 ships no type declarations and `@types/ssh2` is not installed; npm + * install is out of scope here, so this file declares exactly the surface this + * project consumes (verified against node_modules/ssh2/lib/client.js, + * Channel.js, kex.js, agent.js, server.js). Lives under src/main/, which is + * owned by this milestone. + */ + +declare module 'ssh2' { + import { Duplex } from 'stream' + import { Socket } from 'net' + + /** Duplex wrapper around an SSH channel (shell/exec). */ + export interface ClientChannel extends Duplex { + setWindow(rows: number, cols: number, height: number, width: number): void + signal(signalName: string): void + exit(statusOrSignal: number | string, coreDumped?: boolean, msg?: string): void + close(): void + } + + export interface PseudoTtyOptions { + rows?: number + cols?: number + width?: number + height?: number + term?: string + } + + export interface ShellOptions { + rows?: number + cols?: number + width?: number + height?: number + term?: string + /** environment (name => value) requested over the session */ + env?: Record + x11?: boolean | number | Record + /** forward a local ssh-agent to the remote session */ + agentForward?: boolean + } + + export interface ConnectConfig { + host?: string + port?: number + username: string + password?: string + privateKey?: Buffer | string + passphrase?: string + /** ssh-agent socket path or a BaseAgent instance */ + agent?: string + /** forward the local agent to the remote (requires `agent`) */ + agentForward?: boolean + /** keepalive interval in milliseconds (0 disables) */ + keepaliveInterval?: number + keepaliveCountMax?: number + /** ms to wait for handshake before erroring (0 disables) */ + readyTimeout?: number + /** socket connect timeout in ms (0 disables) */ + timeout?: number + hostHash?: string + /** + * Optional host key verification. When it returns a boolean, ssh2 uses it + * synchronously; a promise-using implementer must instead call the `verify` + * callback (returning `undefined`), which defers the handshake. + */ + hostVerifier?: (key: Buffer, verify: (permitted: boolean) => void) => boolean | void + debug?: (...args: unknown[]) => void + algorithms?: Record + ident?: string | Buffer + sock?: Socket + strictVendor?: boolean + localAddress?: string + localHostname?: string + localUsername?: string + /** try keyboard-interactive auth */ + tryKeyboard?: boolean + authHandler?: unknown + forceIPv4?: boolean + forceIPv6?: boolean + } + + export class Client { + connect(cfg: ConnectConfig): this + end(): this + destroy(): this + shell(cb: (err: Error | undefined, stream: ClientChannel) => void): this + shell( + opts: PseudoTtyOptions | ShellOptions | false, + cb: (err: Error | undefined, stream: ClientChannel) => void + ): this + exec(cmd: string, cb: (err: Error | undefined, stream: ClientChannel) => void): this + on(event: 'ready' | 'close', listener: () => void): this + on(event: 'error', listener: (err: Error) => void): this + on(event: string, listener: (...args: never[]) => void): this + } + + export function createAgent(path: string): unknown +} \ No newline at end of file diff --git a/src/preload/index.ts b/src/preload/index.ts new file mode 100644 index 0000000..b0814f2 --- /dev/null +++ b/src/preload/index.ts @@ -0,0 +1,61 @@ +import { contextBridge, ipcRenderer } from 'electron' +import { Ipc } from '../shared/ipc' +import type { AppSettings } from '../shared/settings' +import type { AppApi } from '../shared/api' +import type { LayoutMeta, PtyCreateOptions, PtyDataEvent, PtyExitEvent } from '../shared/ipc' +import type { + HostKeyAction, + HostKeyPromptEvent, + SessionOpenOptions, + SshConnectionInput +} from '../shared/connections' + +const api: AppApi = { + appInfo: () => ipcRenderer.invoke(Ipc.APP_INFO), + + createPty: (opts?: PtyCreateOptions) => ipcRenderer.invoke(Ipc.PTY_CREATE, opts), + openSession: (opts: SessionOpenOptions) => ipcRenderer.invoke(Ipc.SESSION_OPEN, opts), + getSessionReplay: (id: string) => ipcRenderer.invoke(Ipc.SESSION_REPLAY, id), + writePty: (id: string, data: string) => ipcRenderer.send(Ipc.PTY_WRITE, id, data), + resizePty: (id: string, cols: number, rows: number) => + ipcRenderer.send(Ipc.PTY_RESIZE, id, cols, rows), + killPty: (id: string) => ipcRenderer.send(Ipc.PTY_KILL, id), + onPtyData: (cb: (e: PtyDataEvent) => void) => { + const listener = (_: unknown, e: PtyDataEvent): void => cb(e) + ipcRenderer.on(Ipc.PTY_DATA, listener) + return () => ipcRenderer.removeListener(Ipc.PTY_DATA, listener) + }, + onPtyExit: (cb: (e: PtyExitEvent) => void) => { + const listener = (_: unknown, e: PtyExitEvent): void => cb(e) + ipcRenderer.on(Ipc.PTY_EXIT, listener) + return () => ipcRenderer.removeListener(Ipc.PTY_EXIT, listener) + }, + + listConnections: () => ipcRenderer.invoke(Ipc.CONNECTIONS_LIST), + saveConnection: (input: SshConnectionInput) => ipcRenderer.invoke(Ipc.CONNECTIONS_SAVE, input), + deleteConnection: (id: string) => ipcRenderer.invoke(Ipc.CONNECTIONS_DELETE, id), + onHostKeyPrompt: (cb: (e: HostKeyPromptEvent) => void) => { + const listener = (_: unknown, e: HostKeyPromptEvent): void => cb(e) + ipcRenderer.on(Ipc.HOSTKEY_PROMPT, listener) + return () => ipcRenderer.removeListener(Ipc.HOSTKEY_PROMPT, listener) + }, + respondHostKey: (promptId: string, action: HostKeyAction) => + ipcRenderer.send(Ipc.HOSTKEY_RESPOND, promptId, action), + + getSettings: () => ipcRenderer.invoke(Ipc.SETTINGS_GET), + saveSettings: (next: AppSettings) => ipcRenderer.invoke(Ipc.SETTINGS_SET, next), + onSettingsChanged: (cb: (s: AppSettings) => void) => { + const listener = (_: unknown, s: AppSettings): void => cb(s) + ipcRenderer.on(Ipc.SETTINGS_CHANGED, listener) + return () => ipcRenderer.removeListener(Ipc.SETTINGS_CHANGED, listener) + }, + + listFonts: () => ipcRenderer.invoke(Ipc.FONTS_LIST), + + listLayouts: () => ipcRenderer.invoke(Ipc.LAYOUTS_LIST), + getLayout: (id: string) => ipcRenderer.invoke(Ipc.LAYOUTS_GET, id), + saveLayout: (meta: LayoutMeta, json: string) => ipcRenderer.invoke(Ipc.LAYOUTS_SAVE, meta, json), + deleteLayout: (id: string) => ipcRenderer.invoke(Ipc.LAYOUTS_DELETE, id) +} + +contextBridge.exposeInMainWorld('api', api) diff --git a/src/renderer/index.html b/src/renderer/index.html new file mode 100644 index 0000000..a012544 --- /dev/null +++ b/src/renderer/index.html @@ -0,0 +1,15 @@ + + + + + OpenTerminal + + + +
+ + + diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx new file mode 100644 index 0000000..cc691a3 --- /dev/null +++ b/src/renderer/src/App.tsx @@ -0,0 +1,20 @@ +import { useEffect, useState } from 'react' +import Workspace from '@renderer/workspace/Workspace' +import { SettingsDialog } from '@renderer/settings/SettingsDialog' +import { useSettingsStore } from '@renderer/settings/store' + +export default function App(): React.JSX.Element { + const hydrate = useSettingsStore((s) => s.hydrate) + const [settingsOpen, setSettingsOpen] = useState(false) + + useEffect(() => { + void hydrate() + }, [hydrate]) + + return ( +
+ setSettingsOpen(true)} /> + setSettingsOpen(false)} /> +
+ ) +} diff --git a/src/renderer/src/ErrorBoundary.tsx b/src/renderer/src/ErrorBoundary.tsx new file mode 100644 index 0000000..ee77766 --- /dev/null +++ b/src/renderer/src/ErrorBoundary.tsx @@ -0,0 +1,66 @@ +import { Component } from 'react' +import type { ErrorInfo, ReactNode } from 'react' + +interface ErrorBoundaryProps { + children: ReactNode +} + +interface ErrorBoundaryState { + error: Error | null +} + +/** + * Last-resort boundary: a render/effect crash must never take the whole app + * down to a black window. Shows the error and a reload escape instead. + */ +export class ErrorBoundary extends Component { + state: ErrorBoundaryState = { error: null } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error } + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + console.error('[ErrorBoundary]', error, info.componentStack) + } + + render(): ReactNode { + if (this.state.error) { + return ( +
+
界面出现异常
+
+            {this.state.error.message}
+          
+ +
+ ) + } + return this.props.children + } +} diff --git a/src/renderer/src/connections/Common.tsx b/src/renderer/src/connections/Common.tsx new file mode 100644 index 0000000..177cf23 --- /dev/null +++ b/src/renderer/src/connections/Common.tsx @@ -0,0 +1,84 @@ +import type { SshAuthMethod, SshConnection, SshConnectionInput, SshSavedAuthFlags } from '@shared/connections' + +/** + * Shared helpers for the connection manager UI (connections/**, renderer). + * Nothing here ever holds secret plaintext; secrets live only in the dialog's + * controlled Form state and are stripped before anything is passed around. + */ + +/** Numeric parse for InputNumber stores: undefined/'' → null, else Number(). */ +export function num(v: unknown): number | null { + if (v == null || v === '') return null + return Number(v) +} + +export type SecretKind = 'password' | 'keyContent' | 'passphrase' + +export function hasSavedKind(kind: SecretKind, savedAuth: SshSavedAuthFlags | undefined): boolean { + if (!savedAuth) return false + if (kind === 'password') return savedAuth.hasPassword + if (kind === 'keyContent') return savedAuth.hasKeyContent + return savedAuth.hasPassphrase +} + +export function authLabel(kind: SshAuthMethod): string { + return kind === 'privateKey' ? '私钥' : kind === 'agent' ? 'Agent' : '密码' +} + +export function AuthTags({ auth }: { auth: SshAuthMethod }): React.JSX.Element { + return {authLabel(auth)} +} + +export interface BuildCtx { + /** present when editing; its id is reused for the update */ + conn?: SshConnection | null +} + +/** + * Build the renderer→main save payload from raw Form values. + * Secret fields are included ONLY when a non-empty value was typed; an empty + * value means "keep the previously stored one" and the key is simply omitted, + * so no plaintext secret is ever passed through this path on edit. + */ +export function buildInput(values: Record, ctx: BuildCtx): SshConnectionInput { + const auth = values.auth as SshAuthMethod + const askPasswordAtConnect = Boolean(values.askPasswordAtConnect) + const askPassphraseAtConnect = Boolean(values.askPassphraseAtConnect) + + const rawGroup = values.group + const group = Array.isArray(rawGroup) + ? rawGroup.find((g): g is string => typeof g === 'string' && g.trim() !== '') + : typeof rawGroup === 'string' && rawGroup.trim() !== '' + ? rawGroup.trim() + : undefined + + const input: SshConnectionInput = { + id: ctx.conn?.id, + name: String(values.name ?? '').trim(), + host: String(values.host ?? '').trim(), + port: num(values.port) ?? 22, + username: String(values.username ?? '').trim(), + auth, + askPasswordAtConnect, + askPassphraseAtConnect, + keepaliveIntervalSec: num(values.keepaliveIntervalSec) ?? 30, + group + } + + const secret = (key: SecretKind): string | undefined => { + const raw = values[key] + return typeof raw === 'string' && raw.trim() !== '' ? raw : undefined + } + + if (auth === 'password') { + if (!askPasswordAtConnect) input.password = secret('password') + } else if (auth === 'privateKey') { + if (typeof values.keyPath === 'string' && values.keyPath.trim() !== '') { + input.keyPath = values.keyPath.trim() + } + input.keyContent = secret('keyContent') + if (!askPassphraseAtConnect) input.passphrase = secret('passphrase') + } + // agent: no secret fields in the input + return input +} \ No newline at end of file diff --git a/src/renderer/src/connections/ConnectionEditDialog.tsx b/src/renderer/src/connections/ConnectionEditDialog.tsx new file mode 100644 index 0000000..05b6426 --- /dev/null +++ b/src/renderer/src/connections/ConnectionEditDialog.tsx @@ -0,0 +1,211 @@ +import { useEffect, useMemo, useState } from 'react' +import { Alert, Button, Form, Input, InputNumber, Modal, Radio, Select } from 'antd' +import type { SshAuthMethod, SshConnection, SshConnectionInput } from '@shared/connections' +import { AuthFieldsRenderer } from './Fields' +import { buildInput, num, hasSavedKind, type SecretKind } from './Common' + +export interface ConnectionEditDialogProps { + open: boolean + /** connection being edited; null = create new */ + editing: SshConnection | null + onClose: () => void + onSaved: () => void +} + +/** + * Modal for creating / editing an SSH bookmark. + * + * Secret handling: the form holds only what the user typed. On edit, secret + * inputs start empty and stay empty to mean "keep the stored value" — those + * fields are simply omitted from the save payload (see buildInput). No secret + * plaintext is ever written to the console or logs. + */ +export function ConnectionEditDialog({ + open, + editing, + onClose, + onSaved +}: ConnectionEditDialogProps): React.JSX.Element { + const [form] = Form.useForm() + const [saving, setSaving] = useState(false) + const [errorMsg, setErrorMsg] = useState(null) + const [auth, setAuth] = useState('password') + + /** Editing target identity; re-initializes the form when it changes. */ + const editKey = editing?.id ?? 'new' + + // Unique group names from all saved connections, for the group Select. + const [groupOptions, setGroupOptions] = useState([]) + useEffect(() => { + if (!open) return + window.api + .listConnections() + .then((conns: SshConnection[]) => { + const seen = new Set() + for (const c of conns) { + if (c.group) seen.add(c.group) + } + setGroupOptions([...seen].sort()) + }) + .catch(() => { + // sidebar keeps its own list; a transient failure here just leaves the + // group field as a plain text input + setGroupOptions([]) + }) + }, [open]) + + // Populate / clear the form for each open + target change. + useEffect(() => { + if (!open) return + setErrorMsg(null) + const base: SshAuthMethod = editing?.auth ?? 'password' + setAuth(base) + form.setFieldsValue({ + name: editing?.name, + group: editing?.group, + host: editing?.host, + port: editing?.port, + username: editing?.username, + auth: base, + askPasswordAtConnect: editing?.askPasswordAtConnect, + askPassphraseAtConnect: editing?.askPassphraseAtConnect, + keyPath: editing?.keyPath, + keepaliveIntervalSec: editing?.keepaliveIntervalSec + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, editKey, form]) + + const handleSave = async (): Promise => { + const values = await form.validateFields() + const input = buildInput(values as unknown as Record, { + conn: editing + }) + setSaving(true) + setErrorMsg(null) + try { + await window.api.saveConnection(input) + onSaved() + onClose() + } catch (err) { + setErrorMsg(err instanceof Error ? err.message : '保存失败,请检查输入后重试') + } finally { + setSaving(false) + } + } + + const handleCancel = (): void => { + if (!saving) onClose() + } + + const savedNotes = useMemo(() => { + if (!editing) return [] + const kinds: Array<{ kind: SecretKind; label: string }> = [ + { kind: 'password', label: '密码' }, + { kind: 'keyContent', label: '私钥' }, + { kind: 'passphrase', label: '口令' } + ] + const active = auth === 'password' ? ['password'] : auth === 'privateKey' ? ['keyContent', 'passphrase'] : [] + return kinds.filter((k) => active.includes(k.kind) && hasSavedKind(k.kind, editing.savedAuth)) + }, [editing, auth]) + + return ( + + {errorMsg} + + + + } + > +
void handleSave()} + > + + + + + + + + + + + + + + + + + + setAuth(e.target.value as SshAuthMethod)}> + 密码 + 私钥 + Agent + + + + {savedNotes.length > 0 && + savedNotes.map((n) => ( + + ))} + + + + + + + +
+ ) +} \ No newline at end of file diff --git a/src/renderer/src/connections/ConnectionSidebar.tsx b/src/renderer/src/connections/ConnectionSidebar.tsx new file mode 100644 index 0000000..cec4b62 --- /dev/null +++ b/src/renderer/src/connections/ConnectionSidebar.tsx @@ -0,0 +1,208 @@ +import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react' +import { Button, Collapse, Popconfirm, Tooltip } from 'antd' +import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons' +import type { SshConnection } from '@shared/connections' +import { AuthTags } from './Common' +import { ConnectionEditDialog } from './ConnectionEditDialog' +import './connections.css' + +export interface ConnectionSidebarProps { + className?: string + /** called when the user double-clicks a connection */ + onConnect: (conn: SshConnection) => void +} + +export interface ConnectionSidebarHandle { + /** reload the connection list from main */ + refresh: () => Promise +} + +const DEFAULT_GROUP = '默认' + +/** + * Left-hand SSH bookmark sidebar (XTerminal server-list style). + * + * Refresh contract: the parent may call `useRef()` and + * invoke `refresh()` after opening an SSH session elsewhere (e.g. to update + * `lastConnectedAt`), or simply remount the component with a `key`. + */ +export const ConnectionSidebar = forwardRef( + function ConnectionSidebar({ className, onConnect }: ConnectionSidebarProps, ref): React.JSX.Element { + const [connections, setConnections] = useState([]) + const [loaded, setLoaded] = useState(false) + const [dialogOpen, setDialogOpen] = useState(false) + const [editing, setEditing] = useState(null) + + const refresh = async (): Promise => { + try { + const conns = await window.api.listConnections() + setConnections(conns) + } catch { + setConnections([]) + } finally { + setLoaded(true) + } + } + + useImperativeHandle(ref, () => ({ refresh }), [refresh]) + + useEffect(() => { + void refresh() + }, []) + + // Fold every group open whenever the dataset changes. + const activeGroups = useMemo(() => { + const groups = new Set() + for (const c of connections) groups.add(c.group ?? DEFAULT_GROUP) + return [...groups] + }, [connections]) + + const groups = useMemo(() => { + const map = new Map() + for (const c of connections) { + const key = c.group ?? DEFAULT_GROUP + const list = map.get(key) + if (list) list.push(c) + else map.set(key, [c]) + } + const entries = [...map.entries()].sort((a, b) => { + if (a[0] === DEFAULT_GROUP) return -1 + if (b[0] === DEFAULT_GROUP) return 1 + return a[0].localeCompare(b[0]) + }) + return entries.map(([group, list]) => ({ group, list })) + }, [connections]) + + const openCreate = (): void => { + setEditing(null) + setDialogOpen(true) + } + + const openEdit = (conn: SshConnection): void => { + setEditing(conn) + setDialogOpen(true) + } + + const handleDelete = async (id: string): Promise => { + await window.api.deleteConnection(id) + await refresh() + } + + const handleSaved = (): void => { + void refresh() + } + + const collapseItems = groups.map(({ group, list }) => ({ + key: group, + label: ( +
+ {group} + {list.length} +
+ ), + children: ( +
+ {list.map((conn) => ( + onConnect(conn)} + onEdit={() => openEdit(conn)} + onDelete={() => void handleDelete(conn.id)} + /> + ))} +
+ ) + })) + + return ( + + ) + } +) + +function Row({ + conn, + onDoubleClick, + onEdit, + onDelete +}: { + conn: SshConnection + onDoubleClick: () => void + onEdit: () => void + onDelete: () => void +}): React.JSX.Element { + const [confirming, setConfirming] = useState(false) + return ( +
+
+
+ {conn.name} + + + +
+
+ {conn.group ? `${conn.group} · ` : ''} + {conn.host}:{conn.port} · {conn.username} +
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/renderer/src/connections/Fields.tsx b/src/renderer/src/connections/Fields.tsx new file mode 100644 index 0000000..6568bd9 --- /dev/null +++ b/src/renderer/src/connections/Fields.tsx @@ -0,0 +1,89 @@ +import { Form, Input, Switch } from 'antd' +import useFormInstance from 'antd/es/form/hooks/useFormInstance' +import { useWatch } from 'antd/es/form/Form' +import type { SshAuthMethod } from '@shared/connections' + +/** + * Auth-method dependent fields rendered inside the edit dialog's
. + * Secret inputs (password / key content / passphrase) are controlled Form + * state only — they never enter connection objects shown elsewhere, and no + * plaintext is ever logged here. + */ + +export function AuthFieldsRenderer({ auth }: { auth: SshAuthMethod }): React.JSX.Element { + const form = useFormInstance() + const askPasswordAtConnect = useWatch('askPasswordAtConnect', form) ?? false + const askPassphraseAtConnect = useWatch('askPassphraseAtConnect', form) ?? false + + if (auth === 'agent') { + return
使用本机 SSH Agent 中已加载的密钥进行认证,无需在本应用内保存任何密钥。
+ } + + return ( +
+ {auth === 'password' ? ( + <> + + {!askPasswordAtConnect && ( + + + + )} + + ) : ( + <> + + + + + + + + {!askPassphraseAtConnect && ( + + + + )} + + )} +
+ ) +} + +function SwitchRow({ + name, + label, + hint +}: { + name: 'askPasswordAtConnect' | 'askPassphraseAtConnect' + label: string + hint: string +}): React.JSX.Element { + const form = useFormInstance() + const checked = useWatch(name, form) ?? false + const set = (next: boolean): void => { + form.setFieldValue(name, next) + } + return ( +
+ + {label} +
{hint}
+
+ +
+ ) +} \ No newline at end of file diff --git a/src/renderer/src/connections/connections.css b/src/renderer/src/connections/connections.css new file mode 100644 index 0000000..b7e2160 --- /dev/null +++ b/src/renderer/src/connections/connections.css @@ -0,0 +1,271 @@ +/* ============================================================ + Connection manager UI (connections/**): sidebar + edit dialog. + Palette is driven by the global chrome variables (global.css). + ============================================================ */ + +/* ---------- sidebar shell ---------- */ + +.connections-sidebar { + width: 240px; + flex: 0 0 240px; + min-width: 0; + display: flex; + flex-direction: column; + background: var(--chrome-bg); + border-right: 1px solid var(--chrome-border); + box-sizing: border-box; +} + +.connections-header { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 8px 0 12px; + height: 34px; + box-sizing: border-box; +} + +.connections-title { + font-size: 13px; + font-weight: 600; + color: var(--chrome-fg); + letter-spacing: 0.5px; +} + +.connections-list { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding: 4px 8px 12px; + box-sizing: border-box; +} + +/* ---------- grouped accordion ---------- */ + +.connections-group-head { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.connections-group-name { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + color: var(--chrome-fg); +} + +.connections-group-count { + flex: 0 0 auto; + font-size: 11px; + color: #8a8a8a; + background: rgba(255, 255, 255, 0.08); + border-radius: 9px; + padding: 0 7px; + line-height: 18px; +} + +.connections-collapse .ant-collapse-item { + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.connections-collapse .ant-collapse-item:last-child { + border-bottom: none; +} + +.connections-collapse .ant-collapse-header { + align-items: center; + padding: 7px 4px; +} + +.connections-collapse .ant-collapse-content > .ant-collapse-content-box { + padding: 0 0 6px; +} + +/* ---------- connection rows ---------- */ + +.connections-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 6px 6px 8px; + border-radius: 4px; + cursor: pointer; + user-select: none; +} + +.connections-row:hover { + background: var(--chrome-hover); +} + +.connections-row-main { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; +} + +.connections-row-name { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--chrome-fg); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.connections-row-sub { + font-size: 11px; + color: #8a8a8a; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 1px; +} + +/* text-only auth badge: 密码 / 私钥 / Agent */ +.connections-auth-badge { + flex: 0 0 auto; + font-size: 10px; + line-height: 1; + padding: 2px 5px; + border-radius: 4px; + color: #9a9a9a; + background: rgba(255, 255, 255, 0.08); +} + +.connections-row-actions { + flex: 0 0 auto; + display: none; + align-items: center; + gap: 0; +} + +.connections-row:hover .connections-row-actions { + display: inline-flex; +} + +.connections-row-actions .ant-btn { + color: #9a9a9a; +} + +.connections-row-actions .ant-btn-text:hover { + color: #ffffff; + background: rgba(255, 255, 255, 0.1); +} + +/* ---------- empty state ---------- */ + +.connections-empty { + padding: 28px 8px; + text-align: center; + color: #8a8a8a; + font-size: 12px; + line-height: 20px; +} + +/* ---------- edit dialog ---------- */ + +.connections-dialog .connections-auth-agents { + display: flex; + flex-direction: column; + gap: 16px; +} + +.connections-form-section { + border-top: 1px solid rgba(255, 255, 255, 0.08); + padding-top: 8px; +} + +.connections-form-section-title { + font-size: 12px; + font-weight: 600; + color: rgba(255, 255, 255, 0.55); + letter-spacing: 0.5px; + margin: 10px 0 0; +} + +.connections-switch-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 2px 0 14px; +} + +.connections-switch-label { + color: rgba(255, 255, 255, 0.88); + font-size: 13px; +} + +.connections-switch-hint { + color: rgba(255, 255, 255, 0.45); + font-size: 12px; + margin-top: 2px; +} + +.connections-dialog .ant-form-item { + margin-bottom: 14px; +} + +.connections-dialog .ant-form-item:last-child { + margin-bottom: 0; +} + +.connections-dialog .ant-alert { + font-size: 12px; +} + +.connections-secret-note { + margin-bottom: 12px; +} + +.connections-dialog-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; +} + +.connections-dialog-error { + flex: 1 1 auto; + min-width: 0; + font-size: 12px; + color: #cf6679; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; +} + +.connections-empty-create { + border: none; + background: none; + color: #8a8a8a; + font: inherit; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; +} + +.connections-empty-create:hover { + color: #ffffff; + background: var(--chrome-hover); +} + +.connections-row-label { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} \ No newline at end of file diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts new file mode 100644 index 0000000..e8e6140 --- /dev/null +++ b/src/renderer/src/env.d.ts @@ -0,0 +1,9 @@ +import type { AppApi } from '../shared/api' + +declare global { + interface Window { + api: AppApi + } +} + +export {} diff --git a/src/renderer/src/global.css b/src/renderer/src/global.css new file mode 100644 index 0000000..fe3a2b8 --- /dev/null +++ b/src/renderer/src/global.css @@ -0,0 +1,68 @@ +/* Global chrome styles. Terminal-specific styles live in their own modules. */ +:root { + --chrome-bg: #181818; + --chrome-fg: #cccccc; + --chrome-border: #2d2d2d; + --chrome-accent: #3b8eea; + --chrome-hover: #2a2a2a; +} + +html, +body, +#root { + height: 100%; + margin: 0; + padding: 0; + overflow: hidden; + background: var(--chrome-bg); + color: var(--chrome-fg); + font-family: 'Segoe UI', system-ui, sans-serif; + font-size: 13px; +} + +/* App-wide slim dark scrollbars: the native Windows style renders light + tracks + arrow buttons that look broken on the dark surface. */ +* { + scrollbar-width: thin; + scrollbar-color: rgba(121, 121, 121, 0.45) transparent; +} + +*::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background: rgba(121, 121, 121, 0.45); + border-radius: 5px; +} + +*::-webkit-scrollbar-thumb:hover { + background: rgba(160, 160, 160, 0.6); +} + +*::-webkit-scrollbar-button, +*::-webkit-scrollbar-corner { + display: none; + background: transparent; +} + +button { + font: inherit; +} + +/* antd renders this wrapper between #root and .app-root; without an + explicit height the percentage chain breaks and the workspace collapses. */ +.ant-app { + height: 100%; +} + +.app-root { + height: 100%; + display: flex; + flex-direction: column; +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx new file mode 100644 index 0000000..43828c6 --- /dev/null +++ b/src/renderer/src/main.tsx @@ -0,0 +1,71 @@ +import ReactDOM from 'react-dom/client' +import { App as AntdApp, ConfigProvider, theme as antdTheme } from 'antd' +import type { AppApi } from '@shared/api' +import { DEFAULT_SETTINGS } from '@shared/settings' +import App from './App' +import { ErrorBoundary } from './ErrorBoundary' +import './global.css' + +// Dev convenience: allow opening the renderer in a plain browser (vite page) +// so layout/UX work doesn't require the electron shell. No-op under electron. +if (typeof window !== 'undefined' && !window.api) { + const noop = (): void => undefined + const stubId = (): string => `stub-${Math.random().toString(36).slice(2)}` + const api: AppApi = { + appInfo: async () => ({ platform: 'browser', appVersion: 'dev', homeDir: '' }), + createPty: async () => ({ id: stubId(), shell: 'stub', cwd: '' }), + openSession: async () => ({ id: stubId() }), + getSessionReplay: async () => '', + writePty: noop, + resizePty: noop, + killPty: noop, + onPtyData: () => noop, + onPtyExit: () => noop, + listConnections: async () => [], + saveConnection: async (input) => ({ + id: input.id ?? stubId(), + name: input.name, + group: input.group, + host: input.host, + port: input.port, + username: input.username, + auth: input.auth, + askPasswordAtConnect: input.askPasswordAtConnect, + askPassphraseAtConnect: input.askPassphraseAtConnect, + keyPath: input.keyPath, + keepaliveIntervalSec: input.keepaliveIntervalSec, + createdAt: Date.now(), + savedAuth: { hasPassword: false, hasKeyContent: false, hasPassphrase: false } + }), + deleteConnection: async () => undefined, + onHostKeyPrompt: () => noop, + respondHostKey: noop, + getSettings: async () => DEFAULT_SETTINGS, + saveSettings: async (s) => s, + onSettingsChanged: () => noop, + listFonts: async () => [], + listLayouts: async () => [], + getLayout: async () => null, + saveLayout: async () => undefined, + deleteLayout: async () => undefined + } + ;(window as unknown as { api: AppApi }).api = api +} + +// antd provides the context that App.useApp() consumers (workspace +// message/notification) rely on; without it they get empty stubs and crash. +// ErrorBoundary keeps any render crash from black-screening the whole window. +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + + + + + +) diff --git a/src/renderer/src/settings/HighlightTab.tsx b/src/renderer/src/settings/HighlightTab.tsx new file mode 100644 index 0000000..66ffcd2 --- /dev/null +++ b/src/renderer/src/settings/HighlightTab.tsx @@ -0,0 +1,451 @@ +import { useEffect, useMemo, useState } from 'react' +import { + Alert, + Button, + Input, + InputNumber, + Modal, + Popconfirm, + Switch, + Table, + Tooltip +} from 'antd' +import type { ColumnsType } from 'antd/es/table' +import { DEFAULT_HIGHLIGHT_RULES } from '@shared/settings' +import type { HighlightRule } from '@shared/settings' +import { useSettingsStore } from './store' +import './highlight.css' + +const ORDERED_SORT: 'ascend' = 'ascend' + +/** preset color swatches for the highlighting editor. */ +const PRESET_COLORS = ['#3fb950', '#f85149', '#e3b341', '#58a6ff', '#d2a8ff', '#79c0ff', '#bc8cff', '#f2cc60'] + +/** clone the builtin preset set to prevent reference pollution by mutations. */ +const cloneDefaults = (): HighlightRule[] => JSON.parse(JSON.stringify(DEFAULT_HIGHLIGHT_RULES)) as HighlightRule[] + +function tryCompile(pattern: string): { ok: boolean; message?: string } { + if (pattern.trim() === '') return { ok: true } + try { + // eslint-disable-next-line no-new + new RegExp(pattern) + return { ok: true } + } catch (err) { + return { ok: false, message: err instanceof Error ? err.message : '正则表达式无效' } + } +} + +interface RuleDraft { + pattern: string + priority: number + fg: string + bg?: string + note?: string + enabled: boolean +} + +interface EditorState { + open: boolean + /** editing an existing rule → its id; create mode → undefined */ + id?: string +} + +export function HighlightTab(): React.JSX.Element { + const highlightRules = useSettingsStore((s) => s.settings.highlightRules) + const setHighlightRules = useSettingsStore((s) => s.setHighlightRules) + const [editor, setEditor] = useState({ open: false }) + + const sorted = useMemo( + () => [...highlightRules].sort((a, b) => a.priority - b.priority), + [highlightRules] + ) + + const openCreate = (): void => setEditor({ open: true, id: undefined }) + const openEdit = (target: HighlightRule): void => setEditor({ open: true, id: target.id }) + const closeEditor = (): void => setEditor((prev) => (prev.open ? { ...prev, open: false } : prev)) + + const replaceRule = async (updated: HighlightRule): Promise => { + const next = highlightRules.map((r) => (r.id === updated.id ? updated : r)) + await setHighlightRules(next) + } + + const toggleRule = async (target: HighlightRule, checked: boolean): Promise => { + await replaceRule({ ...target, enabled: checked }) + } + + const handleDelete = async (id: string): Promise => { + await setHighlightRules(highlightRules.filter((r) => r.id !== id)) + } + + const handleReset = async (): Promise => { + await setHighlightRules(cloneDefaults()) + } + + const columns: ColumnsType = [ + { + title: '启用', + dataIndex: 'enabled', + width: 64, + align: 'center', + render: (enabled: boolean, record) => ( + void toggleRule(record, c)} /> + ) + }, + { + title: '正则', + dataIndex: 'pattern', + ellipsis: true, + render: (pattern: string) => + }, + { + title: '优先级', + dataIndex: 'priority', + width: 76, + align: 'center', + sorter: (a, b) => a.priority - b.priority, + defaultSortOrder: ORDERED_SORT, + render: (priority: number) => {priority} + }, + { + title: '预览', + dataIndex: 'color', + width: 120, + align: 'center', + render: (color: HighlightRule['color']) => ( + + Highlight + + ) + }, + { + title: '备注', + dataIndex: 'note', + ellipsis: true, + render: (note: string | undefined) => + note ? {note} : — + }, + { + title: '操作', + key: 'action', + width: 120, + align: 'center', + render: (_, record) => ( +
+ + void handleDelete(record.id)} + > + + +
+ ) + } + ] + + return ( +
+ +
+ void handleReset()} + > + + + +
+
+ + + + + ) +} + +function PatternCell({ pattern }: { pattern: string }): React.JSX.Element { + const { ok, message } = tryCompile(pattern) + return ( +
+ + {pattern} + + {!ok && ( + + ⚠ + + )} +
+ ) +} + +/** + * Modal for creating / editing a single highlight rule. Mirrors the structure + * of ThemeEditor: local draft state, initialized on open, saved through the store. + */ +function HighlightEditor({ + open, + onClose, + ruleId +}: { + open: boolean + onClose: () => void + ruleId?: string +}): React.JSX.Element { + const highlightRules = useSettingsStore((s) => s.settings.highlightRules) + const setHighlightRules = useSettingsStore((s) => s.setHighlightRules) + + const isCreate = ruleId === undefined + const editing = useMemo( + () => (ruleId ? highlightRules.find((r) => r.id === ruleId) : undefined), + [highlightRules, ruleId] + ) + + const [draft, setDraft] = useState({ + pattern: '', + priority: 1, + fg: '#3fb950', + bg: undefined, + note: undefined, + enabled: true + }) + const [saving, setSaving] = useState(false) + const [showBg, setShowBg] = useState(false) + + // (re)initialize the form each time the modal opens + useEffect(() => { + if (!open) return + if (!isCreate && editing === undefined) { + onClose() + return + } + const pattern = editing ? editing.pattern : '' + const priority = editing ? editing.priority : 1 + const fg = editing ? editing.color.fg : PRESET_COLORS[0] + const bg = editing ? editing.color.bg : undefined + const note = editing ? editing.note : undefined + const enabled = editing ? editing.enabled : true + setDraft({ pattern, priority, fg, bg, note, enabled }) + setShowBg(bg != null) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + const compile = tryCompile(draft.pattern) + const valid = draft.pattern.trim() !== '' && compile.ok + + const patch = (partial: Partial): void => setDraft((prev) => ({ ...prev, ...partial })) + + const handleSave = async (): Promise => { + if (!valid) return + setSaving(true) + try { + const color: HighlightRule['color'] = { fg: draft.fg, ...(showBg && draft.bg ? { bg: draft.bg } : {}) } + if (isCreate) { + const rule: HighlightRule = { + id: crypto.randomUUID(), + pattern: draft.pattern.trim(), + priority: draft.priority, + enabled: draft.enabled, + color, + note: draft.note?.trim() ? draft.note.trim() : undefined + } + await setHighlightRules([...highlightRules, rule]) + } else if (editing !== undefined) { + const rule: HighlightRule = { + ...editing, + pattern: draft.pattern.trim(), + priority: draft.priority, + enabled: draft.enabled, + color, + note: draft.note?.trim() ? draft.note.trim() : undefined + } + await setHighlightRules(highlightRules.map((r) => (r.id === rule.id ? rule : r))) + } + onClose() + } finally { + setSaving(false) + } + } + + return ( + void handleSave()} + okText="保存" + cancelText="取消" + confirmLoading={saving} + destroyOnHidden + width={560} + title={isCreate ? '新建高亮规则' : '编辑高亮规则'} + okButtonProps={{ disabled: !valid }} + > +
+
+ 正则 +
+ patch({ pattern: e.target.value })} + placeholder="例如 \b(ERROR|FAILED)\b" + autoSize={{ minRows: 1, maxRows: 4 }} + className={compile.ok ? undefined : 'hl-editor-input-bad'} + /> + {!compile.ok && compile.message != null && ( +
{compile.message}
+ )} +
+
+ +
+ 优先级 + { + if (v !== null) patch({ priority: v }) + }} + /> + 值越小越先应用 +
+ +
+ 颜色 +
+ patch({ fg: v })} + /> +
+ 背景 + {showBg ? ( + <> + patch({ bg: v })} + /> + + + ) : ( + + )} +
+
+
+ +
+ 色板 +
+ {showBg && 背景} + {PRESET_COLORS.map((c) => ( + (showBg ? patch({ bg: v }) : patch({ fg: v }))} + /> + ))} +
+
+ +
+ 备注 + patch({ note: e.target.value })} + placeholder="规则说明(可选)" + maxLength={60} + /> +
+ +
+ 启用 + patch({ enabled: c })} /> +
+
+
+ ) +} + +function ColorField({ + label, + value, + onChange +}: { + label?: string + value: string + onChange: (value: string) => void +}): React.JSX.Element { + return ( +
+ {label != null && {label}} + onChange(e.target.value)} + /> +
+ ) +} + +function PaletteSwatch({ + color, + selected, + onClick +}: { + color: string + selected?: string + onClick: (value: string) => void +}): React.JSX.Element { + const isSel = color === selected + return ( + onClick(color)} + /> + ) +} \ No newline at end of file diff --git a/src/renderer/src/settings/SettingsDialog.tsx b/src/renderer/src/settings/SettingsDialog.tsx new file mode 100644 index 0000000..84a77b7 --- /dev/null +++ b/src/renderer/src/settings/SettingsDialog.tsx @@ -0,0 +1,69 @@ +import { useMemo, useState } from 'react' +import { Button, Modal, Tabs } from 'antd' +import type { TabsProps } from 'antd' +import { DEFAULT_SETTINGS } from '@shared/settings' +import { useSettingsStore } from './store' +import { CursorSettingsTab, FontSettingsTab, RenderSettingsTab } from './SettingsTabs' +import { ThemeSettingsTab } from './ThemeSettingsTab' +import { HighlightTab } from './HighlightTab' +import { ThemeEditor } from '../theme/editor/ThemeEditor' +import './settings.css' + +export interface SettingsDialogProps { + open: boolean + onClose: () => void +} + +export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JSX.Element { + const updateTerminal = useSettingsStore((s) => s.updateTerminal) + const [editorState, setEditorState] = useState<{ open: boolean; themeId?: string }>({ + open: false + }) + + const tabItems: TabsProps['items'] = [ + { key: 'font', label: '字体', children: }, + { key: 'cursor', label: '光标与滚动', children: }, + { key: 'render', label: '渲染与行为', children: }, + { key: 'highlight', label: '高亮', children: }, + { + key: 'theme', + label: '主题', + children: ( + setEditorState({ open: true, themeId })} + onCreateTheme={() => setEditorState({ open: true, themeId: undefined })} + /> + ) + } + ] + + const closeEditor = (): void => setEditorState((prev) => (prev.open ? { ...prev, open: false } : prev)) + + const handleResetDefaults = (): void => { + void updateTerminal({ ...DEFAULT_SETTINGS.terminal }) + } + + return ( + + + + + } + > + + + + ) +} \ No newline at end of file diff --git a/src/renderer/src/settings/SettingsTabs.tsx b/src/renderer/src/settings/SettingsTabs.tsx new file mode 100644 index 0000000..d266fa7 --- /dev/null +++ b/src/renderer/src/settings/SettingsTabs.tsx @@ -0,0 +1,345 @@ +import type { ReactNode } from 'react' +import { useMemo, useState } from 'react' +import { InputNumber, Radio, Select, Switch } from 'antd' +import type { ThemeColors } from '@shared/theme' +import { useSettingsStore, useResolvedTheme } from './store' +import { + DEFAULT_FONT_STACK, + SettingRow, + cursorStyleOptions, + inactiveCursorStyleOptions, + rendererModeOptions, + weightOptions +} from './fields' + +export function FontSettingsTab(): React.JSX.Element { + const settings = useSettingsStore((s) => s.settings) + const updateTerminal = useSettingsStore((s) => s.updateTerminal) + const theme = useResolvedTheme() + + const [fonts, setFonts] = useState([]) + + // refresh system font list when the component mounts (dialog open first time); + // results are cached for this component's lifetime + useState(() => { + window.api.listFonts().then( + (list: string[]) => setFonts(list.filter((f: string) => f !== '')), + (err: unknown) => console.error('[settings] listFonts failed', err) + ) + return [] + }) + + const fontOptions = useMemo(() => { + const seen = new Set() + const items: Array<{ value: string; label: ReactNode }> = [] + for (const f of fonts) { + if (seen.has(f)) continue + seen.add(f) + items.push({ value: f, label: {f} }) + } + return items + }, [fonts]) + + /** map the persisted family to a recognized option value (default / system font / verbatim) */ + const selectedFont = (): string => { + const cur = settings.terminal.fontFamily + if (cur === DEFAULT_FONT_STACK) return 'default' + if (fonts.includes(cur)) return cur + return cur + } + + const handleFontChange = (value: string): void => { + void updateTerminal({ + fontFamily: value === 'default' ? DEFAULT_FONT_STACK : value + }) + } + + return ( +
+ 默认 }, + ...fontOptions + ]} + filterOption={(input, option) => + typeof option?.value === 'string' && + option.value.toLowerCase().includes(input.toLowerCase()) + } + /> + } + /> + { + if (v !== null) void updateTerminal({ fontSize: v }) + }} + /> + } + /> + { + if (v !== null) void updateTerminal({ fontWeight: v }) + }} + options={weightOptions} + /> + } + /> + { + if (v !== null) void updateTerminal({ boldFontWeight: v }) + }} + options={weightOptions} + /> + } + /> + { + if (v !== null) void updateTerminal({ letterSpacing: v }) + }} + /> + } + /> + { + if (v !== null) void updateTerminal({ lineHeight: v }) + }} + /> + } + /> + + +
+ ) +} + +export function CursorSettingsTab(): React.JSX.Element { + const settings = useSettingsStore((s) => s.settings) + const updateTerminal = useSettingsStore((s) => s.updateTerminal) + return ( +
+ void updateTerminal({ cursorBlink: checked })} + /> + } + /> + { + if (v !== null) void updateTerminal({ cursorStyle: v }) + }} + options={cursorStyleOptions} + /> + } + /> + { + if (v !== null) void updateTerminal({ cursorInactiveStyle: v }) + }} + options={inactiveCursorStyleOptions} + /> + } + /> + { + if (v !== null) void updateTerminal({ scrollback: v }) + }} + /> + } + /> +
+ ) +} + +export function RenderSettingsTab(): React.JSX.Element { + const settings = useSettingsStore((s) => s.settings) + const updateTerminal = useSettingsStore((s) => s.updateTerminal) + return ( +
+
+ 渲染模式 + 终端单元渲染加速方式,改动将应用到所有终端 +
+ void updateTerminal({ rendererMode: e.target.value })} + options={rendererModeOptions} + optionType="button" + buttonStyle="solid" + /> +
+ {rendererModeOptions.find((o) => o.value === settings.terminal.rendererMode)?.hint} +
+ void updateTerminal({ autoWrap: checked })} + /> + } + /> + void updateTerminal({ copyOnSelect: checked })} + /> + } + /> + void updateTerminal({ pasteRiskConfirm: checked })} + /> + } + /> +
+ ) +} + +/** + * Live preview of the current font settings, rendered as an `ls -la`-style + * terminal screen with ANSI-colored tokens on the active theme background. + */ +function TerminalPreview({ + fontFamily, + fontSize, + fontWeight, + lineHeight, + letterSpacing, + colors +}: { + fontFamily: string + fontSize: number + fontWeight: number + lineHeight: number + letterSpacing: number + colors: ThemeColors +}): React.JSX.Element { + const lines: Array = [ + + drwxr-xr-x{' '} + 12 bill users 4096 Sep 5 10:02{' '} + node_modules + , + + -rw-r--r--{' '} + 1 bill users 890 Sep 5 09:41{' '} + package.json + , + + -rw-r--r--{' '} + 1 bill users 42 Sep 5 09:41{' '} + index.ts + , + + + bill@desktop:~$ _ + + + ] + const gridStyle: React.CSSProperties = { + fontFamily, + fontSize, + fontWeight, + lineHeight, + letterSpacing + } + return ( +
+ 预览 +
+ {lines.map((l, i) => ( +
+ {l} +
+ ))} +
+
+ ) +} \ No newline at end of file diff --git a/src/renderer/src/settings/ThemeSettingsTab.tsx b/src/renderer/src/settings/ThemeSettingsTab.tsx new file mode 100644 index 0000000..5ab1610 --- /dev/null +++ b/src/renderer/src/settings/ThemeSettingsTab.tsx @@ -0,0 +1,121 @@ +import { useMemo } from 'react' +import { Button, Popconfirm } from 'antd' +import type { TerminalSettings } from '@shared/settings' +import { DEFAULT_SETTINGS } from '@shared/settings' +import type { TerminalTheme, ThemeColors } from '@shared/theme' +import { BUILTIN_THEMES } from '@shared/theme' +import { useSettingsStore, useResolvedTheme } from './store' + +export interface ThemeSettingsTabProps { + onCreateTheme: () => void + onEditTheme: (themeId: string) => void +} + +const ANSI_KEYS: ReadonlyArray = [ + 'black', + 'red', + 'green', + 'yellow', + 'blue', + 'magenta', + 'cyan', + 'white' +] + +export function ThemeSettingsTab({ + onCreateTheme, + onEditTheme +}: ThemeSettingsTabProps): React.JSX.Element { + const settings = useSettingsStore((s) => s.settings) + const updateTerminal = useSettingsStore((s) => s.updateTerminal) + const setCustomThemes = useSettingsStore((s) => s.setCustomThemes) + const activeTheme = useResolvedTheme() + + const themeCards = useMemo( + () => [...BUILTIN_THEMES, ...settings.customThemes], + [settings.customThemes] + ) + + const handleDelete = async (themeId: string): Promise => { + const next = settings.customThemes.filter((t) => t.id !== themeId) + if (settings.terminal.themeId === themeId) { + // currently-active custom theme removed — fall back to the default dark theme + await updateTerminal({ themeId: DEFAULT_SETTINGS.terminal.themeId }) + } + await setCustomThemes(next) + } + + return ( +
+ {themeCards.map((t) => { + const selected = t.id === activeTheme.id + const isCustom = t.builtin === false + return ( +
void updateTerminal({ themeId: t.id })} + title={t.name} + > +
+
+
+ {t.name} + {isCustom && 自定义} +
+
+{ANSI_KEYS.map((k) => ( + + ))} +
+
+ {isCustom ? ( + <> + + { + e?.stopPropagation() + void handleDelete(t.id) + }} + > + + + + ) : null} +
+
+
+ ) + })} + +
+
+
+
新建主题
+
+
+ ) +} \ No newline at end of file diff --git a/src/renderer/src/settings/fields.tsx b/src/renderer/src/settings/fields.tsx new file mode 100644 index 0000000..688e6f6 --- /dev/null +++ b/src/renderer/src/settings/fields.tsx @@ -0,0 +1,57 @@ +import type { ReactNode } from 'react' +import type { TerminalSettings } from '@shared/settings' + +/** font stack shown as the persistent "默认" option */ +export const DEFAULT_FONT_STACK = "'Cascadia Mono', Consolas, 'JetBrains Mono', 'Courier New', monospace" + +export const WEIGHT_OPTIONS = [100, 200, 300, 400, 500, 600, 700, 800, 900] + +export const weightOptions = WEIGHT_OPTIONS.map((w) => ({ value: w, label: String(w) })) + +export const cursorStyleOptions: Array<{ value: TerminalSettings['cursorStyle']; label: string }> = [ + { value: 'bar', label: '竖线 (bar)' }, + { value: 'block', label: '方块 (block)' }, + { value: 'underline', label: '下划线 (underline)' } +] + +export const inactiveCursorStyleOptions: Array<{ + value: TerminalSettings['cursorInactiveStyle'] + label: string +}> = [ + { value: 'outline', label: '轮廓 (outline)' }, + { value: 'block', label: '方块 (block)' }, + { value: 'bar', label: '竖线 (bar)' }, + { value: 'underline', label: '下划线 (underline)' }, + { value: 'none', label: '无 (none)' } +] + +export const rendererModeOptions: Array<{ + value: TerminalSettings['rendererMode'] + label: string + hint: string +}> = [ + { value: 'auto', label: '自动', hint: '优先使用 WebGL 加速,失败时自动回退到 DOM 渲染' }, + { value: 'webgl', label: '高性能 WebGL', hint: '强制使用 GPU 加速渲染,适合高刷新率与大行数滚动' }, + { value: 'dom', label: '兼容 DOM', hint: '纯 CPU 渲染,兼容性最好,适合无 GPU 或远程桌面环境' } +] + +/** one label + control row inside a settings pane */ +export function SettingRow({ + label, + desc, + control +}: { + label: ReactNode + desc?: ReactNode + control: ReactNode +}): React.JSX.Element { + return ( +
+ + {label} + {desc != null && {desc}} + +
{control}
+
+ ) +} \ No newline at end of file diff --git a/src/renderer/src/settings/highlight.css b/src/renderer/src/settings/highlight.css new file mode 100644 index 0000000..7d9545c --- /dev/null +++ b/src/renderer/src/settings/highlight.css @@ -0,0 +1,221 @@ +/* ============================================================ + Highlight settings tab + rule editor (settings/highlight.css) + ============================================================ */ + +.hl-tab { + display: flex; + flex-direction: column; + gap: 12px; + padding-top: 8px; +} + +.hl-toolbar { + display: flex; + justify-content: flex-end; + gap: 8px; + align-items: center; +} + +.hl-table .ant-table { + background: transparent; +} + +.hl-table .ant-table-thead > tr > th { + background: rgba(255, 255, 255, 0.04); + border-bottom: 1px solid var(--chrome-border); + color: rgba(255, 255, 255, 0.85); + font-size: 12px; +} + +.hl-table .ant-table-tbody > tr > td { + background: transparent; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.hl-table .ant-table-tbody > tr:hover > td { + background: var(--chrome-hover) !important; +} + +/* ---- regex cell ---- */ + +.hl-pattern-cell { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.hl-pattern-text { + font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace; + font-size: 12px; + color: rgba(255, 255, 255, 0.88); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.hl-pattern-bad { + color: #f85149; +} + +.hl-warn-icon { + flex: 0 0 auto; + color: #f85149; + font-size: 12px; + line-height: 1; + cursor: help; +} + +/* ---- misc cells ---- */ + +.hl-cell-priority { + font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace; + font-size: 12px; + color: rgba(255, 255, 255, 0.88); +} + +.hl-cell-note { + font-size: 12px; + color: rgba(255, 255, 255, 0.7); +} + +.hl-cell-empty { + font-size: 12px; + color: rgba(255, 255, 255, 0.3); +} + +.hl-preview-text { + display: inline-block; + font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace; + font-size: 12px; + font-weight: 600; + line-height: 1; + min-width: 74px; +} + +.hl-actions { + display: flex; + justify-content: center; + gap: 6px; +} + +/* ---- rule editor ---- */ + +.hl-editor { + padding-top: 4px; +} + +.hl-editor-row { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 14px; +} + +.hl-editor-label { + flex: 0 0 56px; + color: rgba(255, 255, 255, 0.88); + padding-top: 5px; +} + +.hl-editor-control { + flex: 1 1 auto; + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.hl-editor-hint { + align-self: center; + font-size: 12px; + color: rgba(255, 255, 255, 0.45); +} + +.hl-editor-error { + font-size: 12px; + color: #f85149; +} + +.hl-editor-input-bad { + border-color: #f85149; +} + +.hl-editor-input-bad:hover, +.hl-editor-input-bad:focus { + border-color: #f85149; + box-shadow: 0 0 0 2px rgba(248, 81, 73, 0.2); +} + +.hl-editor-sub-label { + font-size: 12px; + color: rgba(255, 255, 255, 0.6); +} + +.hl-editor-inline { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.hl-color-field { + display: flex; + align-items: center; + gap: 8px; +} + +.hl-color-input { + flex: 0 0 auto; + width: 40px; + height: 26px; + padding: 0; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + background: transparent; + cursor: pointer; +} + +.hl-color-input::-webkit-color-swatch-wrapper { + padding: 2px; +} + +.hl-color-input::-webkit-color-swatch { + border: none; + border-radius: 3px; +} + +/* ---- palette swatches (16px, radius 3px, clickable) ---- */ + +.hl-palette { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + padding-top: 4px; +} + +.hl-palette-tag { + font-size: 12px; + color: rgba(255, 255, 255, 0.5); + margin-right: 2px; +} + +.hl-swatch { + width: 16px; + height: 16px; + border-radius: 3px; + border: 1px solid rgba(255, 255, 255, 0.3); + cursor: pointer; + display: inline-block; + transition: transform 0.12s ease, box-shadow 0.12s ease; +} + +.hl-swatch:hover { + transform: scale(1.15); +} + +.hl-swatch-selected { + box-shadow: 0 0 0 2px var(--chrome-accent) inset; + border-color: var(--chrome-accent); +} \ No newline at end of file diff --git a/src/renderer/src/settings/settings.css b/src/renderer/src/settings/settings.css new file mode 100644 index 0000000..3580f93 --- /dev/null +++ b/src/renderer/src/settings/settings.css @@ -0,0 +1,342 @@ +/* ============================================================ + Settings dialog + theme editor styles (settings/**, theme/**) + ============================================================ */ + +/* ---------- settings dialog scaffolding ---------- */ + +.ant-modal-header { + border-bottom: 1px solid #2d2d2d; + padding-bottom: 14px; +} + +.ant-modal .ant-tabs-nav { + margin-bottom: 8px; + border-bottom: 1px solid #2d2d2d; +} + +.settings-footer { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} + +.settings-pane { + padding-top: 8px; +} + +.settings-row { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 44px; + gap: 16px; + padding: 6px 8px; + border-radius: 4px; + transition: background-color 0.15s ease; +} + +.settings-row:hover { + background: rgba(255, 255, 255, 0.03); +} + +.settings-row + .settings-row { + margin-top: 2px; +} + +.settings-row-label { + flex: 0 0 46%; + color: rgba(255, 255, 255, 0.88); + font-size: 13px; + line-height: 1.4; +} + +.settings-row-desc { + display: block; + margin-top: 2px; + font-size: 12px; + font-weight: 400; + color: rgba(255, 255, 255, 0.45); +} + +.settings-row-control { + flex: 1 1 auto; + display: flex; + justify-content: flex-end; + align-items: center; + gap: 8px; +} + +.settings-select { + width: 220px; +} + +.settings-input-number { + width: 220px; +} + +.settings-block-label { + display: flex; + align-items: baseline; + gap: 12px; + font-size: 13px; + font-weight: 600; + margin-bottom: 8px; +} + +.settings-block-hint { + font-weight: 400; + font-size: 12px; + color: rgba(255, 255, 255, 0.45); +} + +.settings-radio-group { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 16px; +} + +.settings-radio-group .ant-radio-button-wrapper { + min-width: 120px; + text-align: center; +} + +.settings-render-hint { + margin: -8px 0 16px; +} + +/* ---------- font tab: live terminal preview ---------- */ + +.settings-preview { + margin-top: 14px; + border-radius: 6px; + padding: 12px 14px; + border: 1px solid #2d2d2d; + overflow: hidden; +} + +.settings-preview-title { + display: block; + font-size: 12px; + color: rgba(255, 255, 255, 0.5); + margin-bottom: 8px; +} + +.settings-preview-grid { + white-space: pre; + font-variant-ligatures: none; + overflow: hidden; +} + +.settings-preview-line { + overflow: hidden; + text-overflow: clip; +} + +/* ---------- theme gallery ---------- */ + +.theme-gallery { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 12px; + max-height: 480px; + overflow-y: auto; + padding: 4px 2px 12px; +} + +.theme-card { + position: relative; + border-radius: 8px; + overflow: hidden; + cursor: pointer; + border: 2px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.04); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.theme-card:hover { + border-color: var(--chrome-accent); + background: rgba(255, 255, 255, 0.07); +} + +.theme-card-selected { + border-color: #3b8eea; + box-shadow: 0 0 0 1px #3b8eea inset; +} + +.theme-card-bg { + height: 64px; +} + +.theme-card-body { + padding: 8px 10px 10px; +} + +.theme-card-name-row { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; +} + +.theme-card-name { + font-size: 13px; + font-weight: 600; + color: rgba(255, 255, 255, 0.88); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.theme-card-badge { + flex: 0 0 auto; + font-size: 10px; + line-height: 1; + padding: 2px 5px; + border-radius: 4px; + background: rgba(59, 142, 234, 0.2); + color: #6fb3ff; +} + +.theme-card-dots { + display: flex; + gap: 4px; + margin-bottom: 8px; +} + +.theme-card-dot { + width: 14px; + height: 14px; + border-radius: 3px; + border: 1px solid rgba(255, 255, 255, 0.25); + display: inline-block; +} + +.theme-card-actions { + display: flex; + gap: 6px; + justify-content: flex-end; + opacity: 0; + transition: opacity 0.15s ease; +} + +.theme-card:hover .theme-card-actions, +.theme-card:focus-within .theme-card-actions { + opacity: 1; +} + +.theme-card-new { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 148px; + border-style: dashed; +} + +.theme-card-new-icon { + font-size: 26px; + line-height: 1; + color: rgba(255, 255, 255, 0.55); +} + +.theme-card-new-text { + font-size: 13px; + color: rgba(255, 255, 255, 0.6); +} + +/* ---------- theme editor ---------- */ + +.theme-editor { + padding-top: 4px; +} + +.theme-editor-row { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 14px; +} + +.theme-editor-label { + flex: 0 0 56px; + color: rgba(255, 255, 255, 0.88); +} + +.theme-editor-colors { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 8px 12px; + max-height: 300px; + overflow-y: auto; + padding-right: 4px; +} + +.theme-editor-color-field { + display: flex; + align-items: center; + gap: 8px; +} + +.theme-editor-color-label { + font-size: 12px; + color: rgba(255, 255, 255, 0.7); + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.theme-editor-color-input { + flex: 0 0 auto; + width: 40px; + height: 26px; + padding: 0; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + background: transparent; + cursor: pointer; +} + +.theme-editor-color-input::-webkit-color-swatch-wrapper { + padding: 2px; +} + +.theme-editor-color-input::-webkit-color-swatch { + border: none; + border-radius: 3px; +} + +.theme-editor-divider { + grid-column: 1 / -1; + border-top: 1px solid rgba(255, 255, 255, 0.1); + margin: 2px 0; +} + +.theme-editor-preview { + margin-top: 14px; +} + +.theme-editor-preview-label { + display: block; + font-size: 12px; + color: rgba(255, 255, 255, 0.5); + margin-bottom: 6px; +} + +.theme-editor-preview-box { + border-radius: 6px; + padding: 10px 14px; + border: 1px solid #2d2d2d; + font-family: 'Cascadia Mono', Consolas, 'JetBrains Mono', monospace; + font-size: 12px; +} + +.theme-editor-preview-line { + white-space: pre; + line-height: 1.6; + overflow: hidden; + text-overflow: ellipsis; +} \ No newline at end of file diff --git a/src/renderer/src/settings/store.ts b/src/renderer/src/settings/store.ts new file mode 100644 index 0000000..e3f9d4d --- /dev/null +++ b/src/renderer/src/settings/store.ts @@ -0,0 +1,99 @@ +import type { AppSettings, TerminalSettings } from '@shared/settings' +import { DEFAULT_SETTINGS } from '@shared/settings' +import type { TerminalTheme } from '@shared/theme' +import { getThemeById } from '@shared/theme' +import { create } from 'zustand' + +export interface SettingsState { + /** true once initial settings have been loaded from the main process */ + hydrated: boolean + settings: AppSettings + hydrate: () => Promise + /** shallow-merge into settings.terminal and persist */ + updateTerminal: (partial: Partial) => Promise + /** replace customThemes array and persist */ + setCustomThemes: (themes: AppSettings['customThemes']) => Promise + /** replace highlightRules array and persist */ + setHighlightRules: (rules: AppSettings['highlightRules']) => Promise +} + +/** unsubscriber for the cross-window SETTINGS_CHANGED listener; held module-level so hydrate() is idempotent */ +let unsubscribeStoreListener: (() => void) | null = null +/** re-entrancy guard so concurrent hydrate() calls don't double-subscribe */ +let hydrating = false + +export const useSettingsStore = create((set, get) => ({ + hydrated: false, + settings: DEFAULT_SETTINGS, + + hydrate: async () => { + if (get().hydrated) return + // guard against concurrent hydrate() calls racing on the first render + if (hydrating) return + hydrating = true + try { + const settings = await window.api.getSettings() + set({ settings, hydrated: true }) + + if (unsubscribeStoreListener === null) { + unsubscribeStoreListener = window.api.onSettingsChanged((s: AppSettings) => + set({ settings: s }) + ) + } + } catch (err) { + console.error('[settings] hydrate failed', err) + } finally { + hydrating = false + } + }, + + updateTerminal: async (partial) => { + const prev = get().settings + const next: AppSettings = { + ...prev, + terminal: { ...prev.terminal, ...partial } + } + // optimistic update; the SETTINGS_CHANGED echo will re-set the same value (idempotent) + set({ settings: next }) + try { + await window.api.saveSettings(next) + } catch (err) { + console.error('[settings] updateTerminal failed, rolling back', err) + set({ settings: prev }) + } + }, + + setCustomThemes: async (themes) => { + const prev = get().settings + const next: AppSettings = { ...prev, customThemes: themes } + set({ settings: next }) + try { + await window.api.saveSettings(next) + } catch (err) { + console.error('[settings] setCustomThemes failed, rolling back', err) + set({ settings: prev }) + } + }, + + setHighlightRules: async (rules) => { + const prev = get().settings + const next: AppSettings = { ...prev, highlightRules: rules } + // optimistic update; the SETTINGS_CHANGED echo will re-set the same value (idempotent) + set({ settings: next }) + try { + await window.api.saveSettings(next) + } catch (err) { + console.error('[settings] setHighlightRules failed, rolling back', err) + set({ settings: prev }) + } + } +})) + +/** + * Resolve the currently active TerminalTheme (builtin or custom) from the store. + * Subscribes to settings so consumers re-render when the active theme changes. + */ +export function useResolvedTheme(): TerminalTheme { + const settings = useSettingsStore((s) => s.settings) + return getThemeById(settings.terminal.themeId, settings.customThemes) +} \ No newline at end of file diff --git a/src/renderer/src/terminal/TerminalView.tsx b/src/renderer/src/terminal/TerminalView.tsx new file mode 100644 index 0000000..4fefe2c --- /dev/null +++ b/src/renderer/src/terminal/TerminalView.tsx @@ -0,0 +1,490 @@ +import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react' +import type { ForwardRefExoticComponent, Ref } from 'react' +import { Terminal } from '@xterm/xterm' +import type { ITheme } from '@xterm/xterm' +import { FitAddon } from '@xterm/addon-fit' +import { WebglAddon } from '@xterm/addon-webgl' +import { SearchAddon } from '@xterm/addon-search' +import '@xterm/xterm/css/xterm.css' +import { getThemeById } from '@shared/theme' +import type { PtyDataEvent, PtyExitEvent } from '@shared/ipc' +import type { TerminalSettings } from '@shared/settings' +import { useSettingsStore } from '@renderer/settings/store' +import { compileRules, HighlightStream, type CompiledRule } from './highlightEngine' +import './terminal.css' + +/** Platform-modifier-based chord (Cmd on macOS, Ctrl elsewhere), plus Shift. */ +function isModShiftChord(e: KeyboardEvent, key: string): boolean { + const mod = navigator.platform.toLowerCase().includes('mac') ? e.metaKey : e.ctrlKey + return Boolean(mod) && e.shiftKey && e.key.toLowerCase() === key +} + +/** Keep xterm cursor/font weights in its accepted FontWeight format. */ +function toFontWeight(n: number): 'normal' | 'bold' | number { + if (n === 400) return 'normal' + if (n === 700) return 'bold' + return n +} + +/** Paste-risk heuristic: multi-line or unreasonably long text. */ +function isRiskyPaste(text: string): boolean { + if (text.includes('\n') || text.includes('\r')) return true + return text.length > 100 +} + +const SEARCH_DECORATIONS: NonNullable[1]>['decorations']> = { + matchBackground: '#3b8eea', + matchBorder: '#2d2d2d', + matchOverviewRuler: '#3b8eea', + activeMatchBackground: '#c77800', + activeMatchBorder: '#2d2d2d', + activeMatchColorOverviewRuler: '#c77800' +} + +export interface TerminalHandle { + focus(): void + clear(): void + /** currently attached pty session id */ + sessionId(): string +} + +export interface TerminalViewProps { + /** pty session created via window.api.createPty() */ + sessionId: string + /** called once after the pty process exits and the user closes the dead pane */ + onClose: () => void + className?: string +} + +interface SearchBarProps { + value: string + onChange: (v: string) => void + onPrev: () => void + onNext: () => void + onClose: () => void + enabled: boolean + result: string +} + +function SearchBar({ value, onChange, onPrev, onNext, onClose, enabled, result }: SearchBarProps): React.JSX.Element { + return ( +
+ onChange(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation() + if (e.key === 'Enter') onNext() + else if (e.key === 'Escape') onClose() + }} + /> + + + {result} + +
+ ) +} + +export const TerminalView: ForwardRefExoticComponent }> = + forwardRef(function TerminalViewInner({ sessionId, onClose, className }, ref) { + const settings = useSettingsStore((s) => s.settings) + + const hostRef = useRef(null) + const termRef = useRef(null) + const fitAddonRef = useRef(null) + const webglRef = useRef(null) + const searchRef = useRef(null) + const observerRef = useRef(null) + const timerRef = useRef(0) + const deadRef = useRef(false) + const searchOpenRef = useRef(false) + const searchTermRef = useRef('') + const nativePasteRef = useRef(null) + const onCloseRef = useRef(onClose) + const compiledRef = useRef([]) + const streamRef = useRef(null) + + // Compile keyword-highlight rules once per rules change; keep them in a ref so + // the (stable, single-construction) pty/term subscriptions always read the + // latest rules without triggering a terminal rebuild. Only new output is + // highlighted — the existing scrollback buffer is left untouched. + const compiledRules = useMemo(() => compileRules(settings.highlightRules), [settings.highlightRules]) + compiledRef.current = compiledRules + streamRef.current?.setRules(compiledRules) + + const [searchOpen, setSearchOpen] = useState(false) + const [searchTerm, setSearchTerm] = useState('') + const [resultInfo, setResultInfo] = useState('') + const [paste, setPaste] = useState(null) + const [dead, setDead] = useState(false) + const [exitCode, setExitCode] = useState(0) + const tSettings = settings.terminal + + const applyRenderers = useCallback((mode: TerminalSettings['rendererMode']) => { + const term = termRef.current + if (!term) return + if (webglRef.current) { + webglRef.current.dispose() + webglRef.current = null + } + if (mode === 'dom') return + try { + const webgl = new WebglAddon() + webgl.onContextLoss(() => { + // WebGL context lost -> fall back to the DOM renderer, keep the buffer. + if (!webglRef.current) return + webglRef.current.dispose() + webglRef.current = null + }) + term.loadAddon(webgl) + webglRef.current = webgl + } catch { + webglRef.current = null + } + }, []) + + const scheduleFit = useCallback(() => { + window.cancelAnimationFrame(timerRef.current) + timerRef.current = window.requestAnimationFrame(() => { + const fit = fitAddonRef.current + if (!fit) return + try { + fit.fit() + } catch { + return + } + const term = termRef.current + if (term && !deadRef.current) { + window.api.resizePty(sessionId, term.cols, term.rows) + } + }) + }, [sessionId]) + + const closeSearch = useCallback(() => { + searchOpenRef.current = false + searchRef.current?.clearDecorations() + setSearchOpen(false) + setResultInfo('') + termRef.current?.focus() + }, []) + + const search = useCallback((dir: 1 | -1) => { + const addon = searchRef.current + const q = searchTermRef.current.trim() + if (!addon || !q) return + const found = + dir === 1 + ? addon.findNext(q, { decorations: SEARCH_DECORATIONS }) + : addon.findPrevious(q, { decorations: SEARCH_DECORATIONS }) + setResultInfo(found ? '已找到' : '未找到') + }, []) + + const confirmPaste = useCallback( + (text: string) => { + const data = nativePasteRef.current ?? text + nativePasteRef.current = null + setPaste(null) + if (!deadRef.current) window.api.writePty(sessionId, data) + termRef.current?.focus() + }, + [sessionId] + ) + + const cancelPaste = useCallback(() => { + nativePasteRef.current = null + setPaste(null) + termRef.current?.focus() + }, []) + + const beginPaste = useCallback( + (text: string) => { + if (!deadRef.current && settings.terminal.pasteRiskConfirm && isRiskyPaste(text)) { + nativePasteRef.current = text + setPaste(text) + return + } + confirmPaste(text) + }, + [settings.terminal.pasteRiskConfirm, confirmPaste] + ) + + const copySelection = useCallback(async () => { + const term = termRef.current + if (!term) return + const sel = term.getSelection() + if (!sel) return + try { + await navigator.clipboard.writeText(sel) + } catch { + /* clipboard write denied */ + } + if (!settings.terminal.copyOnSelect) term.clearSelection() + }, [settings.terminal.copyOnSelect]) + + const pasteFromClipboard = useCallback(async () => { + try { + const text = await navigator.clipboard.readText() + if (text) beginPaste(text) + } catch { + /* clipboard read denied */ + } + termRef.current?.focus() + }, [beginPaste]) + + const onTerminalKey = useCallback( + (e: KeyboardEvent): boolean => { + if (e.type !== 'keydown') return true + if (searchOpenRef.current && e.key === 'Escape') { + closeSearch() + return false + } + const mod = navigator.platform.toLowerCase().includes('mac') ? e.metaKey : e.ctrlKey + if ((e.key === 'f' || e.key === 'F') && mod && !e.altKey) { + searchOpenRef.current = true + setSearchOpen(true) + return false + } + if (isModShiftChord(e, 'c')) { + void copySelection() + return false + } + if (isModShiftChord(e, 'v')) { + void pasteFromClipboard() + return false + } + return true + }, + [closeSearch, copySelection, pasteFromClipboard] + ) + + // ---- terminal instance: built once per session ---- + useLayoutEffect(() => { + const term = new Terminal({ + fontSize: tSettings.fontSize, + fontFamily: tSettings.fontFamily, + fontWeight: toFontWeight(tSettings.fontWeight), + fontWeightBold: toFontWeight(tSettings.boldFontWeight), + letterSpacing: tSettings.letterSpacing, + lineHeight: tSettings.lineHeight, + scrollback: tSettings.scrollback, + cursorBlink: tSettings.cursorBlink, + cursorStyle: tSettings.cursorStyle, + cursorInactiveStyle: tSettings.cursorInactiveStyle, + theme: getThemeById(tSettings.themeId, settings.customThemes).colors as ITheme, + allowProposedApi: true, + overviewRuler: { width: 9, showTopBorder: false, showBottomBorder: false }, + drawBoldTextInBrightColors: true, + minContrastRatio: 1, + automaticFontFallback: true, + convertEol: false, + windowsPty: { backend: 'conpty', buildNumber: 0 } + } as Terminal['options'] & object) + + term.attachCustomKeyEventHandler(onTerminalKey) + + if (!hostRef.current) return + term.open(hostRef.current) + + const fit = new FitAddon() + term.loadAddon(fit) + fitAddonRef.current = fit + + const searchAddon = new SearchAddon() + searchAddon.onDidChangeResults((ev) => { + if (searchOpenRef.current && searchTermRef.current.trim()) { + setResultInfo(`${Math.max(ev.resultIndex + 1, 0)}/${ev.resultCount}`) + } + }) + term.loadAddon(searchAddon) + searchRef.current = searchAddon + + termRef.current = term + // Rebind (e.g. template apply) must clear a death marker left by the + // previous session's exit — otherwise the pane stays under the dead mask. + deadRef.current = false + setDead(false) + setExitCode(0) + applyRenderers(tSettings.rendererMode) + scheduleFit() + + // xterm event hooks return IDisposable objects, api subscriptions return + // unsubscribe functions — keep the two shapes apart or cleanup throws. + // A fresh HighlightStream is created per session so carry data never leaks + // across a sessionId rebind (e.g. template apply). + const stream = new HighlightStream(compiledRules) + streamRef.current = stream + const writeHighlighted = (data: string) => { + const out = stream.push(data) + if (out) term.write(out) + } + const disposables: { dispose(): void }[] = [ + term.onData((data) => { + if (!deadRef.current) window.api.writePty(sessionId, data) + }), + term.onSelectionChange(() => { + if (!useSettingsStore.getState().settings.terminal.copyOnSelect) return + const sel = term.getSelection() + if (!sel) return + void navigator.clipboard.writeText(sel).catch(() => undefined) + }) + ] + // Live data is queued until the replay lands so output keeps its order. + let replayDone = false + const pending: string[] = [] + const unsubscribes: (() => void)[] = [ + window.api.onPtyData((e: PtyDataEvent) => { + if (e.id !== sessionId || deadRef.current) return + if (!replayDone) { + pending.push(e.data) + return + } + writeHighlighted(e.data) + }), + window.api.onPtyExit((e: PtyExitEvent) => { + if (e.id !== sessionId) return + deadRef.current = true + setExitCode(e.exitCode) + setDead(true) + }) + ] + // Late-subscriber catch-up: output emitted before this subscription + // (shell banner, template-apply rebind) replays from the main buffer. + void window.api + .getSessionReplay(sessionId) + .then((replay: string) => { + if (!deadRef.current && replay) writeHighlighted(replay) + }) + .catch(() => undefined) + .finally(() => { + replayDone = true + if (!deadRef.current) for (const chunk of pending) writeHighlighted(chunk) + pending.length = 0 + }) + + let observer: ResizeObserver | undefined + if (hostRef.current) { + observer = new ResizeObserver(() => scheduleFit()) + observer.observe(hostRef.current) + observerRef.current = observer + } + + return () => { + for (const unsubscribe of unsubscribes) unsubscribe() + for (const disposable of disposables) disposable.dispose() + observer?.disconnect() + observerRef.current = null + deadRef.current = true + webglRef.current?.dispose() + webglRef.current = null + // Drop the stream with the session; a fresh one is created next bind so no + // carry survives across sessions. A mid-session carry is flushed below + // (harmless: it is treated as raw text by the resync-aware xterm). + streamRef.current?.flush() + streamRef.current = null + term.dispose() + termRef.current = null + fitAddonRef.current = null + searchRef.current = null + if (timerRef.current) window.cancelAnimationFrame(timerRef.current) + timerRef.current = 0 + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- terminal constructed exactly once + }, [sessionId]) + + // ---- options reactivity (no instance rebuild) ---- + useEffect(() => { + const term = termRef.current + if (!term) return + term.options.fontSize = tSettings.fontSize + term.options.fontFamily = tSettings.fontFamily + term.options.fontWeight = toFontWeight(tSettings.fontWeight) + term.options.fontWeightBold = toFontWeight(tSettings.boldFontWeight) + term.options.letterSpacing = tSettings.letterSpacing + term.options.lineHeight = tSettings.lineHeight + term.options.cursorBlink = tSettings.cursorBlink + term.options.cursorStyle = tSettings.cursorStyle + term.options.cursorInactiveStyle = tSettings.cursorInactiveStyle + term.options.theme = { ...(getThemeById(tSettings.themeId, settings.customThemes).colors as ITheme) } + scheduleFit() + }, [tSettings, settings.customThemes, scheduleFit]) + + // ---- renderer mode changes: keep the instance, swap renderer, refit ---- + useEffect(() => { + const next = tSettings.rendererMode + if (next === 'dom') { + applyRenderers('dom') + } else if (!webglRef.current) { + applyRenderers(next) + } + scheduleFit() + }, [tSettings.rendererMode, applyRenderers, scheduleFit]) + + // keep onClose fresh for the dead mask + useEffect(() => { + onCloseRef.current = onClose + }, [onClose]) + + useImperativeHandle( + ref, + () => ({ + focus() { + termRef.current?.focus() + }, + clear() { + termRef.current?.clear() + }, + sessionId() { + return sessionId + } + }), + [sessionId] + ) + + const terminalClassName = useMemo(() => ['terminal-view', className].filter(Boolean).join(' '), [className]) + + const onSearchChange = useCallback((value: string) => { + setSearchTerm(value) + searchTermRef.current = value + }, []) + + return ( +
termRef.current?.focus()}> + {searchOpen && ( + search(-1)} + onNext={() => search(1)} + onClose={closeSearch} + enabled={searchTerm.trim().length > 0} + result={resultInfo} + /> + )} + {paste !== null && !dead && ( +
+ 检测到多行/长文本粘贴 + + +
+ )} +
+ {dead && ( +
+
进程已退出 (代码 {exitCode})
+ +
+ )} +
+
+ ) +}) \ No newline at end of file diff --git a/src/renderer/src/terminal/highlightEngine.ts b/src/renderer/src/terminal/highlightEngine.ts new file mode 100644 index 0000000..768a84f --- /dev/null +++ b/src/renderer/src/terminal/highlightEngine.ts @@ -0,0 +1,289 @@ +import type { HighlightRule } from '@shared/settings' + +/** + * Keyword highlight engine. + * + * Pure functions (no React / electron / xterm deps) that rewrite plain terminal + * text into ANSI truecolor SGR runs *before* it is handed to xterm. Escape + * sequences are never matched; only plain-text segments are, rule-by-rule in + * ascending priority order, each rule claiming spans that later (lower- + * priority) rules must not overlap. + */ + +export interface CompiledRule { + id: string + /** compiled with the 'g' flag, pattern's own flags otherwise untouched */ + regex: RegExp + /** hex fg, e.g. "#3fb950" — converted to rgb only at wrap time */ + fg: string + /** optional hex bg */ + bg?: string + /** copied from the source rule for stable priority ordering */ + priority: number +} + +type Token = { kind: 'seq' | 'text'; value: string } +type Span = { start: number; end: number; fg: string; bg?: string } + +/** ANSI escape tokens: CSI, OSC, two-char (ESC ( / ) ..), and ESC = / > . */ +const ESCAPE_RE = + /(\x1b\[[0-9;:?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>])/g + +/** Matches a single rule may inject per chunk (bomb guard). */ +const MAX_PER_RULE = 300 +/** Chunks larger than this are returned unmodified (budget guard). */ +const MAX_CHUNK = 512 * 1024 + +/** "#rrggbb" or 3-digit "#rgb" → {r,g,b}; any malformed input → white. */ +export function hexToRgb(hex: string): { r: number; g: number; b: number } { + let h = (hex || '').trim().replace(/^#/, '') + if (h.length === 3) h = h.replace(/./g, (c) => c + c) + if (!/^[0-9a-fA-F]{6}$/.test(h)) return { r: 255, g: 255, b: 255 } + const n = parseInt(h, 16) + return { r: (n >> 16) & 0xff, g: (n >> 8) & 0xff, b: n & 0xff } +} + +/** Split a chunk into escape-sequence tokens and plain-text runs. */ +export function tokenize(input: string): Token[] { + const out: Token[] = [] + let acc = '' + let i = 0 + let m: RegExpExecArray | null + // No /y — anchored alternations (e.g. a leading `^...` rule) still need to + // try matching at position 0 of each run. lastIndex is reset per call. + while ((m = ESCAPE_RE.exec(input)) !== null) { + if (m.index > i) acc += input.slice(i, m.index) + if (acc.length > 0) { + out.push({ kind: 'text', value: acc }) + acc = '' + } + out.push({ kind: 'seq', value: m[0] }) + i = m.index + m[0].length + } + if (i < input.length) acc += input.slice(i) + if (acc.length > 0) out.push({ kind: 'text', value: acc }) + return out +} + +/** Compile only enabled + safely-compilable rules, sorted by priority asc. */ +export function compileRules(rules: HighlightRule[]): CompiledRule[] { + const out: CompiledRule[] = [] + for (const rule of rules) { + if (!rule || rule.enabled === false) continue + const pattern = rule.pattern + if (typeof pattern !== 'string' || pattern.length === 0) continue + let regex: RegExp + try { + // 'g' always; case-sensitivity left to the pattern (no automatic 'i'). + regex = new RegExp(pattern, 'g') + } catch { + continue // invalid pattern → silently skip + } + const fg = typeof rule.color?.fg === 'string' ? rule.color.fg : '' + if (!/^#[0-9a-fA-F]{3}$|^#[0-9a-fA-F]{6}$/.test(fg)) continue + const bg = typeof rule.color?.bg === 'string' ? rule.color.bg : undefined + out.push({ id: rule.id, regex, fg, bg, priority: rule.priority }) + } + out.sort((a, b) => a.priority - b.priority) + return out +} + +/** Wrap one plain-text span with truecolor SGR. */ +function wrap(text: string, fgHex: string, bgHex?: string): string { + const f = hexToRgb(fgHex) + let s = `\x1b[38;2;${f.r};${f.g};${f.b}` + if (bgHex) { + const b = hexToRgb(bgHex) + s += `;48;2;${b.r};${b.g};${b.b}` + } + return `${s}m${text}\x1b[0m` +} + +/** True when [s,e) overlaps any claimed span. */ +function overlaps(claimed: Span[], s: number, e: number): boolean { + for (const c of claimed) { + if (s < c.end && e > c.start) return true + } + return false +} + +/** + * Apply all rules to a single plain-text run. Consumes from `budgets` (per-rule + * remaining match allowance shared across the whole chunk). Returns the + * SGR-injected text. With a global budget of zero for every rule the run is + * returned untouched. + */ +function applyRun(text: string, rules: CompiledRule[], budgets: number[]): string { + if (text.length === 0) return '' + const claimed: Span[] = [] + for (let ri = 0; ri < rules.length; ri++) { + const rule = rules[ri] + if (budgets[ri] <= 0) continue + rule.regex.lastIndex = 0 + let m: RegExpExecArray | null + let made = 0 + while ((m = rule.regex.exec(text)) !== null) { + const start = m.index + const end = start + m[0].length + // guard against zero-width / runaway matches + if (end <= start) { + rule.regex.lastIndex++ + continue + } + if (!overlaps(claimed, start, end)) { + claimed.push({ start, end, fg: rule.fg, bg: rule.bg }) + made++ + budgets[ri]-- + if (budgets[ri] <= 0) break + } + if (rule.regex.lastIndex === start) rule.regex.lastIndex++ // hard anti-recurse + } + } + if (claimed.length === 0) return text + claimed.sort((a, b) => a.start - b.start) + let out = '' + let pos = 0 + for (const c of claimed) { + if (c.start > pos) out += text.slice(pos, c.start) + out += wrap(text.slice(c.start, c.end), c.fg, c.bg) + pos = c.end + } + if (pos < text.length) out += text.slice(pos) + return out +} + +/** + * Rewrite `chunk` with truecolor highlights. Returns the input unchanged when + * there are no enabled rules, the chunk is empty, or it exceeds the budget. + */ +export function applyHighlights(chunk: string, rules: CompiledRule[]): string { + if (!chunk || rules.length === 0) return chunk + if (chunk.length > MAX_CHUNK) return chunk + + let budgets = rules.map(() => MAX_PER_RULE) + let usedBudget = false + let out = '' + for (const token of tokenize(chunk)) { + if (token.kind === 'seq') { + out += token.value + continue + } + // Double insurance: a residual raw ESC that somehow survives tokenization is + // never highlighted (it is a partial/unknown escape, do not corrupt it). + if (token.value.includes('\x1b')) { + out += token.value + continue + } + const before = budgets.reduce((a, b) => a + b, 0) + out += applyRun(token.value, rules, budgets) + const after = budgets.reduce((a, b) => a + b, 0) + if (after < before) usedBudget = true + } + return usedBudget ? out : chunk +} + +/** True when `seg` is a dangling, unfinished escape fragment (must not be emitted). */ +function isIncompleteEscape(seg: string): boolean { + // `seg` is expected to start at (or be) a lone ESC. + // A lone ESC — the leading half of any CSI/OSC/two-char/ST sequence. + if (seg === '\x1b') return true + // Unfinished CSI: ESC [ plus parameter bytes but no final byte in 0x40..0x7E. + if (/^\x1b\[[0-9;:?]*$/.test(seg)) return true + // Unfinished OSC: ESC ] ... with no BEL (\x07) or ST (\x1b\ ) terminator. + // (A trailing half-ST is covered by the lone-ESC case above.) + if (seg.startsWith('\x1b]') && !seg.includes('\x07') && !seg.includes('\x1b\\')) return true + return false +} + +/** Index of the last complete escape sequence's end in `input`, or -1. */ +function lastEscapeEnd(input: string): number { + let end = -1 + let m: RegExpExecArray | null + ESCAPE_RE.lastIndex = 0 + while ((m = ESCAPE_RE.exec(input)) !== null) end = m.index + m[0].length + return end +} + +/** Index of the dangling incomplete escape at the tail of `input`, or -1. */ +function findIncompleteIndex(input: string): number { + const idx = input.lastIndexOf('\x1b') + if (idx < 0) return -1 + return isIncompleteEscape(input.slice(idx)) ? idx : -1 +} + +/** Upper bound a held (unfinished) escape fragment is allowed to grow to. */ +const MAX_CARRY = 1024 + +/** + * Stateful, block-agnostic highlighter. + * + * PTY output arrives in arbitrary chunks: (a) escape sequences are routinely cut + * mid-sequence, and (b) a plain-text run can span many chunks. To make the + * streamed result identical to highlighting the whole stream at once (important + * for `^`/`$`-anchored rules), `push` only emits a safe *closed* prefix and + * holds back anything that a later chunk could continue: + * - a trailing *incomplete escape* (no final byte / OSC terminator), or + * - the final *text run* (it may extend into the next chunk). + * Held bytes are re-joined with the next chunk before highlighting, so run / + * anchor boundaries never shift. `flush` highlights whatever is still held. + */ +export class HighlightStream { + private rules: CompiledRule[] + private buffer = '' + + constructor(rules: CompiledRule[]) { + this.rules = rules + } + + /** Swap the active rules without touching any buffered content. */ + setRules(rules: CompiledRule[]): void { + this.rules = rules + } + + /** Join held bytes with `chunk`, return content safe to hand to xterm. */ + push(chunk: string): string { + this.buffer += chunk + + // (a) trailing incomplete escape -> hold it, emit the closed prefix. + const incompleteAt = findIncompleteIndex(this.buffer) + if (incompleteAt >= 0) { + const head = this.buffer.slice(0, incompleteAt) + const tail = this.buffer.slice(incompleteAt) + if (tail.length > MAX_CARRY) { + // Give up waiting on a pathologically long unterminated sequence: emit + // the fragment raw (applyHighlights skips raw \x1b in text runs). + this.buffer = '' + return applyHighlights(head, this.rules) + tail + } + this.buffer = tail + return applyHighlights(head, this.rules) + } + + // (b) no incomplete escape -> find the last *closed* escape and emit only + // through its end, holding the trailing text run for a future chunk. + const lastEnd = lastEscapeEnd(this.buffer) + if (lastEnd < 0) { + // Pure text so far, nothing is closed yet. Bounds memory: if it grew past + // the per-chunk budget just pass it through raw rather than hold forever. + if (this.buffer.length > MAX_CHUNK) { + const raw = this.buffer + this.buffer = '' + return applyHighlights(raw, this.rules) + } + return '' + } + const head = this.buffer.slice(0, lastEnd) + this.buffer = this.buffer.slice(lastEnd) + return applyHighlights(head, this.rules) + } + + /** Flush any held content (session end) — highlighted as a final run. */ + flush(): string { + const b = this.buffer + this.buffer = '' + return applyHighlights(b, this.rules) + } +} + +/** Exposed for self-tests only — not part of the public contract. */ +export const __testHooks = { tokenize, hexToRgb, isIncompleteEscape, findIncompleteIndex, lastEscapeEnd } \ No newline at end of file diff --git a/src/renderer/src/terminal/terminal.css b/src/renderer/src/terminal/terminal.css new file mode 100644 index 0000000..a598ed8 --- /dev/null +++ b/src/renderer/src/terminal/terminal.css @@ -0,0 +1,190 @@ +/* Terminal surface */ +.terminal-view { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--chrome-bg); +} + +.terminal-view .xterm { + height: 100%; +} + +/* Dark slim scrollbar for the xterm viewport — the native Windows one renders + light track + arrow buttons that look broken on the dark surface. */ +.terminal-view .xterm-viewport { + scrollbar-width: thin; + scrollbar-color: rgba(121, 121, 121, 0.45) transparent; +} + +.terminal-view .xterm-viewport::-webkit-scrollbar { + width: 10px; +} + +.terminal-view .xterm-viewport::-webkit-scrollbar-track { + background: transparent; +} + +.terminal-view .xterm-viewport::-webkit-scrollbar-thumb { + background: rgba(121, 121, 121, 0.45); + border-radius: 5px; +} + +.terminal-view .xterm-viewport::-webkit-scrollbar-thumb:hover { + background: rgba(160, 160, 160, 0.6); +} + +.terminal-view .xterm-viewport::-webkit-scrollbar-button, +.terminal-view .xterm-viewport::-webkit-scrollbar-corner { + display: none; + background: transparent; +} + +/* ---- search bar ---- */ +.term-searchbar { + display: flex; + align-items: center; + gap: 6px; + flex: none; + padding: 4px 8px; + background: var(--chrome-hover); + border-bottom: 1px solid var(--chrome-border); + font-size: 12px; + color: var(--chrome-fg); +} + +.term-searchbar-input { + flex: 1; + min-width: 0; + padding: 3px 8px; + border: 1px solid var(--chrome-border); + border-radius: 4px; + background: var(--chrome-bg); + color: var(--chrome-fg); + font: inherit; + outline: none; +} + +.term-searchbar-input:focus { + border-color: var(--chrome-accent); +} + +.term-searchbar-btn { + flex: none; + padding: 3px 6px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--chrome-fg); + cursor: pointer; + font: inherit; +} + +.term-searchbar-btn:hover:not(:disabled), +.term-searchbar-btn:focus-visible { + background: var(--chrome-hover); + color: var(--chrome-accent); +} + +.term-searchbar-btn:disabled { + opacity: 0.4; + cursor: default; +} + +.term-searchbar-count { + flex: none; + min-width: 48px; + text-align: center; + color: var(--chrome-fg); + opacity: 0.8; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +/* ---- paste risk confirm bar ---- */ +.term-pastebar { + display: flex; + align-items: center; + gap: 8px; + flex: none; + padding: 4px 8px; + background: #3a2e1a; + border-bottom: 1px solid #5c4a26; + font-size: 12px; + color: #e8d9a8; +} + +.term-pastebar-text { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.term-pastebar-btn { + flex: none; + padding: 3px 12px; + border: none; + border-radius: 4px; + cursor: pointer; + font: inherit; +} + +.term-pastebar-btn-ok { + background: var(--chrome-accent); + color: #fff; +} + +.term-pastebar-btn-ok:hover { + filter: brightness(1.1); +} + +.term-pastebar-btn-cancel { + background: transparent; + color: var(--chrome-fg); +} + +.term-pastebar-btn-cancel:hover { + background: rgba(255, 255, 255, 0.1); +} + +/* ---- terminal docked area (flex:1) ---- */ +.terminal-view-dock { + position: relative; + flex: 1 1 0; + min-height: 0; + height: 100%; + padding: 0; +} + +/* ---- dead process overlay ---- */ +.term-dead-mask { + position: absolute; + inset: 0; + z-index: 10; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + background: rgba(0, 0, 0, 0.55); + color: #fff; + font-size: 14px; +} + +.term-dead-btn { + padding: 6px 24px; + border: none; + border-radius: 4px; + background: var(--chrome-accent); + color: #fff; + font: inherit; + cursor: pointer; +} + +.term-dead-btn:hover { + filter: brightness(1.1); +} \ No newline at end of file diff --git a/src/renderer/src/theme/editor/ThemeEditor.tsx b/src/renderer/src/theme/editor/ThemeEditor.tsx new file mode 100644 index 0000000..5bbb867 --- /dev/null +++ b/src/renderer/src/theme/editor/ThemeEditor.tsx @@ -0,0 +1,201 @@ +import { useEffect, useMemo, useState } from 'react' +import { Input, Modal } from 'antd' +import type { ThemeColors, TerminalTheme } from '@shared/theme' +import { DEFAULT_DARK, getThemeById } from '@shared/theme' +import { useSettingsStore } from '../../settings/store' + +export interface ThemeEditorProps { + open: boolean + onClose: () => void + /** + * Editing mode: theme id to edit (must exist in settings.customThemes). + * Create mode: undefined — initial values are the currently active theme's colors. + */ + themeId?: string +} + +/** editable palette keys; cursorAccent is derived and intentionally not exposed */ +type ColorKey = Exclude + +interface FieldSpec { + key: ColorKey + label: string +} + +const HEADER_KEYS: FieldSpec[] = [ + { key: 'foreground', label: '前景' }, + { key: 'background', label: '背景' }, + { key: 'cursor', label: '光标' }, + { key: 'selectionBackground', label: '选区' } +] + +const ANSI_KEYS: FieldSpec[] = [ + { key: 'black', label: 'Black' }, + { key: 'red', label: 'Red' }, + { key: 'green', label: 'Green' }, + { key: 'yellow', label: 'Yellow' }, + { key: 'blue', label: 'Blue' }, + { key: 'magenta', label: 'Magenta' }, + { key: 'cyan', label: 'Cyan' }, + { key: 'white', label: 'White' }, + { key: 'brightBlack', label: 'Bright Black' }, + { key: 'brightRed', label: 'Bright Red' }, + { key: 'brightGreen', label: 'Bright Green' }, + { key: 'brightYellow', label: 'Bright Yellow' }, + { key: 'brightBlue', label: 'Bright Blue' }, + { key: 'brightMagenta', label: 'Bright Magenta' }, + { key: 'brightCyan', label: 'Bright Cyan' }, + { key: 'brightWhite', label: 'Bright White' } +] + +export function ThemeEditor({ open, onClose, themeId }: ThemeEditorProps): React.JSX.Element { + const settings = useSettingsStore((s) => s.settings) + const updateTerminal = useSettingsStore((s) => s.updateTerminal) + const setCustomThemes = useSettingsStore((s) => s.setCustomThemes) + + /** create mode = themeId undefined; edit mode must point at a custom theme */ + const isCreate = themeId === undefined + const editingTheme = useMemo(() => { + if (themeId === undefined) return undefined + return settings.customThemes.find((t) => t.id === themeId) + }, [settings.customThemes, themeId]) + + const [name, setName] = useState('') + const [colors, setColors] = useState(DEFAULT_DARK.colors) + const [saving, setSaving] = useState(false) + + // (re)initialize the form each time the modal opens + useEffect(() => { + if (!open) return + if (!isCreate) { + if (editingTheme === undefined) { + // the target theme disappeared (e.g. deleted in another window) — close the editor + onClose() + return + } + setName(editingTheme.name) + setColors({ ...editingTheme.colors }) + } else { + /** create mode: seed a fresh copy based on the currently active theme */ + const active = getThemeById(settings.terminal.themeId, settings.customThemes) + setName(active.name + '(副本)') + setColors({ ...active.colors }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + const handleSave = async (): Promise => { + const trimmed = name.trim() + if (trimmed === '') return + setSaving(true) + try { + if (isCreate) { + const theme: TerminalTheme = { + id: crypto.randomUUID(), + name: trimmed, + builtin: false, + colors: { ...colors } + } + // persist the new theme and activate it immediately + await setCustomThemes([...settings.customThemes, theme]) + await updateTerminal({ themeId: theme.id }) + onClose() + } else if (editingTheme !== undefined) { + await setCustomThemes( + settings.customThemes.map((t) => + t.id === editingTheme.id ? { ...t, name: trimmed, colors: { ...colors } } : t + ) + ) + onClose() + } + } finally { + setSaving(false) + } + } + + const setColor = (key: ColorKey, value: string): void => { + setColors((prev) => ({ ...prev, [key]: value })) + } + + return ( + +
+
+ 名称 + setName(e.target.value)} + placeholder="主题名称" + maxLength={60} + /> +
+ +
+ {HEADER_KEYS.map((spec) => ( + + ))} +
+ {ANSI_KEYS.map((spec) => ( + + ))} +
+ +
+ 预览 + +
+
+ + ) +} + +function ColorField({ + spec, + value, + onChange +}: { + spec: FieldSpec + value: string + onChange: (key: ColorKey, value: string) => void +}): React.JSX.Element { + return ( +
+ {spec.label} + onChange(spec.key, e.target.value)} + /> +
+ ) +} + +function ThemePreviewSnippet({ colors }: { colors: ThemeColors }): React.JSX.Element { + const lines: ReadonlyArray = [ + ['drwxr-xr-x 12 bill users 4096 Sep 5 10:02 node_modules', colors.brightBlue], + ['-rw-r--r-- 1 bill users 890 Sep 5 09:41 package.json', colors.brightBlack], + ['-rw-r--r-- 1 bill users 42 Sep 5 09:41 index.ts', colors.brightBlack], + ['OpenTerminal v0.1.0', colors.brightCyan] + ] + return ( +
+ {lines.map(([text, color], i) => ( +
+ {text} +
+ ))} +
+ ) +} \ No newline at end of file diff --git a/src/renderer/src/workspace/ConnectFlow.tsx b/src/renderer/src/workspace/ConnectFlow.tsx new file mode 100644 index 0000000..e4f3b7e --- /dev/null +++ b/src/renderer/src/workspace/ConnectFlow.tsx @@ -0,0 +1,175 @@ +import { useState } from 'react' +import { Button, Input, Modal } from 'antd' +import { LoadingOutlined } from '@ant-design/icons' +import type { SshConnection, SshSecretOverride } from '@shared/connections' + +/** + * Connect-time gateways for one SSH connection attempt. + * + * `phase` drives which dialog shows: + * - 'secret' → secret prompt modal (password when `askPasswordAtConnect`, + * passphrase when `askPassphraseAtConnect`). This is the only + * connect-time dialog that can be cancelled, because + * openSession cannot be aborted before it resolves. + * - 'connecting' → an uncancellable "连接中…" modal while openSession flies. + * + * Workspace moves `phase` secret→connecting when the secret is confirmed and + * back to null when the attempt settles. Each dialog is `key`-ed by the + * connection id so a new attempt never inherits previously typed secrets. + */ + +export type ConnectStage = 'idle' | 'password' | 'passphrase' | 'connecting' + +export interface ConnectFlowProps { + /** one pending double-click request; null clears the flow */ + conn: SshConnection | null + /** 'connecting' once a secret (if any) has been committed and openSession started */ + phase: 'secret' | 'connecting' + onConfirmed: (secretOverride: SshSecretOverride) => void + onCancel: () => void +} + +export function ConnectFlow({ conn, phase, onConfirmed, onCancel }: ConnectFlowProps): React.JSX.Element { + const [password, setPassword] = useState('') + const [passphrase, setPassphrase] = useState('') + + const stage: ConnectStage = + conn == null + ? 'idle' + : phase === 'connecting' + ? 'connecting' + : conn.askPasswordAtConnect + ? 'password' + : conn.askPassphraseAtConnect + ? 'passphrase' + : 'connecting' + + // A fresh connection must not inherit a previous attempt's typed value, so + // each stage modal carries a per-connection `key` that forces a new mount. + const key = conn?.id ?? 'none' + + if (conn == null) return <> + + if (stage === 'password') { + return ( + setPassword(v)} + onOk={() => onConfirmed({ password })} + onCancel={onCancel} + /> + ) + } + + if (stage === 'passphrase') { + return ( + setPassphrase(v)} + onOk={() => onConfirmed({ passphrase })} + onCancel={onCancel} + /> + ) + } + + if (stage === 'connecting') { + return ( + +
+ + + 正在连接 {conn?.name != null ? conn.name : '…'} + +
+
+ ) + } + + return <> +} + +function SecretModal({ + key, + title, + description, + placeholder, + value, + onChange, + onOk, + onCancel +}: { + /** forces per-connection mount */ + key: string + title: string + description: string + placeholder: string + value: string + onChange: (value: string) => void + onOk: () => void + onCancel: () => void +}): React.JSX.Element { + const [submitting, setSubmitting] = useState(false) + + const handleOk = (): void => { + setSubmitting(true) + onOk() + } + + return ( + +
+
{description}
+ onChange(event.target.value)} + onPressEnter={handleOk} + disabled={submitting} + /> +
+
+ ) +} + +/** + * Small "连接中…" indicator rendered in the toolbar while a connection attempt + * is in flight. + */ +export function ConnectSpinnerButton({ busy }: { busy: boolean }): React.JSX.Element | null { + return busy ? ( + + ) : null +} \ No newline at end of file diff --git a/src/renderer/src/workspace/HostKeyModal.tsx b/src/renderer/src/workspace/HostKeyModal.tsx new file mode 100644 index 0000000..5b14f29 --- /dev/null +++ b/src/renderer/src/workspace/HostKeyModal.tsx @@ -0,0 +1,60 @@ +import { Alert, Button, Modal } from 'antd' +import type { HostKeyPromptEvent } from '@shared/connections' + +/** + * Host key verification dialog. Shown one at a time (Workspace keeps a FIFO + * queue and renders the head) for the two distinct cases: + * + * - 'new' → first time we see this host: show host:port + fingerprint. + * - 'changed' → the stored fingerprint no longer matches: red warning header + * about a possible man-in-the-middle attack. + * + * The decision is returned to the caller, which commits it through + * `window.api.respondHostKey(promptId, action)`. + */ + +export interface HostKeyModalProps { + event: HostKeyPromptEvent + onDecision: (action: 'accept' | 'reject') => void +} + +export function HostKeyModal({ event, onDecision }: HostKeyModalProps): React.JSX.Element { + const isChanged = event.reason === 'changed' + const target = `${event.host}:${event.port}` + + return ( + 主机指纹已变更!
+ ) : ( + '未知主机' + ) + } + okText="接受并连接" + cancelText="拒绝" + okButtonProps={isChanged ? { danger: true } : { type: 'primary' }} + cancelButtonProps={{ danger: !isChanged }} + onOk={() => onDecision('accept')} + onCancel={() => onDecision('reject')} + width={480} + > +
+ {isChanged ? ( + + ) : ( +
首次连接该主机,尚未记录其指纹。
+ )} + +
主机:{target}
+
指纹:{event.fingerprint}
+
+
+ ) +} \ No newline at end of file diff --git a/src/renderer/src/workspace/TerminalPanel.tsx b/src/renderer/src/workspace/TerminalPanel.tsx new file mode 100644 index 0000000..4b933e7 --- /dev/null +++ b/src/renderer/src/workspace/TerminalPanel.tsx @@ -0,0 +1,43 @@ +import { useRef } from 'react' +import type { IDockviewPanelProps } from 'dockview-react' + +import { TerminalView } from '../terminal/TerminalView' + +export interface TerminalPanelExtraProps { + /** + * Notifies the workspace that a session is already dead (its process + * exited) so the panel close skips the pty kill. + */ + onSessionDead?: (sessionId: string) => void +} + +export type TerminalPanelProps = IDockviewPanelProps<{ sessionId?: string }> & + TerminalPanelExtraProps + +/** + * Dockview content component registered as `'terminal'`. + * + * Binds the pty `sessionId` param to . When the underlying + * process exits, TerminalView calls `onClose` and we close the panel — + * marking the session dead first so onDidRemovePanel won't try to kill an + * already-exited process. + */ +export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps): React.JSX.Element { + const sessionId = params.sessionId ?? '' + const closedRef = useRef(false) + + return ( +
+ { + if (closedRef.current) return + closedRef.current = true + onSessionDead?.(sessionId) + api.close() + }} + /> +
+ ) +} \ No newline at end of file diff --git a/src/renderer/src/workspace/TerminalTab.tsx b/src/renderer/src/workspace/TerminalTab.tsx new file mode 100644 index 0000000..2003865 --- /dev/null +++ b/src/renderer/src/workspace/TerminalTab.tsx @@ -0,0 +1,218 @@ +import { useEffect, useState } from 'react' +import { Button, Dropdown, Input, Modal, Popconfirm, Space } from 'antd' +import type { MenuProps } from 'antd' +import type { IDockviewPanelHeaderProps } from 'dockview-react' + +import type { LayoutMeta } from '@shared/ipc' + +export type TerminalTabProps = IDockviewPanelHeaderProps<{ sessionId?: string }> + +/** + * Custom tab renderer for terminal panels: default dockview tab look (title + + * close glyph) with an antd context menu: + * 关闭 / 关闭其他 / 关闭右侧 + * + * All menu actions close panels directly through `api.close()`. The + * workspace's `onDidRemovePanel` handles the pty kill, so nothing leaks. + */ +export function TerminalTab({ api }: TerminalTabProps): React.JSX.Element { + const panel = api.group // bound panel via the panel api + const [title, setTitle] = useState(api.title) + const selfIndex = panel.panels.findIndex((p) => p.api === api) + + // Keep the tab title live (dockview re-renders tab components only on + // params changes; title updates arrive through this event). + useEffect(() => { + const disposable = api.onDidTitleChange((event) => { + setTitle(event.title) + }) + return () => disposable.dispose() + }, [api]) + + const closePanel = (target: typeof panel.panels[number]): void => { + target.api.close() + } + + const menu: MenuProps = { + items: [ + { key: 'close', label: '关闭' }, + { key: 'closeOthers', label: '关闭其他', disabled: panel.panels.length <= 1 }, + { key: 'closeRight', label: '关闭右侧', disabled: selfIndex === -1 || selfIndex === panel.panels.length - 1 } + ], + onClick: ({ key }) => { + if (key === 'close') { + api.close() + } else if (key === 'closeOthers') { + for (const sibling of panel.panels) { + if (sibling.api !== api) closePanel(sibling) + } + } else if (key === 'closeRight') { + for (let i = selfIndex + 1; i < panel.panels.length; i++) { + closePanel(panel.panels[i]) + } + } + } + } + + return ( + +
+ {title} + +
+
+ ) +} + +function CloseGlyph(): React.JSX.Element { + return ( + + ) +} + +export function SaveTemplateModal({ + open, + onCancel, + onConfirm +}: { + open: boolean + onCancel: () => void + onConfirm: (name: string) => Promise +}): React.JSX.Element { + const [name, setName] = useState('') + const [saving, setSaving] = useState(false) + + const handleOk = async (): Promise => { + const trimmed = name.trim() + if (!trimmed) return + setSaving(true) + try { + await onConfirm(trimmed) + setName('') + onCancel() + } finally { + setSaving(false) + } + } + + return ( + + setName(event.target.value)} + onPressEnter={handleOk} + /> + + ) +} + +export function ApplyTemplateModal({ + open, + onClose, + onApply, + onDelete, + list +}: { + open: boolean + onClose: () => void + onApply: (meta: LayoutMeta) => Promise + onDelete: (meta: LayoutMeta) => Promise + list: () => Promise +}): React.JSX.Element { + const [items, setItems] = useState([]) + const [busyId, setBusyId] = useState(null) + + const refresh = async (): Promise => { + setItems(await list()) + } + + useEffect(() => { + if (open) void refresh() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + const handleApply = async (meta: LayoutMeta): Promise => { + setBusyId(meta.id) + try { + await onApply(meta) + onClose() + } finally { + setBusyId(null) + } + } + + const handleDelete = async (meta: LayoutMeta): Promise => { + setBusyId(meta.id) + try { + await onDelete(meta) + await refresh() + } finally { + setBusyId(null) + } + } + + return ( + + {items.length === 0 ? ( +
暂无已保存的模板
+ ) : ( +
    + {items.map((meta) => ( +
  • +
    +
    {meta.name}
    +
    + {new Date(meta.createdAt).toLocaleString()} +
    +
    + + + void handleDelete(meta)} + > + + + +
  • + ))} +
+ )} +
+ ) +} \ No newline at end of file diff --git a/src/renderer/src/workspace/Workspace.tsx b/src/renderer/src/workspace/Workspace.tsx new file mode 100644 index 0000000..43ea67e --- /dev/null +++ b/src/renderer/src/workspace/Workspace.tsx @@ -0,0 +1,536 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { ReactNode } from 'react' +import { App, Button, Dropdown, Tooltip } from 'antd' +import type { MenuProps } from 'antd' +import { + ColumnHeightOutlined, + ColumnWidthOutlined, + LayoutOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, + PlusOutlined, + SettingOutlined +} from '@ant-design/icons' +import { DockviewReact } from 'dockview-react' +import type { + DockviewApi, + DockviewGroupPanel, + DockviewReadyEvent, + IDockviewHeaderActionsProps, + IDockviewPanel, + IDockviewPanelHeaderProps, + IDockviewPanelProps +} from 'dockview-react' +import 'dockview-react/dist/styles/dockview.css' + +import type { HostKeyPromptEvent, SshConnection, SshSecretOverride } from '@shared/connections' +import { ConnectionSidebar } from '../connections/ConnectionSidebar' +import type { ConnectionSidebarHandle } from '../connections/ConnectionSidebar' +import { ConnectFlow } from './ConnectFlow' +import { HostKeyModal } from './HostKeyModal' +import { TerminalPanel } from './TerminalPanel' +import { ApplyTemplateModal, SaveTemplateModal, TerminalTab } from './TerminalTab' +import './workspace.css' + +export interface WorkspaceProps { + onOpenSettings: () => void +} + +interface LayoutMetaLike { + id: string + name: string + createdAt: number +} + +type TerminalParams = { sessionId?: string } + +const TERMINAL_COMPONENT = 'terminal' +const TERMINAL_TAB_COMPONENT = 'terminal-tab' + +/** + * Per-workspace monotonic title counter for "终端 N". Lives on `window` so + * dev hot-reloads (which reset module state) don't restart the numbering and + * produce duplicate tab titles. + */ +const titleSeqHolder = window as unknown as { __otTitleSeq?: number } +function nextTerminalTitle(): string { + titleSeqHolder.__otTitleSeq = (titleSeqHolder.__otTitleSeq ?? 0) + 1 + return `终端 ${titleSeqHolder.__otTitleSeq}` +} + +function sessionIdOf(panel: IDockviewPanel): string | undefined { + return (panel.params as TerminalParams | undefined)?.sessionId +} + +/** + * Workspace owns the dockview layout + terminal session lifecycle. + * + * Sessions: panels carry a `params.sessionId`. Killing a session is + * idempotent through `deadSessionsRef` so a session is killed at most once — + * either up-front (TerminalView `onClose` after the process exits) or when + * its panel is removed. + */ +export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX.Element { + const { message } = App.useApp() + const apiRef = useRef(undefined) + /** Panel used as the anchor for split placements. */ + const activePanelRef = useRef(undefined) + + /** Sessions we already asked to kill — kill once, never twice. */ + const deadSessionsRef = useRef>(new Set()) + + const [panelCount, setPanelCount] = useState(0) + const [saveOpen, setSaveOpen] = useState(false) + const [applyOpen, setApplyOpen] = useState(false) + + /** [侧栏] toggle — hidden by default; the choice persists per device. */ + const [sidebarOpen, setSidebarOpen] = useState(() => + window.localStorage.getItem('ot.sidebarOpen') === '1' + ) + const toggleSidebar = useCallback((): void => { + setSidebarOpen((prev) => { + const next = !prev + window.localStorage.setItem('ot.sidebarOpen', next ? '1' : '0') + return next + }) + }, []) + /** one pending SSH connection request (null = idle). */ + const [requestedConn, setRequestedConn] = useState(null) + /** true while openSession is in flight — drives the "连接中…" modal. */ + const [connecting, setConnecting] = useState(false) + /** FIFO of pending host-key confirmations — render the head only. */ + const [hostKeyQueue, setHostKeyQueue] = useState([]) + + /** B's sidebar handle; refresh() after a successful SSH connect */ + const sidebarRef = useRef(null) + + /** Idempotently kill a pty session. */ + const killSession = useCallback((sessionId: string | undefined): void => { + if (!sessionId) return + if (deadSessionsRef.current.has(sessionId)) return + deadSessionsRef.current.add(sessionId) + window.api.killPty(sessionId) + }, []) + + /** Mark a session dead ahead of a panel close so the cleanup skips it. */ + const markSessionDead = useCallback((sessionId: string): void => { + deadSessionsRef.current.add(sessionId) + }, []) + + const createSession = useCallback(async (): Promise => { + const result = await window.api.createPty() + return result.id + }, []) + + const handleReady = useCallback((event: DockviewReadyEvent): void => { + apiRef.current = event.api + setPanelCount(event.api.panels.length) + }, []) + + /** + * Subscribe to dockview lifecycle events. Installed once after the first + * `onReady`; `apiRef.current` is set by then. + */ + useEffect(() => { + const current = apiRef.current + if (!current) return + + const disposables = [ + current.onDidRemovePanel((panel: IDockviewPanel) => { + // Panel vanished (tab close / context menu / layout clear): kill its + // session unless it was already marked dead (process already exited). + killSession(sessionIdOf(panel)) + }), + current.onDidActivePanelChange((event) => { + activePanelRef.current = event.panel ?? undefined + }), + current.onDidLayoutChange(() => { + setPanelCount(current.panels.length) + }) + ] + return () => { + for (const disposable of disposables) disposable.dispose() + } + }, [killSession]) + + /** Kill remaining sessions on window close. */ + useEffect(() => { + const handler = (): void => { + const current = apiRef.current + if (!current) return + for (const panel of current.panels) { + killSession(sessionIdOf(panel)) + } + } + window.addEventListener('beforeunload', handler) + return () => window.removeEventListener('beforeunload', handler) + }, [killSession]) + + /** + * Host-key verification subscription. Events stream in a FIFO queue; each + * workspace shows a single modal over its head and pops on every decision. + */ + useEffect(() => { + return window.api.onHostKeyPrompt((event: HostKeyPromptEvent) => { + setHostKeyQueue((prev) => [...prev, event]) + }) + }, []) + + const hostKeyHead = hostKeyQueue.length > 0 ? hostKeyQueue[0] : null + + const handleHostKeyDecision = useCallback((action: 'accept' | 'reject'): void => { + // The queue head's decision is final (accept/reject both consume the prompt). + setHostKeyQueue((prev) => { + const head = prev[0] + if (!head) return prev + window.api.respondHostKey(head.promptId, action) + return prev.slice(1) + }) + }, []) + + /** Add a terminal panel; `direction` splits relative to the active panel. */ + const addTerminal = useCallback( + async (direction?: 'right' | 'below'): Promise => { + const current = apiRef.current + if (!current) return + const sessionId = await createSession() + const reference = activePanelRef.current + + current.addPanel({ + id: sessionId, + component: TERMINAL_COMPONENT, + tabComponent: TERMINAL_TAB_COMPONENT, + title: nextTerminalTitle(), + params: { sessionId }, + position: + reference && direction + ? { referencePanel: reference.id, direction } + : undefined + }) + setPanelCount(current.panels.length) + }, + [createSession] + ) + + /** + * SSH connection pipeline. openSession resolves once the session is ready to + * stream; `connectionId` + any staged `secretOverride` are the only inputs + * the main process needs — no extra IPC channel. + */ + const connect = useCallback( + async (conn: SshConnection, secretOverride: SshSecretOverride): Promise => { + setConnecting(true) + try { + const result = await window.api.openSession({ + kind: 'ssh', + connectionId: conn.id, + secretOverride + }) + const current = apiRef.current + if (!current) return + current.addPanel({ + id: result.id, + component: TERMINAL_COMPONENT, + tabComponent: TERMINAL_TAB_COMPONENT, + title: conn.name, + params: { sessionId: result.id } + }) + setPanelCount(current.panels.length) + // B's sidebar refreshes lastConnectedAt on connect. + void sidebarRef.current?.refresh() + } catch (error) { + message.error(typeof error === 'string' ? error : 'SSH 连接失败') + } finally { + setConnecting(false) + setRequestedConn(null) + } + }, + [message] + ) + + const handleConnectRequest = useCallback((conn: SshConnection): void => { + setRequestedConn(conn) + }, []) + + const handleConnectCancel = useCallback((): void => { + setRequestedConn(null) + }, []) + + const handleSecretConfirmed = useCallback( + (secretOverride: SshSecretOverride): void => { + const conn = requestedConn + if (!conn) return + // Swap the secret modal for the connecting modal and fire openSession. + setRequestedConn(conn) + void connect(conn, secretOverride) + }, + [requestedConn, connect] + ) + + const handleNewTerminal = useCallback((): void => { + void addTerminal() + }, [addTerminal]) + + /** First run: open one terminal so the workspace is never empty on launch. */ + const bootstrappedRef = useRef(false) + useEffect(() => { + if (bootstrappedRef.current) return + bootstrappedRef.current = true + const current = apiRef.current + if (current && current.panels.length === 0) void addTerminal() + }, [addTerminal]) + + const handleSplit = useCallback( + (direction: 'right' | 'below'): void => { + void addTerminal(direction) + }, + [addTerminal] + ) + + const handleSaveTemplate = useCallback(async (name: string): Promise => { + const current = apiRef.current + if (!current) return + await window.api.saveLayout( + { + id: window.crypto.randomUUID(), + name, + createdAt: Date.now() + }, + JSON.stringify(current.toJSON()) + ) + }, []) + + const handleApplyTemplate = useCallback( + async (meta: LayoutMetaLike): Promise => { + const current = apiRef.current + if (!current) return + const raw = await window.api.getLayout(meta.id) + if (raw == null) return + + // Sessions currently bound to panels — the template load replaces them. + const previousSessions = new Set() + for (const panel of current.panels) { + const sid = sessionIdOf(panel) + if (sid) previousSessions.add(sid) + } + + let serialized: unknown + try { + serialized = JSON.parse(raw) + } catch { + return + } + // JSON.parse returns `any`; validate above, cast one level down. + current.fromJSON(serialized as never) + + // Every restored terminal panel needs a fresh pty bound to its params. + for (const panel of current.panels) { + const id = sessionIdOf(panel) + if (id && panel.view.contentComponent === TERMINAL_COMPONENT) { + const newSessionId = (await window.api.createPty()).id + panel.api.updateParameters({ sessionId: newSessionId }) + deadSessionsRef.current.delete(id) + } + } + + // Old sessions are unreachable after the layout swap — kill them. + for (const sid of previousSessions) killSession(sid) + setPanelCount(current.panels.length) + activePanelRef.current = current.activePanel + }, + [killSession] + ) + + const handleDeleteTemplate = useCallback(async (meta: LayoutMetaLike): Promise => { + await window.api.deleteLayout(meta.id) + }, []) + + const layoutMenu: MenuProps = { + items: [ + { key: 'save', label: '保存为模板' }, + { key: 'apply', label: '应用模板' } + ], + onClick: ({ key }) => { + if (key === 'save') setSaveOpen(true) + else if (key === 'apply') setApplyOpen(true) + } + } + + return ( +
+ +
+ {sidebarOpen && ( + + )} +
+ ) => ( + + ) + }} + tabComponents={{ + [TERMINAL_TAB_COMPONENT]: (props: IDockviewPanelHeaderProps) => ( + + ) + }} + rightHeaderActionsComponent={GroupAddButton} + onReady={handleReady} + theme={{ name: 'Abyss', className: 'dockview-theme-abyss', colorScheme: 'dark' }} + /> + {panelCount === 0 && ( +
+
+
暂无终端
+ +
+
+ )} +
+
+ + {/* SSH connect flow: secret prompt (cancellable) → connecting modal (not). */} + {requestedConn != null && ( + + )} + + {/* Host-key verification — head of the FIFO queue only. */} + {hostKeyHead != null && ( + + )} + + setSaveOpen(false)} + onConfirm={handleSaveTemplate} + /> + setApplyOpen(false)} + onApply={handleApplyTemplate} + onDelete={handleDeleteTemplate} + list={() => window.api.listLayouts()} + /> +
+ ) +} + +/** + * "+" rendered at the end of every group's tab strip: creates a terminal as a + * new tab inside THAT group (XTerminal-style per-pane tab addition). + */ +function GroupAddButton({ + containerApi, + group +}: IDockviewHeaderActionsProps): React.JSX.Element { + const handleAdd = (): void => { + void (async (): Promise => { + const result = await window.api.createPty() + containerApi.addPanel({ + id: result.id, + component: TERMINAL_COMPONENT, + tabComponent: TERMINAL_TAB_COMPONENT, + title: nextTerminalTitle(), + params: { sessionId: result.id }, + position: { referenceGroup: group.id, direction: 'within' } + }) + })() + } + + return ( + + ) +} + +/** + * XTerminal-style slim icon rail on the left edge — replaces the top toolbar. + * Top: workspace actions; bottom: settings. + */ +function IconRail({ + onOpenSettings, + onNewTerminal, + onSplit, + onToggleSidebar, + sidebarOpen, + connecting, + layoutMenu +}: { + onOpenSettings: () => void + onNewTerminal: () => void + onSplit: (direction: 'right' | 'below') => void + onToggleSidebar: () => void + sidebarOpen: boolean + connecting: boolean + layoutMenu: MenuProps +}): React.JSX.Element { + return ( +
+ : } + onClick={onToggleSidebar} + /> +
+ } onClick={onNewTerminal} /> + } onClick={() => onSplit('right')} /> + } onClick={() => onSplit('below')} /> + + } /> + +
+ {connecting && ( + + + + )} + } onClick={onOpenSettings} /> +
+ ) +} + +function RailButton({ + title, + icon, + onClick +}: { + title: string + icon: ReactNode + onClick?: () => void +}): React.JSX.Element { + return ( + + + + ) +} \ No newline at end of file diff --git a/src/renderer/src/workspace/workspace.css b/src/renderer/src/workspace/workspace.css new file mode 100644 index 0000000..f266674 --- /dev/null +++ b/src/renderer/src/workspace/workspace.css @@ -0,0 +1,351 @@ +/* Workspace chrome: top toolbar + dockview layout area. */ + +.workspace-root { + display: flex; + flex-direction: row; + height: 100%; + background: var(--chrome-bg); +} + +/* ---- left icon rail (XTerminal-style collapsed menu) ---- */ +.workspace-rail { + display: flex; + flex-direction: column; + align-items: center; + flex: 0 0 40px; + width: 40px; + padding: 8px 0; + gap: 6px; + background: var(--chrome-bg); + border-right: 1px solid var(--chrome-border); + box-sizing: border-box; +} + +.workspace-rail-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 5px; + background: transparent; + color: #9a9a9a; + font-size: 15px; + cursor: pointer; +} + +.workspace-rail-btn:hover { + background: var(--chrome-hover); + color: #ffffff; +} + +.workspace-rail-divider { + width: 20px; + height: 1px; + background: var(--chrome-border); + margin: 2px 0; +} + +.workspace-rail-spacer { + flex: 1 1 auto; +} + +.workspace-rail-busy { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--chrome-accent); + animation: workspace-rail-pulse 1s ease-in-out infinite; +} + +@keyframes workspace-rail-pulse { + 0%, + 100% { + opacity: 0.35; + } + 50% { + opacity: 1; + } +} + +/* ---- main row: sidebar + dockview ---- */ +.workspace-body { + display: flex; + flex-direction: row; + flex: 1 1 auto; + min-width: 0; + min-height: 0; +} + +/* B's sidebar owns the aside chrome; the fallback width below only applies if + the className isn't passed — it is, so the 240px lives in connections.css. */ +.workspace-body .connections-sidebar { + height: 100%; +} + +/* ---- dockview area ---- */ +.workspace-dockview { + position: relative; + flex: 1 1 auto; + min-height: 0; +} + +.workspace-dockview .dockview-theme-abyss, +.workspace-dockview .dv-dockview { + width: 100%; + height: 100%; +} + +/* Align terminal chrome with the app chrome. */ +.dockview-theme-abyss { + --dv-group-view-background-color: var(--chrome-bg); + --dv-tabs-and-actions-container-background-color: #10141a; + --dv-tabs-and-actions-container-height: 32px; + --dv-tab-divider-color: transparent; +} + +/* ---- tab polish: rounded pills, quiet inactive, green active ---- */ +.workspace-dockview .dv-tabs-and-actions-container { + padding: 3px 4px 0; + box-sizing: border-box; +} + +.workspace-dockview .dv-tab { + border-radius: 6px; + padding: 0 9px; + margin-right: 2px; + background: transparent; + color: #8a8a8a; + transition: background-color 0.12s ease, color 0.12s ease; +} + +.workspace-dockview .dv-tab.dv-inactive-tab:hover { + background: rgba(255, 255, 255, 0.06); + color: #d0d0d0; +} + +/* XTerminal-style active tab: green outline + tinted bg + green title. */ +.workspace-dockview .dv-tab.dv-active-tab { + background: rgba(63, 185, 80, 0.08); + box-shadow: inset 0 0 0 1px rgba(63, 185, 80, 0.7); + color: #3fb950; +} + +.workspace-dockview .dv-tab.dv-active-tab:hover { + color: #46c96a; +} + +.workspace-dockview .dv-tab .workspace-terminal-tab { + gap: 6px; +} + +.workspace-dockview .dv-tab .workspace-terminal-tab-title { + font-size: 12.5px; + line-height: 1; +} + +.workspace-dockview .dv-tab .workspace-terminal-tab-close { + width: 18px; + height: 18px; + opacity: 0.6; +} + +.workspace-dockview .dv-tab .workspace-terminal-tab-close:hover { + opacity: 1; +} + +/* ---- empty state overlay ---- */ +.workspace-empty-overlay { + position: absolute; + inset: 0; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + background: var(--chrome-bg); + pointer-events: none; +} + +.workspace-empty-inner { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + pointer-events: auto; +} + +.workspace-empty-title { + color: #8a8a8a; + font-size: 14px; + user-select: none; +} + +/* ---- terminal panel ---- */ +.workspace-terminal-panel { + width: 100%; + height: 100%; +} + +.workspace-terminal-view { + width: 100%; + height: 100%; +} + +/* ---- per-group "+" right after the last tab ---- + Push the flexible drag-target void to the far end so the right-actions + slot (the + button) hugs the tabs, XTerminal-style. */ +.workspace-dockview .dv-void-container { + order: 3; +} + +.workspace-tabbar-add { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + margin: 0 6px; + padding: 0; + border: none; + border-radius: 3px; + background: transparent; + color: #8a8a8a; + font-size: 11px; + cursor: pointer; +} + +.workspace-tabbar-add:hover { + background: var(--dv-icon-hover-background-color, rgba(90, 93, 94, 0.31)); + color: #ffffff; +} + +/* ---- custom tab (title + close + context menu) ---- */ +.workspace-terminal-tab { + position: relative; + height: 100%; + width: 100%; + display: flex; + align-items: center; + white-space: nowrap; + text-overflow: ellipsis; + gap: 4px; +} + +.workspace-terminal-tab-title { + flex-grow: 1; + overflow: hidden; + text-overflow: ellipsis; +} + +.workspace-terminal-tab-close { + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + border: none; + background: none; + color: inherit; + font: inherit; + cursor: pointer; + border-radius: 2px; +} + +.workspace-terminal-tab-close:hover { + background: var(--dv-icon-hover-background-color, rgba(90, 93, 94, 0.31)); +} + +/* ---- template modals ---- */ +.workspace-template-list { + list-style: none; + margin: 0; + padding: 0; + max-height: 60vh; + overflow-y: auto; +} + +.workspace-template-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 4px; + border-bottom: 1px solid var(--chrome-border); +} + +.workspace-template-item:last-child { + border-bottom: none; +} + +.workspace-template-meta { + min-width: 0; +} + +.workspace-template-name { + color: var(--chrome-fg); + font-size: 13px; +} + +.workspace-template-time { + color: #8a8a8a; + font-size: 12px; + margin-top: 2px; +} + +.workspace-template-empty { + padding: 24px 0; + text-align: center; + color: #8a8a8a; +} + +/* ---- SSH connect flow modals ---- */ +.connect-flow-connecting { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 0; +} + +.connect-flow-connecting-text { + color: var(--chrome-fg); + font-size: 13px; +} + +.connect-flow-secret { + display: flex; + flex-direction: column; + gap: 10px; +} + +.connect-flow-secret-target { + color: var(--chrome-fg); + font-size: 12px; + word-break: break-all; +} + +/* ---- host key verification modal ---- */ +.hostkey-body { + display: flex; + flex-direction: column; + gap: 12px; +} + +.hostkey-changed-title { + color: #ff4d4f; + font-weight: 600; +} + +.hostkey-desc { + color: var(--chrome-fg); +} + +.hostkey-target, +.hostkey-fingerprint { + font-family: 'Cascadia Mono', 'Consolas', 'SFMono-Regular', monospace; + font-size: 12px; + color: var(--chrome-fg); + word-break: break-all; + user-select: text; +} \ No newline at end of file diff --git a/src/shared/api.ts b/src/shared/api.ts new file mode 100644 index 0000000..105896b --- /dev/null +++ b/src/shared/api.ts @@ -0,0 +1,46 @@ +import type { AppInfo, LayoutMeta, PtyCreateOptions, PtyCreateResult, PtyDataEvent, PtyExitEvent, SessionOpenResult } from './ipc' +import type { AppSettings } from './settings' +import type { HostKeyAction, HostKeyPromptEvent, SessionOpenOptions, SshConnection, SshConnectionInput } from './connections' + +/** + * The single API surface exposed on `window.api` by the preload script. + * Main process implements the handlers; renderer consumes this contract. + */ +export interface AppApi { + appInfo(): Promise + + // ---- sessions (pty data plane is shared by local and ssh sessions) ---- + createPty(opts?: PtyCreateOptions): Promise + /** open a local or ssh session; resolves once the session is ready to stream */ + openSession(opts: SessionOpenOptions): Promise + /** output a session produced before this renderer subscribed (capped ring buffer) */ + getSessionReplay(id: string): Promise + writePty(id: string, data: string): void + resizePty(id: string, cols: number, rows: number): void + killPty(id: string): void + /** subscribe to all session output; returns unsubscribe */ + onPtyData(cb: (e: PtyDataEvent) => void): () => void + onPtyExit(cb: (e: PtyExitEvent) => void): () => void + + // ---- ssh connections ---- + listConnections(): Promise + saveConnection(input: SshConnectionInput): Promise + deleteConnection(id: string): Promise + onHostKeyPrompt(cb: (e: HostKeyPromptEvent) => void): () => void + respondHostKey(promptId: string, action: HostKeyAction): void + + // ---- settings ---- + getSettings(): Promise + saveSettings(next: AppSettings): Promise + onSettingsChanged(cb: (s: AppSettings) => void): () => void + + // ---- fonts ---- + listFonts(): Promise + + // ---- layout templates ---- + listLayouts(): Promise + getLayout(id: string): Promise + /** json = serialized dockview layout (DockviewApi.toJSON()) */ + saveLayout(meta: LayoutMeta, json: string): Promise + deleteLayout(id: string): Promise +} diff --git a/src/shared/connections.ts b/src/shared/connections.ts new file mode 100644 index 0000000..0c873c4 --- /dev/null +++ b/src/shared/connections.ts @@ -0,0 +1,86 @@ +import type { LayoutMeta } from './ipc' + +/** + * SSH connection model. Secrets (password / key content / passphrase) are only + * ever persisted encrypted (Electron safeStorage, base64) inside the main + * process. The renderer receives connections with secret fields stripped and + * replaced by the `savedAuth` flags, so plaintext secrets never cross the + * contextBridge. + */ + +export type SshAuthMethod = 'password' | 'privateKey' | 'agent' + +export interface SshSavedAuthFlags { + hasPassword: boolean + hasKeyContent: boolean + hasPassphrase: boolean +} + +export interface SshConnection { + id: string + name: string + /** optional group for the bookmark tree; undefined = root */ + group?: string + host: string + port: number + username: string + auth: SshAuthMethod + /** prompt for password in a dialog at connect time instead of using the stored one */ + askPasswordAtConnect: boolean + /** prompt for key passphrase at connect time */ + askPassphraseAtConnect: boolean + keyPath?: string + /** seconds between keepalive packets; 0 disables */ + keepaliveIntervalSec: number + createdAt: number + lastConnectedAt?: number + savedAuth: SshSavedAuthFlags +} + +/** Renderer-supplied connection data on save; secrets here are plaintext on purpose (one-way, encrypted before persisting). */ +export interface SshConnectionInput { + id?: string + name: string + group?: string + host: string + port: number + username: string + auth: SshAuthMethod + askPasswordAtConnect: boolean + askPassphraseAtConnect: boolean + keyPath?: string + keepaliveIntervalSec: number + /** plain password to store; omit to keep the previously stored one */ + password?: string + /** private key content to store inline; omit to keep the previously stored one */ + keyContent?: string + passphrase?: string +} + +/** Secrets typed at connect time when ask*AtConnect is set. */ +export interface SshSecretOverride { + password?: string + passphrase?: string +} + +export interface SessionOpenOptions { + kind: 'local' | 'ssh' + /** ssh only */ + connectionId?: string + secretOverride?: SshSecretOverride +} + +export type HostKeyAction = 'accept' | 'reject' + +export interface HostKeyPromptEvent { + promptId: string + host: string + port: number + /** sha256 fingerprint, base64 (no padding), ssh "SHA256:..." style */ + fingerprint: string + reason: 'new' | 'changed' +} + +/** Reuse LayoutMeta shape for layout templates only; groups are a plain string field on SshConnection. */ +export type ConnectionMeta = Pick +export type { LayoutMeta } diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts new file mode 100644 index 0000000..e56d679 --- /dev/null +++ b/src/shared/ipc.ts @@ -0,0 +1,95 @@ +/** + * IPC channel names + payload contracts shared between main / preload / renderer. + * DO NOT rename channels or change payload shapes without updating all consumers. + */ + +export const Ipc = { + // ---- PTY session lifecycle ---- + // NOTE: since M2 these channels are session-generic: `id` addresses both + // local pty sessions and ssh sessions; data plane is identical. + PTY_CREATE: 'pty:create', + PTY_WRITE: 'pty:write', + PTY_RESIZE: 'pty:resize', + PTY_KILL: 'pty:kill', + /** main -> renderer broadcast: { id, data } */ + PTY_DATA: 'pty:data', + /** main -> renderer broadcast: { id, exitCode } */ + PTY_EXIT: 'pty:exit', + + // ---- settings ---- + SETTINGS_GET: 'settings:get', + SETTINGS_SET: 'settings:set', + /** main -> renderer broadcast after any save */ + SETTINGS_CHANGED: 'settings:changed', + + // ---- fonts ---- + FONTS_LIST: 'fonts:list', + + // ---- layouts ---- + LAYOUTS_LIST: 'layouts:list', + LAYOUTS_GET: 'layouts:get', + LAYOUTS_SAVE: 'layouts:save', + LAYOUTS_DELETE: 'layouts:delete', + + // ---- sessions (openSession unifies local pty and ssh) ---- + SESSION_OPEN: 'session:open', + /** main keeps a short replay buffer per session so late subscribers catch up */ + SESSION_REPLAY: 'session:replay', + + // ---- ssh connections (bookmarks) ---- + CONNECTIONS_LIST: 'connections:list', + CONNECTIONS_SAVE: 'connections:save', + CONNECTIONS_DELETE: 'connections:delete', + + // ---- host key verification ---- + /** main -> renderer: needs user decision before connect proceeds */ + HOSTKEY_PROMPT: 'hostkey:prompt', + HOSTKEY_RESPOND: 'hostkey:respond', + + // ---- misc ---- + APP_INFO: 'app:info' +} as const + +export interface PtyCreateOptions { + cwd?: string + /** executable; omit for platform default shell */ + shell?: string + env?: Record +} + +export interface PtyCreateResult { + id: string + shell: string + cwd: string +} + +export interface PtyDataEvent { + id: string + data: string +} + +export interface PtyExitEvent { + id: string + exitCode: number +} + +export interface AppInfo { + platform: NodeJS.Platform | string + appVersion: string + homeDir: string +} + +export interface LayoutMeta { + id: string + name: string + createdAt: number +} + +export interface SessionOpenResult { + id: string +} + +export interface HostKeyResponse { + promptId: string + action: 'accept' | 'reject' +} diff --git a/src/shared/settings.ts b/src/shared/settings.ts new file mode 100644 index 0000000..c93b09e --- /dev/null +++ b/src/shared/settings.ts @@ -0,0 +1,90 @@ +import type { TerminalTheme } from './theme' + +/** One terminal keyword-highlight rule (regex → ANSI color injection). */ +export interface HighlightRule { + id: string + /** regex source; compiled with 'g' flag at use site */ + pattern: string + enabled: boolean + /** 1..100, lower runs first and claims its spans first */ + priority: number + /** truecolor hex (#rrggbb); fg required, bg optional */ + color: { fg: string; bg?: string } + note?: string + /** part of the factory preset set (restored by 恢复默认) */ + builtin?: boolean +} + +export interface TerminalSettings { + fontFamily: string + fontSize: number + /** 100..900 */ + fontWeight: number + /** 100..900 */ + boldFontWeight: number + letterSpacing: number + lineHeight: number + scrollback: number + cursorBlink: boolean + cursorStyle: 'bar' | 'block' | 'underline' + cursorInactiveStyle: 'outline' | 'block' | 'bar' | 'underline' | 'none' + themeId: string + rendererMode: 'auto' | 'webgl' | 'dom' + autoWrap: boolean + copyOnSelect: boolean + /** confirm before pasting multi-line / suspicious text */ + pasteRiskConfirm: boolean +} + +export interface AppSettings { + terminal: TerminalSettings + /** user-created themes; builtin themes are immutable and live in shared/theme.ts */ + customThemes: TerminalTheme[] + /** terminal keyword highlight rules, applied at write time in priority order */ + highlightRules: HighlightRule[] +} + +const RULE = ( + id: string, + pattern: string, + priority: number, + fg: string, + note: string, + bg?: string +): HighlightRule => ({ id, pattern, enabled: true, priority, color: { fg, bg }, note, builtin: true }) + +export const DEFAULT_HIGHLIGHT_RULES: HighlightRule[] = [ + RULE('perm', '^[-dlbcps][rwxstST-]{9}', 1, '#3fb950', 'Linux 权限与用户'), + RULE('path', '(?:^|[\\s:=])(?:/[A-Za-z0-9_.\\-/@+]+|~[A-Za-z0-9_.\\-/]*)', 2, '#58a6ff', 'Linux 文件路径'), + RULE('shellkw', '\\b(if|then|else|elif|fi|for|while|do|done|case|esac|function|return|break|continue|export|source)\\b', 3, '#d2a8ff', 'Shell 关键字与流程'), + RULE('okstate', '\\b(SUCCESS|PASS|OK|DONE|PASSED)\\b', 5, '#3fb950', '成功状态'), + RULE('badstate', '\\b(FAILED|ERROR|FAIL|FATAL|WARN|WARNING|DENIED)\\b', 6, '#f85149', '错误/警告状态'), + RULE('quoted', '"[^"\\n]*"|\'[^\'\\n]*\'', 8, '#e3b341', '字符串与引号'), + RULE('envvar', '\\$\\{?[A-Za-z_][A-Za-z0-9_]*\\}?', 10, '#79c0ff', '环境变量与参数'), + RULE('ipv4', '\\b(?:\\d{1,3}\\.){3}\\d{1,3}(?::\\d{1,5})?\\b', 15, '#58a6ff', '网络与 IP 地址'), + RULE('datetime', '\\b\\d{4}[-/]\\d{1,2}[-/]\\d{1,2}(?:[ T]\\d{1,2}:\\d{2}(?::\\d{2})?)?\\b|\\b\\d{1,2}:\\d{2}(?::\\d{2})?\\b', 20, '#bc8cff', '时间与日期'), + RULE('numbers', '\\b\\d+(?:\\.\\d+)?(?:%)?\\b', 25, '#f2cc60', '数字与计数'), + RULE('url', 'https?://[^\\s]+', 30, '#58a6ff', '网址链接') +] + +export const DEFAULT_SETTINGS: AppSettings = { + terminal: { + fontFamily: "'Cascadia Mono', Consolas, 'JetBrains Mono', 'Courier New', monospace", + fontSize: 14, + fontWeight: 400, + boldFontWeight: 700, + letterSpacing: 0, + lineHeight: 1, + scrollback: 5000, + cursorBlink: true, + cursorStyle: 'block', + cursorInactiveStyle: 'outline', + themeId: 'default-dark', + rendererMode: 'auto', + autoWrap: true, + copyOnSelect: false, + pasteRiskConfirm: true + }, + customThemes: [], + highlightRules: DEFAULT_HIGHLIGHT_RULES +} diff --git a/src/shared/theme.ts b/src/shared/theme.ts new file mode 100644 index 0000000..33bb0a3 --- /dev/null +++ b/src/shared/theme.ts @@ -0,0 +1,382 @@ +/** + * Terminal color theme model. Palette layout mirrors xterm.js ITheme. + * Values of builtin themes come from the public-domain iTerm2-Color-Schemes / xterm-theme collections. + */ + +export interface ThemeColors { + background: string + foreground: string + cursor: string + /** accent block behind cursor text; falls back to foreground if omitted */ + cursorAccent?: string + selectionBackground: string + black: string + red: string + green: string + yellow: string + blue: string + magenta: string + cyan: string + white: string + brightBlack: string + brightRed: string + brightGreen: string + brightYellow: string + brightBlue: string + brightMagenta: string + brightCyan: string + brightWhite: string +} + +export interface TerminalTheme { + id: string + name: string + builtin: boolean + colors: ThemeColors +} + +export const DEFAULT_DARK: TerminalTheme = { + id: 'default-dark', + name: 'OpenTerminal Dark', + builtin: true, + colors: { + background: '#1e1e1e', + foreground: '#cccccc', + cursor: '#ffffff', + cursorAccent: '#1e1e1e', + selectionBackground: '#264f78', + black: '#000000', + red: '#cd3131', + green: '#0dbc79', + yellow: '#e5e510', + blue: '#2472c8', + magenta: '#bc3fbc', + cyan: '#11a8cd', + white: '#e5e5e5', + brightBlack: '#666666', + brightRed: '#f14c4c', + brightGreen: '#23d18b', + brightYellow: '#f5f543', + brightBlue: '#3b8eea', + brightMagenta: '#d670d6', + brightCyan: '#29b8db', + brightWhite: '#ffffff' + } +} + +export const BUILTIN_THEMES: TerminalTheme[] = [ + DEFAULT_DARK, + { + id: 'dracula', + name: 'Dracula', + builtin: true, + colors: { + background: '#282a36', + foreground: '#f8f8f2', + cursor: '#f8f8f2', + cursorAccent: '#282a36', + selectionBackground: '#44475a', + 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' + } + }, + { + id: 'one-half-dark', + name: 'One Half Dark', + builtin: true, + colors: { + background: '#282c34', + foreground: '#dcdfe4', + cursor: '#61afef', + cursorAccent: '#282c34', + selectionBackground: '#3e4451', + black: '#282c34', + red: '#e06c75', + green: '#98c379', + yellow: '#e5c07b', + blue: '#61afef', + magenta: '#c678dd', + cyan: '#56b6c2', + white: '#dcdfe4', + brightBlack: '#5c6370', + brightRed: '#e06c75', + brightGreen: '#98c379', + brightYellow: '#e5c07b', + brightBlue: '#61afef', + brightMagenta: '#c678dd', + brightCyan: '#56b6c2', + brightWhite: '#ffffff' + } + }, + { + id: 'one-half-light', + name: 'One Half Light', + builtin: true, + colors: { + background: '#fafafa', + foreground: '#383a42', + cursor: '#383a42', + cursorAccent: '#fafafa', + selectionBackground: '#d2d6db', + black: '#383a42', + red: '#e45649', + green: '#50a14f', + yellow: '#c18401', + blue: '#0184bc', + magenta: '#a626a4', + cyan: '#0997b3', + white: '#fafafa', + brightBlack: '#4f525e', + brightRed: '#e06c75', + brightGreen: '#98c379', + brightYellow: '#e5c07b', + brightBlue: '#61afef', + brightMagenta: '#c678dd', + brightCyan: '#56b6c2', + brightWhite: '#ffffff' + } + }, + { + id: 'solarized-dark', + name: 'Solarized Dark', + builtin: true, + colors: { + background: '#002b36', + foreground: '#839496', + cursor: '#93a1a1', + cursorAccent: '#002b36', + selectionBackground: '#073642', + black: '#002b36', + red: '#dc322f', + green: '#859900', + yellow: '#b58900', + blue: '#268bd2', + magenta: '#d33682', + cyan: '#2aa198', + white: '#eee8d5', + brightBlack: '#073642', + brightRed: '#cb4b16', + brightGreen: '#586e75', + brightYellow: '#657b83', + brightBlue: '#839496', + brightMagenta: '#6c71c4', + brightCyan: '#93a1a1', + brightWhite: '#fdf6e3' + } + }, + { + id: 'solarized-light', + name: 'Solarized Light', + builtin: true, + colors: { + background: '#fdf6e3', + foreground: '#657b83', + cursor: '#586e75', + cursorAccent: '#fdf6e3', + selectionBackground: '#eee8d5', + black: '#073642', + red: '#dc322f', + green: '#859900', + yellow: '#b58900', + blue: '#268bd2', + magenta: '#d33682', + cyan: '#2aa198', + white: '#eee8d5', + brightBlack: '#93a1a1', + brightRed: '#cb4b16', + brightGreen: '#586e75', + brightYellow: '#657b83', + brightBlue: '#839496', + brightMagenta: '#6c71c4', + brightCyan: '#93a1a1', + brightWhite: '#fdf6e3' + } + }, + { + id: 'gruvbox-dark', + name: 'Gruvbox Dark', + builtin: true, + colors: { + background: '#282828', + foreground: '#ebdbb2', + cursor: '#ebdbb2', + cursorAccent: '#282828', + selectionBackground: '#504945', + black: '#282828', + red: '#cc241d', + green: '#98971a', + yellow: '#d79921', + blue: '#458588', + magenta: '#b16286', + cyan: '#689d6a', + white: '#a89984', + brightBlack: '#928374', + brightRed: '#fb4934', + brightGreen: '#b8bb26', + brightYellow: '#fabd2f', + brightBlue: '#83a598', + brightMagenta: '#d3869b', + brightCyan: '#8ec07c', + brightWhite: '#ebdbb2' + } + }, + { + id: 'nord', + name: 'Nord', + builtin: true, + colors: { + background: '#2e3440', + foreground: '#d8dee9', + cursor: '#d8dee9', + cursorAccent: '#2e3440', + selectionBackground: '#434c5e', + black: '#3b4252', + red: '#bf616a', + green: '#a3be8c', + yellow: '#ebcb8b', + blue: '#81a1c1', + magenta: '#b48ead', + cyan: '#88c0d0', + white: '#e5e9f0', + brightBlack: '#4c566a', + brightRed: '#bf616a', + brightGreen: '#a3be8c', + brightYellow: '#ebcb8b', + brightBlue: '#81a1c1', + brightMagenta: '#b48ead', + brightCyan: '#8fbcbb', + brightWhite: '#eceff4' + } + }, + { + id: 'monokai', + name: 'Monokai', + builtin: true, + colors: { + background: '#272822', + foreground: '#f8f8f2', + cursor: '#f8f8f0', + cursorAccent: '#272822', + selectionBackground: '#49483e', + black: '#272822', + red: '#f92672', + green: '#a6e22e', + yellow: '#f4bf75', + blue: '#66d9ef', + magenta: '#ae81ff', + cyan: '#a1efe4', + white: '#f8f8f2', + brightBlack: '#75715e', + brightRed: '#f92672', + brightGreen: '#a6e22e', + brightYellow: '#f4bf75', + brightBlue: '#66d9ef', + brightMagenta: '#ae81ff', + brightCyan: '#a1efe4', + brightWhite: '#f9f8f5' + } + }, + { + id: 'github-light', + name: 'GitHub Light', + builtin: true, + colors: { + background: '#ffffff', + foreground: '#4d4d4c', + cursor: '#4d4d4c', + cursorAccent: '#ffffff', + selectionBackground: '#dcdcdc', + black: '#3e3e3e', + red: '#c91b00', + green: '#00c200', + yellow: '#c7c400', + blue: '#0225c7', + magenta: '#c930c7', + cyan: '#00c5c7', + white: '#c7c7c7', + brightBlack: '#676767', + brightRed: '#ff6d67', + brightGreen: '#5ff967', + brightYellow: '#ffff6c', + brightBlue: '#6871ff', + brightMagenta: '#ff76ff', + brightCyan: '#5ffdff', + brightWhite: '#ffffff' + } + }, + { + id: 'afterglow', + name: 'Afterglow', + builtin: true, + colors: { + background: '#1f1f1f', + foreground: '#d0d0d0', + cursor: '#f5f5f5', + cursorAccent: '#1f1f1f', + selectionBackground: '#4a4a4a', + black: '#1f1f1f', + red: '#ac4142', + green: '#7e8e50', + yellow: '#e5b567', + blue: '#6c99bb', + magenta: '#9f4e85', + cyan: '#508451', + white: '#d0d0d0', + brightBlack: '#5a5a5a', + brightRed: '#ac4142', + brightGreen: '#7e8e50', + brightYellow: '#e5b567', + brightBlue: '#6c99bb', + brightMagenta: '#9f4e85', + brightCyan: '#508451', + brightWhite: '#f5f5f5' + } + }, + { + id: 'material-dark', + name: 'Material Dark', + builtin: true, + colors: { + background: '#263238', + foreground: '#eeffff', + cursor: '#ffffff', + cursorAccent: '#263238', + selectionBackground: '#2a4153', + black: '#263238', + red: '#f07178', + green: '#c3e88d', + yellow: '#ffcb6b', + blue: '#82aaff', + magenta: '#c792ea', + cyan: '#89ddff', + white: '#eeeeee', + brightBlack: '#546e7a', + brightRed: '#f07178', + brightGreen: '#c3e88d', + brightYellow: '#ffcb6b', + brightBlue: '#82aaff', + brightMagenta: '#c792ea', + brightCyan: '#89ddff', + brightWhite: '#ffffff' + } + } +] + +/** Resolve a theme by id; falls back to the default dark theme. */ +export function getThemeById(id: string, customThemes: TerminalTheme[] = []): TerminalTheme { + return [...BUILTIN_THEMES, ...customThemes].find((t) => t.id === id) ?? DEFAULT_DARK +} diff --git a/tests/electron-stub.cjs b/tests/electron-stub.cjs new file mode 100644 index 0000000..c9f130b --- /dev/null +++ b/tests/electron-stub.cjs @@ -0,0 +1,12 @@ +// Minimal electron stub for bundling src/main/pty.ts under plain Node +// (tests/ssh-session-e2e.mjs). Only what broadcast.ts touches. +module.exports = { + BrowserWindow: { + getAllWindows: () => [] + }, + app: {}, + ipcMain: {}, + safeStorage: { + isEncryptionAvailable: () => false + } +} diff --git a/tests/hl-split-smoke.mjs b/tests/hl-split-smoke.mjs new file mode 100644 index 0000000..e62cf4c --- /dev/null +++ b/tests/hl-split-smoke.mjs @@ -0,0 +1,64 @@ +// Split-chunk regression test for HighlightStream. +// Builds the engine + TerminalView-free stream, feeds a kimi-like banner in +// deliberately nasty chunk splits, and requires byte-identical output vs the +// single-chunk reference. +import { compileRules, applyHighlights, HighlightStream } from '../src/renderer/src/terminal/highlightEngine.ts' + +const rules = compileRules([ + { id: 'okstate', pattern: '\\b(SUCCESS|PASS|OK|DONE)\\b', enabled: true, priority: 5, color: { fg: '#3fb950' } }, + { id: 'badstate', pattern: '\\b(ERROR|FAILED)\\b', enabled: true, priority: 6, color: { fg: '#f85149' } }, + { id: 'numbers', pattern: '\\b\\d+(?:\\.\\d+)?(?:%)?\\b', enabled: true, priority: 25, color: { fg: '#f2cc60' } }, + { id: 'url', pattern: 'https?://[^\\s]+', enabled: true, priority: 30, color: { fg: '#58a6ff' } } +]) + +// kimi-like: truecolor SGR, OSC title, box drawing, per-line repaints +const banner = + '\x1b]0;kimi — session\x07' + + '\x1b[38;2;79;168;255m╭───────╮\x1b[0m\r\n' + + '\x1b[38;2;136;136;136mWelcome to Kimi Code!\x1b[0m \x1b[38;2;79;168;255m│\x1b[0m send /help\r\n' + + '\x1b[38;2;224;224;224mModel: MiniMax-M3 0.41.038 ERROR 99% https://example.com/x\x1b[0m\r\n' + +const reference = applyHighlights(banner, rules) + +// every possible single split point + a few double splits +let failed = 0 +const splitAt = (s, i) => [s.slice(0, i), s.slice(i)] + +for (let i = 1; i < banner.length - 1; i++) { + const [a, b] = splitAt(banner, i) + const stream = new HighlightStream(rules) + const out = stream.push(a) + stream.push(b) + stream.flush() + if (out !== reference) { + failed++ + if (failed <= 3) { + console.log(`MISMATCH at split ${i}:`) + console.log(' got: ' + JSON.stringify(out.slice(0, 120))) + console.log(' expected: ' + JSON.stringify(reference.slice(0, 120))) + } + } +} + +// double splits at 1/3 and 2/3, plus a pathological 1-byte-per-push feed +{ + const i = Math.floor(banner.length / 3) + const j = Math.floor((banner.length * 2) / 3) + const stream = new HighlightStream(rules) + const out = stream.push(banner.slice(0, i)) + stream.push(banner.slice(i, j)) + stream.push(banner.slice(j)) + stream.flush() + if (out !== reference) failed++ +} + +{ + const stream = new HighlightStream(rules) + let out = '' + for (const ch of banner) out += stream.push(ch) + out += stream.flush() + if (out !== reference) failed++ +} + +if (failed === 0) { + console.log('SPLIT-SMOKE PASS: all split points byte-identical to single-chunk reference') + process.exit(0) +} else { + console.log(`SPLIT-SMOKE FAIL: ${failed} mismatches`) + process.exit(1) +} diff --git a/tests/ssh-loopback.mjs b/tests/ssh-loopback.mjs new file mode 100644 index 0000000..809b463 --- /dev/null +++ b/tests/ssh-loopback.mjs @@ -0,0 +1,163 @@ +/** + * SSH loopback verification (M2). Pure Node ESM, no Electron. + * + * Spins up an in-process ssh2.Server (127.0.0.1, random port), then connects + * with a plain ssh2.Client using the exact same connect parameters the main + * process ssh service uses (host/port/username/password/keepalive/hostVerifier), + * and confirms the full data plane round-trip: + * + * ready -> shell -> banner "LOOPBACK-OK" -> write data -> echo -> close + * + * Run: `node tests/ssh-loopback.mjs` (must exit 0) + */ + +import pkg from 'ssh2' +const { Server, Client, utils } = pkg +import { createHash } from 'crypto' + +function fingerprintOf(key) { + return 'SHA256:' + createHash('sha256').update(key).digest('base64').replace(/=+$/, '') +} + +const fail = (msg) => { + console.error(`FAIL: ${msg}`) + process.exit(1) +} + +// ---- 1. Boot the loopback server ------------------------------------------- +const serverKey = utils.generateKeyPairSync('ed25519') +let serverPort = 0 +let seenWindowChange = { cols: 0, rows: 0 } + +const srv = new Server({ hostKeys: [serverKey.private] }, (client) => { + client.on('authentication', (ctx) => { + if (ctx.method === 'password' && ctx.username === 'test' && ctx.password === 'test') { + ctx.accept() + } else { + ctx.reject(['password']) + } + }) + + client.on('ready', () => { + client.on('session', (accept, reject) => { + const session = accept() + session.on('pty', (accept) => accept()) + session.on('window-change', (accept, reject, info) => { + seenWindowChange = { cols: info.cols, rows: info.rows } + accept && accept() + }) + session.on('shell', (accept, reject) => { + const stream = accept() + stream.write('LOOPBACK-OK\n') + stream.on('data', (d) => { + const data = d.toString('utf8') + stream.write(data) // echo back + if (data.includes('exit')) { + stream.end() + client.end() + } + }) + }) + }) + }) + + client.on('error', (err) => console.error('[server] client error', err.message)) +}) + +await new Promise((resolve, reject) => { + srv.on('error', reject) + srv.listen(0, '127.0.0.1', () => { + serverPort = srv.address().port + resolve() + }) +}) +console.log(`[loopback] ssh server listening on 127.0.0.1:${serverPort}`) + +// ---- 2. Connect with the same params the main ssh service uses -------------- +const client = new Client() +let bannerSeen = false +let echoed = false +let output = '' +let verifyCalls = 0 +let resolveDone +const done = new Promise((r) => (resolveDone = r)) +const timer = setTimeout(() => { + fail(`timed out (client connected: ${client._stream ? 'yes' : 'no'})`) +}, 10000) + +client.on('ready', () => { + console.log('[loopback] client ready') + client.shell({ term: 'xterm-256color', cols: 80, rows: 24 }, (err, stream) => { + if (err) return fail(`shell error: ${err.message}`) + console.log('[loopback] shell open') + let sentInput = false + let sentExit = false + + stream.on('data', (d) => { + const chunk = d.toString('utf8') + output += chunk + if (output.includes('LOOPBACK-OK') && !sentInput) { + bannerSeen = true + console.log('[loopback] banner received') + // exercise resize while the session is live + stream.setWindow(40, 120, 0, 0) + sentInput = true + stream.write('hello loopback\n') + } + if (output.includes('hello loopback') && echoed === false) { + echoed = true + console.log('[loopback] echo received') + } + if (bannerSeen && echoed && !sentExit) { + sentExit = true + stream.write('exit\n') + } + }) + + stream.on('close', () => { + clearTimeout(timer) + console.log('[loopback] stream closed') + client.end() + resolveDone() + }) + }) +}) + +client.on('error', (err) => fail(`client error: ${err.message}`)) + +// hostVerifier mirrors ssh.ts (SHA256 fingerprint; loopback accepts the key) +client.connect({ + host: '127.0.0.1', + port: serverPort, + username: 'test', + password: 'test', + keepaliveInterval: 0, + hostVerifier: (hostKey, verify) => { + verifyCalls++ + const fp = fingerprintOf(hostKey) + console.log(`[loopback] hostVerifier called (${verifyCalls}), fp=${fp}`) + verify(true) + } +}) + +// ---- 3. Assertions ---------------------------------------------------------- +await done +srv.close() + +const checks = [ + ['client reached ready', bannerSeen], + ['banner LOOPBACK-OK received', bannerSeen], + ['typed data echoed back', echoed], + ['host key verified exactly once', verifyCalls === 1], + ['resize propagated to server (40x120)', seenWindowChange.cols === 120 && seenWindowChange.rows === 40] +] + +let ok = true +for (const [label, pass] of checks) { + console.log(`[loopback] ${pass ? 'PASS' : 'FAIL'}: ${label}`) + if (!pass) ok = false +} + +if (!ok) fail('one or more checks failed') +console.log('[loopback] ALL CHECKS PASSED') +process.exit(0) \ No newline at end of file diff --git a/tests/ssh-session-e2e.mjs b/tests/ssh-session-e2e.mjs new file mode 100644 index 0000000..dc56a46 --- /dev/null +++ b/tests/ssh-session-e2e.mjs @@ -0,0 +1,151 @@ +/** + * Session-layer e2e (M2). Drives the REAL session routing (src/main/pty.ts + + * src/main/ssh.ts, bundled by esbuild with an electron stub) against an + * in-process ssh2.Server — the same openSession/write/resize/kill pipeline the + * app uses, minus the IPC + renderer shells. + * + * Pre-req: npx esbuild src/main/pty.ts --bundle --platform=node --format=cjs + * --outfile=tests/.session-e2e.cjs --external:@lydell/node-pty + * --external:ssh2 --alias:electron=./tests/electron-stub.cjs + * Run: node tests/ssh-session-e2e.mjs (must exit 0) + */ + +import pkg from 'ssh2' +import { createHash } from 'crypto' +import { createRequire } from 'module' +const require_ = createRequire(import.meta.url) +const { Server, utils } = pkg + +const fail = (msg) => { + console.error(`FAIL: ${msg}`) + process.exit(1) +} + +// ---- 1. Loopback ssh server -------------------------------------------------- +const serverKey = utils.generateKeyPairSync('ed25519') +let serverPort = 0 +let seenWindowChange = { cols: 0, rows: 0 } + +const srv = new Server({ hostKeys: [serverKey.private] }, (client) => { + client.on('authentication', (ctx) => { + if (ctx.method === 'password' && ctx.username === 'test' && ctx.password === 'test') { + ctx.accept() + } else { + ctx.reject(['password']) + } + }) + client.on('ready', () => { + client.on('session', (accept) => { + const session = accept() + session.on('pty', (accept) => accept()) + session.on('window-change', (accept, reject, info) => { + seenWindowChange = { cols: info.cols, rows: info.rows } + accept && accept() + }) + session.on('shell', (accept) => { + const stream = accept() + stream.write('SESSION-E2E-BANNER\n') + stream.on('data', (d) => { + const data = d.toString('utf8') + stream.write(data) + if (data.includes('exit')) { + stream.end() + client.end() + } + }) + }) + }) + }) + client.on('error', () => {}) +}) + +await new Promise((resolve, reject) => { + srv.on('error', reject) + srv.listen(0, '127.0.0.1', () => { + serverPort = srv.address().port + resolve() + }) +}) +console.log(`[e2e] ssh server listening on 127.0.0.1:${serverPort}`) + +// ---- 2. Load the real session layer ------------------------------------------ +// esbuild replaces 'electron' with tests/electron-stub.cjs via --alias. +const sessionLayer = require_('./.session-e2e.cjs') + +const events = [] +sessionLayer.configureSessionRuntime({ + broadcast: (channel, payload) => events.push({ channel, payload }), + getConnection: (id) => { + if (id !== 'conn-1') fail(`unexpected connectionId ${id}`) + return { + id: 'conn-1', + name: 'loopback', + host: '127.0.0.1', + port: serverPort, + username: 'test', + auth: 'password', + askPasswordAtConnect: false, + askPassphraseAtConnect: false, + keepaliveIntervalSec: 0, + createdAt: Date.now(), + savedAuth: { hasPassword: true, hasKeyContent: false, hasPassphrase: false } + } + }, + getSecret: (conn, field) => (field === 'password' ? 'test' : undefined), + touch: (id) => events.push({ channel: 'touched', payload: id }), + knownHosts: { + check: (host, port, key) => { + const fp = 'SHA256:' + createHash('sha256').update(key).digest('base64').replace(/=+$/, '') + return { status: 'match', fingerprint: fp } + }, + accept: () => {} + }, + promptHostKey: () => fail('promptHostKey must not fire for matching keys') +}) + +// ---- 3. Drive the pipeline ---------------------------------------------------- +const timer = setTimeout(() => fail('e2e timed out'), 15000) +const openResult = await sessionLayer.openSession({ kind: 'ssh', connectionId: 'conn-1' }) +if (!openResult?.id) fail('openSession did not resolve with an id') +console.log(`[e2e] session open: ${openResult.id}`) + +const wait = (ms) => new Promise((r) => setTimeout(r, ms)) +const seenData = (needle) => + events.some((e) => e.channel === 'pty:data' && typeof e.payload?.data === 'string' && e.payload.data.includes(needle)) + +// banner should already have streamed once the shell opened +for (let i = 0; i < 50 && !seenData('SESSION-E2E-BANNER'); i++) await wait(100) +if (!seenData('SESSION-E2E-BANNER')) fail('banner never arrived via PTY_DATA broadcast') +console.log('[e2e] banner received through session data plane') + +// replay buffer: a late subscriber must still get the banner +const replay = await sessionLayer.getSessionReplay(openResult.id) +if (!replay.includes('SESSION-E2E-BANNER')) fail('replay buffer missing the banner') +console.log('[e2e] replay buffer serves the banner to late subscribers') + +sessionLayer.writePty(openResult.id, 'ping-session-e2e\n') +for (let i = 0; i < 50 && !seenData('ping-session-e2e'); i++) await wait(100) +if (!seenData('ping-session-e2e')) fail('written data never echoed back') +console.log('[e2e] write -> echo round-trip ok') + +sessionLayer.resizePty(openResult.id, 111, 33) +for (let i = 0; i < 50 && seenWindowChange.cols !== 111; i++) await wait(100) +if (seenWindowChange.cols !== 111 || seenWindowChange.rows !== 33) fail(`resize not propagated (${JSON.stringify(seenWindowChange)})`) +console.log('[e2e] resize propagated (111x33)') + +sessionLayer.killPty(openResult.id) +const exited = events.find((e) => e.channel === 'pty:exit' && e.payload?.id === openResult.id) +if (!exited) { + // 'close' on the client stream may lag slightly behind kill + for (let i = 0; i < 50 && !events.some((e) => e.channel === 'pty:exit' && e.payload?.id === openResult.id); i++) await wait(100) +} +if (!events.some((e) => e.channel === 'pty:exit' && e.payload?.id === openResult.id)) fail('PTY_EXIT never broadcast after killPty') +console.log('[e2e] kill -> PTY_EXIT ok') + +if (!events.some((e) => e.channel === 'touched' && e.payload === 'conn-1')) fail('lastConnectedAt touch not recorded') +console.log('[e2e] connection touch recorded') + +clearTimeout(timer) +srv.close() +console.log('[e2e] ALL CHECKS PASSED') +process.exit(0) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..155ebaa --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.web.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..fd0c38c --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "composite": true, + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2023"], + "types": ["node"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "@shared/*": ["src/shared/*"] + } + }, + "include": [ + "electron.vite.config.ts", + "src/main/**/*", + "src/preload/**/*", + "src/shared/**/*" + ] +} diff --git a/tsconfig.web.json b/tsconfig.web.json new file mode 100644 index 0000000..306f669 --- /dev/null +++ b/tsconfig.web.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "composite": true, + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "useDefineForClassFields": true, + "baseUrl": ".", + "paths": { + "@shared/*": ["src/shared/*"], + "@renderer/*": ["src/renderer/src/*"] + } + }, + "include": ["src/renderer/**/*", "src/shared/**/*"] +}