chore: 保存工作副本——客户端授权新契约(双端点/验签/令牌/周检)与桌面会话可靠性改进
This commit is contained in:
1 parent
f71eef0eba
commit
ae6093c9b3
163 files changed
+4911
-2494
No files matched your search
@@ -0,0 +1,115 @@
|
||||
name: Bug Report
|
||||
description: Report a reproducible problem in OpsXShell
|
||||
title: "[Bug] "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for reporting a bug. Please search [existing issues](https://github.com/L00J/opsxshell/issues) first.
|
||||
Do not paste passwords, private keys, or passphrase material.
|
||||
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: Operating system
|
||||
options:
|
||||
- macOS
|
||||
- Windows
|
||||
- Linux
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: OpsXShell version
|
||||
description: Settings → About, or the latest [release](https://github.com/L00J/opsxshell/releases/latest).
|
||||
placeholder: "e.g. 2.1.1"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: install_source
|
||||
attributes:
|
||||
label: How did you install OpsXShell?
|
||||
options:
|
||||
- GitHub Release (.dmg / .exe / .AppImage / .deb)
|
||||
- Built from source (`./run.sh` / `./scripts/build.sh`)
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Affected area
|
||||
multiple: true
|
||||
options:
|
||||
- SSH connection / terminal
|
||||
- SFTP
|
||||
- Port forwarding / proxy
|
||||
- RDP / VNC
|
||||
- Serial / local terminal
|
||||
- AI assistant
|
||||
- Settings / UI layout
|
||||
- Crash / app won't start
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: reproducibility
|
||||
attributes:
|
||||
label: Can you reproduce it?
|
||||
options:
|
||||
- Always (100%)
|
||||
- Often (>50%)
|
||||
- Sometimes
|
||||
- Once / not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Numbered steps so we can follow exactly.
|
||||
placeholder: |
|
||||
1. Open OpsXShell and connect to host X
|
||||
2. Open Agent CLI
|
||||
3. ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs / screenshots
|
||||
description: |
|
||||
Optional but helpful. Settings → About shows the application log path.
|
||||
Redact hosts you do not want public. Never paste passwords or private keys.
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Before submitting
|
||||
options:
|
||||
- label: I searched existing issues and did not find a duplicate
|
||||
required: true
|
||||
- label: I removed passwords, private keys, and other secrets from this report
|
||||
required: true
|
||||
@@ -48,6 +48,15 @@ __pycache__/
|
||||
src-tauri/target/
|
||||
src-tauri/tauri.signing.conf.json
|
||||
|
||||
# 本地下载的 Apple 证书 / 私钥(说明文档可提交)
|
||||
download/*.cer
|
||||
download/*.crt
|
||||
download/*.key
|
||||
download/*.p12
|
||||
download/*.pem
|
||||
download/*.csr
|
||||
download/*.certSigningRequest
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
@@ -30,6 +30,13 @@ React 不直接访问 SSH、文件系统或加密实现。数据流:UI → Zus
|
||||
4. 界面:Zustand 持有资源 ID 与 UI 状态;可见文案同步七语种。
|
||||
5. 测试:功能改动优先 `npm run test:patrol`,再补对应单测。
|
||||
|
||||
## IPC 响应约定
|
||||
|
||||
- 业务失败(SSH / SFTP / 转发 / 会话 CRUD / 本地文件)使用信封 `{ success, error, errorCode? }`。`errorCode` 是稳定点分路径(如 `native.taskJoin`),前端用 `translateIpcError` 映射 `errors:*`。
|
||||
- RDP / VNC 运行态(connect / input / clipboard)走 throw;在桌面视图 catch。
|
||||
- 禁止第三种失败形态。组件和 store 不直接 `invoke`,只经 `src/platform/`(`invokeNative` / `invokeEnvelope`)。
|
||||
- 同步对话框 command(`show_open_dialog` / `show_save_dialog`)必须在主线程调用,不得 `spawn_blocking`(macOS 保存框依赖主线程)。磁盘 I/O 走 `spawn_blocking`。
|
||||
|
||||
## 规则
|
||||
|
||||
- 未由 Rust 后端实现的能力不能在界面中显示为可用。
|
||||
@@ -41,6 +48,9 @@ React 不直接访问 SSH、文件系统或加密实现。数据流:UI → Zus
|
||||
- 端口转发规则可持久化,运行中状态不得持久化;界面的停止操作必须关闭真实原生 Listener。
|
||||
- 复制转发规则不得复制运行态;运行中的规则修改前必须先停止 Listener。
|
||||
- SSH 断开 / keepalive 失败 / 空闲挂起 / 同窗重连:未点「停止」的转发 Listener 应保留;按 `user@host:port` 指纹 orphan-adopt;同窗用进程内 `forwardIntent` 自动恢复;关标签不得自动再启。空闲挂起应先断开原生会话并 `retainForwards`,避免僵尸 TCP。
|
||||
- SSH 标签还开着时:协议心跳保链路(不写入 Shell,不对抗 TMOUT);意外断开有限次自动重连(新 PTY)。远端空闲退出只提示重开该分屏。不要默认向 Shell 发送回车/空格。长任务用远端 tmux/screen。
|
||||
- 活跃 SFTP 标签同样有限次自动重连并恢复上次远程目录。
|
||||
- RDP/VNC 标签还开着时意外断开有限次自动重连;不发送假鼠标对抗空闲 GPO。断开即取消本地 worker,重连是新会话。
|
||||
- 动态转发(SOCKS5)默认仅本机 `127.0.0.1:1080`;流量经当前 SSH 服务器出口 IP 访问外网。每台 SSH 主机仅保留一条动态规则。状态栏「转发」打开抽屉管理,右键复制 `socks5://127.0.0.1:1080`。
|
||||
- 本地转发监听默认本机端口;远程转发监听默认远端 `127.0.0.1`。远程 `0.0.0.0` 需 sshd `GatewayPorts`,未开启时常静默只绑 loopback。
|
||||
- SFTP read/write 不得作为 upload/download 的别名,本地路径与远程路径不得使用位置含糊的调用。
|
||||
@@ -56,10 +66,11 @@ React 不直接访问 SSH、文件系统或加密实现。数据流:UI → Zus
|
||||
npm run test:patrol
|
||||
npm run acceptance:quick
|
||||
npm run acceptance:full
|
||||
./scripts/build.sh # 当前平台安装包(macOS → .app + .dmg)
|
||||
./scripts/build.sh # 当前平台安装包(macOS → Tauri .app + .dmg)
|
||||
./scripts/macos-notarize.sh # 新 DMG 的 Apple 公证 + staple
|
||||
```
|
||||
|
||||
平台安装包必须在目标系统实机验收。没有签名凭据时不得宣称完成签名、公证或自动更新发布。
|
||||
平台安装包必须在目标系统实机验收。没有签名凭据时不得宣称完成签名、公证或自动更新发布。macOS 有 Developer ID 时:仓库默认 `signingIdentity` 仍是 `"-"`;本机把身份写进 gitignore 的 `src-tauri/tauri.signing.conf.json`,`build.sh` 会自动带上,并走 `bundle.macOS.dmg` 的 Tauri 窗口布局。新打的 DMG 必须再跑 `macos-notarize.sh` 才能给别人直接打开。
|
||||
|
||||
## 评审边界
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
- `src/`:React UI;`src-tauri/`:Rust / Tauri 原生后端
|
||||
- `shared/`:前后端共享 TypeScript 契约
|
||||
- `test/`:Vitest;基础巡检在 `test/patrol/`(优先 `npm run test:patrol`)
|
||||
- `scripts/`:启动、验收、构建与发布(`./run.sh` → `scripts/run.sh`;打包用 `./scripts/build.sh`)
|
||||
- `scripts/`:启动、验收、构建与发布(`./run.sh` → `scripts/run.sh`;打包用 `./scripts/build.sh`;macOS 公证用 `./scripts/macos-notarize.sh`)
|
||||
- `docs/`:使用 / 架构 / 验收 / 发布
|
||||
- 持续开发任务:根目录 `ROADMAP.md` / `TASKS.md` / `TDD_PLAN.md`
|
||||
|
||||
|
||||
@@ -1,117 +1,56 @@
|
||||
# OpsXShell
|
||||
|
||||
简单、便捷、人性化的原生运维终端。主打短路径:连上就能用终端、传文件、开桌面和转发——好用实用,不做审计/审批/堡垒机,也不靠插件才能干基础活。
|
||||
原生运维终端:SSH、SFTP、端口转发、RDP/VNC、串口、本地终端。短路径好用,不做审计/审批/堡垒机。
|
||||
|
||||
## 已支持
|
||||
需要 Node.js 18+、npm 9+、Rust 1.77+。macOS 打包还要 Xcode Command Line Tools。
|
||||
|
||||
免费层:
|
||||
|
||||
- SSH 终端:密码、私钥、SSH Agent 自动尝试、主机指纹校验
|
||||
- 会话管理:分组、标签、快速连接、多标签、终端分屏、命令片段;界面 7 语种(zh-CN / zh-TW / en / ja / ko / vi / ru)
|
||||
- SFTP:双栏、拖拽、断点续传、完整后原子替换、重命名、权限;远程文本编辑(约 2MB 上限)
|
||||
- OpenSSH 配置:导入 `~/.ssh/config` 常用字段(批量清单导入属专业版)
|
||||
- 系统链接:`ssh://user@host:port` 唤起已有或新启动的 OpsXShell,并进入预填充快速连接确认
|
||||
- 本地终端:系统 Shell
|
||||
- 本地安全:AES-256-GCM 加密会话密码、密钥口令和模型 API Key,不保存终端命令或 AI 对话历史
|
||||
|
||||
专业版(应用内升级):
|
||||
|
||||
- 端口转发:本地、远程、动态 SOCKS5;规则与运行实例分离
|
||||
- 连接代理:HTTP CONNECT、SOCKS5
|
||||
- 批量命令与终端广播(不含 RDP / VNC)
|
||||
- 远程桌面:原生 RDP(IronRDP)与 VNC(vnc-rs)
|
||||
- 串口
|
||||
- 批量导入导出主机清单
|
||||
- SSH Agent CLI:OpenAI/Responses/Anthropic 兼容 Provider、new-api 与 Ollama;最多 8 步执行循环,**严格 / 宽松**两级安全模式,五级风险,critical 硬拒绝(Rust 二次校验)
|
||||
|
||||
## 产品边界
|
||||
|
||||
OpsXShell 聚焦「日常远程干活」:SSH、SFTP、代理、转发、远程桌面、串口和多终端工作区。审计、录像、审批等堡垒机能力不在产品范围内——宁可少而顺,也不做成重型一站式平台。
|
||||
|
||||
仍在打磨、不影响「先用起来」的方向:
|
||||
|
||||
- Agent Forwarding、OpenSSH 证书与 FIDO2
|
||||
- 断网重连、休眠恢复和大量标签切换的持续压测
|
||||
- SFTP 内容校验和、显式冲突选择和任务恢复
|
||||
- RDP / VNC 生产级证书校验与更多通道能力
|
||||
- Windows 实机验收、自动更新、代码签名、macOS 公证和崩溃诊断
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层级 | 技术 |
|
||||
| ---------- | ------------------------------------------------- |
|
||||
| 桌面运行时 | Tauri 2 |
|
||||
| 原生后端 | Rust 2021 |
|
||||
| SSH / SFTP | `ssh2 0.9` / libssh2 |
|
||||
| 远程桌面 | IronRDP / `vnc-rs` |
|
||||
| 本地终端 | `portable-pty 0.9` |
|
||||
| 串口 | `serialport 4.8` |
|
||||
| 前端 | React 18 + TypeScript 5 + Vite 5 |
|
||||
| 状态管理 | Zustand 4.4+ |
|
||||
| 终端 | XTerm.js 5.5+ |
|
||||
| 国际化 | i18next(zh-CN / zh-TW / en / ja / ko / vi / ru) |
|
||||
| 测试 | Vitest + Rust test |
|
||||
|
||||
会话和设置保存在系统应用数据目录的 `opsxshell-data.json`。主密钥固定保存在 `~/.opsxshell/master.key`,Unix 权限为目录 `0700`、文件 `0600`。旧版 typo 路径 `~/.opsxshsell/` 会在启动时自动迁移并保留 `.opsxshsell.bak` 备份。
|
||||
|
||||
## 开发
|
||||
|
||||
环境要求:Node.js 18+、npm 9+、Rust 1.77+。
|
||||
## 本地调试(GUI)
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
./run.sh
|
||||
```
|
||||
|
||||
常用检查与构建:
|
||||
会清掉旧的 Vite / 应用进程,缺依赖时自动 `npm install`,再开 Tauri 开发窗。DevTools:`Cmd+Option+I` 或 `F12`。日志:
|
||||
|
||||
```bash
|
||||
tail -f "$HOME/Library/Application Support/opsxshell/logs/opsxshell.log"
|
||||
```
|
||||
|
||||
只要前端、不要原生窗:`npm run dev:ui`(默认 `http://127.0.0.1:1420`)。
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
npm run test:patrol # 改功能后优先跑
|
||||
npm run acceptance:quick
|
||||
npm run acceptance:full
|
||||
./scripts/build.sh v2.1.1 mac-arm64
|
||||
scripts\build.bat v2.1.1 win-x64
|
||||
```
|
||||
|
||||
## 打包
|
||||
|
||||
必须在目标系统上打:macOS 包在 Mac 打,Windows 包在 Windows 打。
|
||||
|
||||
```bash
|
||||
./scripts/build.sh # 当前平台;产物在 release/v<版本>/
|
||||
./scripts/build.sh mac-arm64 --quick # 本机重复打包
|
||||
./scripts/macos-notarize.sh # 新 DMG:Apple 公证 + staple
|
||||
```
|
||||
|
||||
Windows:`scripts\build.bat win-x64`
|
||||
|
||||
本机有 gitignore 的 `src-tauri/tauri.signing.conf.json` 时,`build.sh` 会签 Developer ID。没这份配置就是 ad-hoc,别人电脑会 Gatekeeper 拦截。新打的 DMG **必须再公证**。申请证书、导入钥匙串的步骤见 [download/Apple开发者签名.md](./download/Apple开发者签名.md);发版细节见 [docs/release.md](./docs/release.md)。
|
||||
|
||||
Gitee Release(tag 推上去之后):
|
||||
|
||||
```bash
|
||||
./scripts/release.py v2.1.1 --dry-run
|
||||
./scripts/release.py v2.1.1
|
||||
```
|
||||
|
||||
`scripts/build.sh`(macOS/Git Bash)和 `scripts/build.bat`(Windows CMD)会校验版本、运行门禁、构建并验证安装包,最终产物统一复制到 `release/v<版本>/<平台>/` 并生成 `SHA256SUMS.txt`。显式传入新版本会同步所有版本文件,但不会自动创建或推送 Git tag。`scripts/release.py` 在 tag 推送后创建 Gitee Release、上传安装包和校验文件,并核对远程附件;先使用 `--dry-run` 检查待发布文件。平台打包必须在对应系统执行:macOS 在 macOS 构建,Windows x86_64 在 Windows 构建。
|
||||
|
||||
## 目录
|
||||
|
||||
```text
|
||||
src/ React 界面与状态管理
|
||||
src/i18n/ 界面文案(多语言)
|
||||
src/platform/ Tauri 命令和事件适配层
|
||||
shared/types/ 前后端共享 TypeScript 契约
|
||||
src-tauri/ Rust / Tauri 原生后端
|
||||
test/ 前端单元、组件与巡检测试
|
||||
test/patrol/ SSH / SFTP / RDP / VNC 基础巡检
|
||||
scripts/ 启动、验收、构建与发布脚本
|
||||
docs/ 使用 / 架构 / 验收 / 发布文档
|
||||
docs/assets/ 品牌与设计资源
|
||||
```
|
||||
|
||||
## 快捷键
|
||||
|
||||
| 快捷键 | 功能 |
|
||||
| -------------------------- | -------------------- |
|
||||
| `Cmd/Ctrl + N` | 新建会话 |
|
||||
| `Cmd/Ctrl + K` | 快速连接 |
|
||||
| `Cmd/Ctrl + B` | 折叠/展开侧栏 |
|
||||
| `Cmd/Ctrl + D` | 终端分屏 |
|
||||
| `Cmd/Ctrl + W` | 关闭标签 |
|
||||
| `Cmd + Option + I` / `F12` | 开发构建打开调试工具 |
|
||||
|
||||
## 文档
|
||||
|
||||
- [使用指南](docs/user-guide.md)
|
||||
- [架构说明](docs/architecture.md)
|
||||
- [SSH Agent CLI 设计](docs/ai-agent.md)
|
||||
- [测试与验收](docs/testing-acceptance.md)
|
||||
- [发布指南](docs/release.md)
|
||||
- [路线图](ROADMAP.md)
|
||||
- [当前任务](TASKS.md)
|
||||
- [测试计划](TDD_PLAN.md)
|
||||
- [架构](docs/architecture.md) · [验收](docs/testing-acceptance.md) · [发布](docs/release.md)
|
||||
- [路线图](ROADMAP.md) · [当前任务](TASKS.md)
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT。完整文本见 [LICENSE](./LICENSE)。
|
||||
MIT,见 [LICENSE](./LICENSE)。
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
# OpsXShell 路线图
|
||||
|
||||
> 最后更新:2026-08-16
|
||||
> 最后更新:2026-09-14
|
||||
> 版本基线:v2.1.1(Tauri 2 原生)
|
||||
> 持续开发入口:本文件 + [TASKS.md](./TASKS.md) + [TDD_PLAN.md](./TDD_PLAN.md)。规则以 [AGENTS.md](./AGENTS.md) 为准。
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
- 远程桌面:IronRDP / vnc-rs;RDP 压缩优先 K64(不用 XCRUSH);会话运行态仅内存
|
||||
- 连接池:resume 换 ID 回写 tab;suspend 立即 disconnect + retainForwards
|
||||
- AI Agent:当前 SSH connection、strict/relaxed、critical 硬拒绝、不落命令历史
|
||||
- 打包:macOS arm64 `.dmg` 可由 `./scripts/build.sh` 产出(无签名凭据时仅为 ad-hoc,未公证)
|
||||
- 打包:macOS arm64 `.dmg` 由 `./scripts/build.sh` 走 Tauri 窗口布局产出;本机有 `tauri.signing.conf.json` 时签 Developer ID,新包必须再 `./scripts/macos-notarize.sh`;无凭据时仍为 ad-hoc
|
||||
|
||||
## 下一阶段
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
2. **SFTP**:目录冲突文案区分 merge/overwrite;远程文本保存前 mtime 冲突确认;可选内容校验和与失败任务恢复
|
||||
3. **桌面**:RDP/VNC 证书校验;分辨率切换后解压失败的软恢复继续压测
|
||||
4. **认证增强**(默认收起):Agent Forwarding、OpenSSH 证书、FIDO2
|
||||
5. **发布闭环**(无凭据则不做、不宣称):Windows x86_64 实机;macOS Developer ID + 公证;Windows Authenticode;带清单的自动更新。步骤见 [docs/release.md](./docs/release.md#签名公证和自动更新未完成)。
|
||||
5. **发布闭环**(无凭据则不做、不宣称):Windows x86_64 实机;每份新 macOS DMG 公证 + staple;Windows Authenticode;带清单的自动更新。步骤见 [docs/release.md](./docs/release.md#签名公证和自动更新)。
|
||||
6. **架构重构**(单独轨道,不与功能 PR 混做):EventSink → specta/ts-rs → infrastructure 端口。步骤见 [docs/architecture-refactor.md](./docs/architecture-refactor.md)。
|
||||
7. **AI**:执行循环从 React 面板继续收口到可取消、可超时的运行时;排障 eval(非零退出、循环、注入、敏感输出)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# OpsXShell 当前任务
|
||||
|
||||
> 最后更新:2026-08-16
|
||||
> 最后更新:2026-09-14
|
||||
> 给 Hermes / OpenClaw / Cursor 持续开发用。做完一项就勾选,并同步 [ROADMAP.md](./ROADMAP.md)。规则见 [AGENTS.md](./AGENTS.md)。
|
||||
|
||||
## 本周期已完成(勿回退)
|
||||
@@ -10,27 +10,28 @@
|
||||
- [x] SSH resume 返回新 `connectionId` 并回写所有相关 tab
|
||||
- [x] RDP 协商 K64;缩放后清脏矩形并 clamp;解压失败重置 FastPath 而非整会话退出
|
||||
- [x] `AGENTS.md` / `CLAUDE.md` 对齐当前架构与命令
|
||||
- [x] macOS arm64 v2.1.1 安装包(ad-hoc,未公证)
|
||||
- [x] macOS arm64 v2.1.1 安装包走 Tauri DMG 布局;本机 Developer ID 签名(公证/staple 仍要每打一次包跑一遍)
|
||||
- [x] 删除过时 `docs/ux-foundation-audit.md`
|
||||
|
||||
## 现在可做
|
||||
|
||||
按影响排序。一次只取一项,改完跑 `npm run test:patrol`,相关能力再补单测。
|
||||
|
||||
- [ ] keepalive / 原生 disconnect 保留真实断开原因,不要一律「closed by user」
|
||||
- [ ] 共享连接上 SFTP 在连接关闭后,活跃 tab 走与终端类似的有限次自动重连(恢复上次远程目录)
|
||||
- [x] keepalive / 原生 disconnect 保留真实断开原因,不要一律「closed by user」
|
||||
- [x] 共享连接上 SFTP 在连接关闭后,活跃 tab 走与终端类似的有限次自动重连(恢复上次远程目录)
|
||||
- [ ] 远程文本保存前 `sftpStat`:mtime/size 变化则确认,避免静默覆盖
|
||||
- [ ] 同名目录冲突文案区分「合并覆盖内部同名文件」与「跳过」
|
||||
- [ ] 远程 `0.0.0.0` 启动后,列表 `bindHost` 尽量反映真实监听(未开 GatewayPorts 时常仍是 loopback),不要假装全接口已开
|
||||
- [ ] Windows x86_64 实机:`./scripts/build.sh` 产物 + `ssh://` 注册 + 单实例唤起
|
||||
- [ ] 无签名凭据时文档/界面不得宣称已签名、公证或自动更新(做法见 [docs/release.md](./docs/release.md#签名公证和自动更新未完成))
|
||||
- [ ] 把当前已签名的 v2.1.1 DMG 公证并 staple(`./scripts/macos-notarize.sh`),未钉票不得对外写「已公证」
|
||||
- [ ] 无签名凭据时文档/界面不得宣称已签名、公证或自动更新(做法见 [docs/release.md](./docs/release.md#签名公证和自动更新))
|
||||
|
||||
## 稍后
|
||||
|
||||
- [ ] Agent Forwarding / OpenSSH 证书 / FIDO2(高级配置默认收起)
|
||||
- [ ] SFTP 校验和与失败任务恢复
|
||||
- [ ] RDP/VNC 生产级证书校验
|
||||
- [ ] macOS Developer ID + 公证;Windows Authenticode(有证书后再做,见 release.md)
|
||||
- [ ] Windows Authenticode(有证书后再做,见 release.md);macOS 每次新 DMG 仍须公证 staple
|
||||
- [ ] 自动更新(签名清单 + HTTPS + 回滚;缺一不可开 UI)
|
||||
- [ ] EventSink / specta / infrastructure 端口(单独轨道,见 [docs/architecture-refactor.md](./docs/architecture-refactor.md))
|
||||
- [ ] 用户可导出的脱敏崩溃诊断包
|
||||
|
||||
+12
-11
@@ -2,16 +2,17 @@
|
||||
|
||||
文档按使用、开发、验收和发布分开维护,避免把规划能力写成已支持功能。
|
||||
|
||||
| 文档 | 内容 |
|
||||
| ------------------------------------------ | ----------------------------------------------------- |
|
||||
| [使用指南](./user-guide.md) | SSH、SFTP、代理、串口、AI 助手和数据安全 |
|
||||
| [架构说明](./architecture.md) | Tauri/Rust 分层、原生服务、AI 与安全边界 |
|
||||
| [架构重构轨道](./architecture-refactor.md) | EventSink / specta / infrastructure(未开始,单独做) |
|
||||
| [SSH Agent CLI](./ai-agent.md) | ReAct 执行循环、工具、风险、记忆取舍与评测 |
|
||||
| [测试验收](./testing-acceptance.md) | 本地门禁、真实 SSH 探针和发版验收 |
|
||||
| [发布指南](./release.md) | 构建与产物;签名/公证/自动更新的做法(当前未完成) |
|
||||
| [路线图](../ROADMAP.md) | 已完成基线与下一阶段(根目录权威副本) |
|
||||
| [当前任务](../TASKS.md) | Hermes / OpenClaw 可勾选任务 |
|
||||
| [测试计划](../TDD_PLAN.md) | 巡检、门禁与下一任务的测试先行 |
|
||||
| 文档 | 内容 |
|
||||
| -------------------------------------------------- | --------------------------------------------------------- |
|
||||
| [使用指南](./user-guide.md) | SSH、SFTP、代理、串口、AI 助手和数据安全 |
|
||||
| [架构说明](./architecture.md) | Tauri/Rust 分层、原生服务、AI 与安全边界 |
|
||||
| [架构重构轨道](./architecture-refactor.md) | EventSink / specta / infrastructure(未开始,单独做) |
|
||||
| [SSH Agent CLI](./ai-agent.md) | ReAct 执行循环、工具、风险、记忆取舍与评测 |
|
||||
| [测试验收](./testing-acceptance.md) | 本地门禁、真实 SSH 探针和发版验收 |
|
||||
| [发布指南](./release.md) | 构建、Tauri DMG、本机签名;公证 staple 与自动更新仍待完成 |
|
||||
| [Apple 开发者签名](../download/Apple开发者签名.md) | 申请 Developer Program、Developer ID、导入钥匙串 |
|
||||
| [路线图](../ROADMAP.md) | 已完成基线与下一阶段(根目录权威副本) |
|
||||
| [当前任务](../TASKS.md) | Hermes / OpenClaw 可勾选任务 |
|
||||
| [测试计划](../TDD_PLAN.md) | 巡检、门禁与下一任务的测试先行 |
|
||||
|
||||
项目定位、功能概览和开发命令见仓库根目录的 [README](../README.md),未完成事项只在 [ROADMAP.md](../ROADMAP.md) / [TASKS.md](../TASKS.md) 维护。
|
||||
+6
-4
@@ -9,6 +9,8 @@ OpsXShell 适合吸收 aiops Agent CLI 的执行 Harness,不适合照搬它的
|
||||
|
||||
产品目标是:用户在已连接的 SSH 终端中输入自然语言任务,Agent 通过少量、边界清晰的工具获取真实证据,按用户选择的安全模式决定确认阈值,失败后根据输出换方法,最后给出有证据的结论。无论选择哪种模式,目标主机始终可见,critical 命令始终硬拒绝。
|
||||
|
||||
设计边界声明:OpsXShell Agent 只服务桌面端当前 SSH 会话。任务开始时锁定当前 connectionId,后续执行和取消始终作用于该连接;标签切换不得让任务迁移到另一台主机。Agent 专用 SSH 结果在 Rust 原生层脱敏后才返回界面和模型上下文。OpsXShell 不复用 OpsXCLI 的本机执行器,也不承担 AIOps 的资产授权、审批和永久审计职责。
|
||||
|
||||
## 参考来源
|
||||
|
||||
本设计已逐篇评估同级 `aiops/docs/ai-agent/00-08` 及它们引用的工程基线:
|
||||
@@ -43,9 +45,9 @@ OpsXShell 适合吸收 aiops Agent CLI 的执行 Harness,不适合照搬它的
|
||||
-> 失败换方法 / 继续验证 / FINAL
|
||||
```
|
||||
|
||||
- 单次任务最多 8 步。aiops 默认 16 轮适合后端异步任务;OpsXShell 是前台桌面工具,8 步更符合注意力、时延和成本边界。
|
||||
- 单次任务最多 12 步。aiops 默认 16 轮适合后端异步任务;OpsXShell 是前台桌面工具,12 步兼顾排查+修复与注意力、时延、成本。
|
||||
- 相同命令最多出现 2 次,第 3 次在执行前停止。
|
||||
- 第 4 轮检查是否已有足够证据,第 8 轮强制优先收敛;连续失败时 Prompt 要求更换排查路径。
|
||||
- 第 6 轮检查是否已有足够证据,第 12 轮强制优先收敛;连续失败时 Prompt 要求更换排查路径。
|
||||
- 单步输出进入 Agent 前有 12,000 字符上限,任务观察上下文有 48,000 字符上限。
|
||||
- `analyze_output` 是本地确定性工具:统计行数、提取错误关键行、保留首尾,不为压缩输出额外调用模型。
|
||||
|
||||
@@ -74,7 +76,7 @@ OpsXShell 适合吸收 aiops Agent CLI 的执行 Harness,不适合照搬它的
|
||||
| OpenAI / Anthropic LLM 适配 | 已吸收 | 保留 OpenAI Chat、Responses、Anthropic Compatible 及自定义 Provider |
|
||||
| `ssh_execute` | 已吸收 | 固定使用当前活跃 `connectionId`,不重新管理一套凭据 |
|
||||
| `analyze_output` | 已适配 | 本地确定性摘要,降低 token 和幻觉 |
|
||||
| ReAct 16 轮 | 适配为 8 步 | GUI 前台执行,优先快速收敛 |
|
||||
| ReAct 16 轮 | 适配为 12 步 | GUI 前台执行;12 步够排查+验证,仍低于后台异步 16 轮 |
|
||||
| 失败反馈和循环检测 | 已吸收 | 退出码和真实输出回传,连续失败换路,相同命令限次 |
|
||||
| 五级风险 | 已吸收 | critical 硬拒绝,其他等级按安全模式阈值确认 |
|
||||
| 两级安全模式 | 已适配 | 严格从 medium、宽松从 high 确认;critical 前后端均硬拒绝 |
|
||||
@@ -131,7 +133,7 @@ src-tauri/src/services/ai/
|
||||
|
||||
### P1:运维效率
|
||||
|
||||
1. 任务中断和“停止”,不等待全部 8 步。
|
||||
1. 任务中断和“停止”,不等待全部 12 步。
|
||||
2. 只读组合命令的参数级评估,减少误判。
|
||||
3. 用户显式保存的 Runbook 模板,支持变量预览和逐步确认。
|
||||
4. 把已选终端文本、主机标签和系统信息组成最小上下文包,并显示实际发送范围。
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
- `infrastructure/` 只有 `file_log`,services 并不走它。
|
||||
- `application/state.rs` 是服务集合,不是端口组合根。
|
||||
- `DomainError` 零引用。
|
||||
- 稳定 IPC 错误码放在 `domain/error.rs` 的 `codes`(如 `native.taskJoin`),不是空壳 `DomainError` 类型。
|
||||
- SSH / 存储 / 桌面服务直接 `use tauri::AppHandle`;known_hosts 还用 `bind_app_handle` 全局 static。
|
||||
- 前后端契约靠手写 `src/desktop.d.ts` + `shared/types/`,没有 specta / ts-rs。
|
||||
|
||||
@@ -66,7 +66,7 @@ pub trait EventSink: Send + Sync {
|
||||
|
||||
`infrastructure/mod.rs` 里现在的「SQLite / keychain / PTY 适配器」是过期注释,做 C 轨时改成与真实 trait 一致,没做的删掉,避免再写成已存在。
|
||||
|
||||
`DomainError`:有调用方再留,否则删。不要为空壳再加一层错误类型。
|
||||
IPC 错误码继续用 `domain/error.rs` 的 `codes` 常量,不要再加一层空壳 `DomainError`。
|
||||
|
||||
## 明确不做(本轨道也不做)
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ XTerm 当前焦点分屏
|
||||
-> 当前 connectionId 的 ssh_exec
|
||||
-> stdout/stderr/exit_code + 本地 analyze_output
|
||||
-> 真实观察脱敏后交给 AI 判断下一步
|
||||
-> 最多 8 步后收敛
|
||||
-> 最多 12 步后收敛
|
||||
```
|
||||
|
||||
- Provider 配置和模型路由保存在 `ai-providers-v1`,不包含 API Key。
|
||||
@@ -108,7 +108,7 @@ XTerm 当前焦点分屏
|
||||
- `ForwardStatus` 由 Rust `TunnelManager` 管理,只在当前进程有效,包含监听端口、开始时间、连接数和流量。
|
||||
- 每个运行实例有唯一 `forwardId` 和取消标志。用户点停止 / 删除正在运行的规则、关闭标签、连接面板手动断开,或进程退出时,Rust 才关闭真实 Listener。终端 SSH 断开、空闲挂起或重连不得拆掉未停止的规则。
|
||||
- 本地 / 动态转发:本机 TCP 监听长住,每条规则共用一条专用 SSH 会话(保活;会话挂了只重建传输,不拆本地端口)。远程转发:专用会话保活,断开后在进程内自动重听。
|
||||
- SSH 重连(同一 `user@host:port`)通过 `adopt_orphans` 把仍在监听的运行实例挂到新 connectionId。运行态仍不落盘。
|
||||
- SSH 重连(同一 `user@host:port`)通过 `adopt_orphans` 把仍在监听的运行实例挂到新 connectionId。运行态仍不落盘。原生 `ssh_disconnect` 必须带真实 reason(keepalive / idle suspend / 用户关闭);空缺时用 `connection closed`,不要再写 `closed by user`。
|
||||
- 规则支持编辑和复制。复制只产生新的规则 ID,不复制运行态;运行中的规则必须先停止真实 Listener 才能编辑。
|
||||
- 应用重启后不得根据上次 UI 状态显示“运行中”,必须以 `ssh_forward_list` 返回的原生状态为准。运行态只存在当前进程内存。
|
||||
- 状态栏「转发」打开右侧抽屉:动态 SOCKS5 固定 `127.0.0.1:1080`(每台 SSH 主机一条),本地 / 远程规则可列表启停;右键状态栏项复制 `socks5://127.0.0.1:1080`。
|
||||
@@ -118,7 +118,7 @@ XTerm 当前焦点分屏
|
||||
## 远程桌面生命周期
|
||||
|
||||
- RDP / VNC 仅在 Tauri 原生应用中可用;浏览器预览不得显示为可连接。
|
||||
- 会话运行态只存在原生内存;Canvas 帧与输入经 Tauri 事件传递;断开即取消 worker。
|
||||
- 会话运行态只存在原生内存;Canvas 帧与输入经 Tauri 事件传递;断开即取消 worker。标签还开着时意外断开会有限次自动重连(新会话);不发送假指针对抗空闲 GPO。
|
||||
- RDP 可选剪贴板与共享盘(本机下载目录映射);VNC 剪贴板按 UTF-8 发送。
|
||||
- 桌面会话不参与批量命令与终端广播。
|
||||
|
||||
@@ -160,7 +160,7 @@ SSH 传输本身受协议 MAC 保护;当前应用额外执行大小校验,
|
||||
- 不持久化终端命令历史或批量命令输出。
|
||||
- 不持久化 AI 提示词、回答或终端上下文;API Key 不得返回 WebView 或写入普通 settings。
|
||||
- 未实现能力不能在界面中显示为可用。
|
||||
- 自动更新必须具备签名公钥、HTTPS 更新源和回滚策略,否则保持关闭。当前未接入 updater,做法见 [发布指南](./release.md#签名公证和自动更新未完成)。
|
||||
- 自动更新必须具备签名公钥、HTTPS 更新源和回滚策略,否则保持关闭。当前未接入 updater,做法见 [发布指南](./release.md#签名公证和自动更新)。
|
||||
- 五层目录补全、specta、EventSink 是单独轨道,见 [架构重构](./architecture-refactor.md);未开始前继续按本文件的 commands → services → platform 扩展。
|
||||
- 应用不能静默绕过 changed host key 或 known_hosts 不匹配。
|
||||
|
||||
|
||||
+85
-37
@@ -1,5 +1,34 @@
|
||||
# 发布指南
|
||||
|
||||
<a id="macos-next-steps"></a>
|
||||
|
||||
## 当前你要做的操作(v2.1.1 macOS)
|
||||
|
||||
本机已打出 **Tauri 窗口布局** 的 Developer ID 签名 DMG(不是手搓 `hdiutil`,Finder 里是大图标 + 拖到 Applications):
|
||||
|
||||
```text
|
||||
release/v2.1.1/macos-arm64/OpsXShell_2.1.1_aarch64.dmg
|
||||
```
|
||||
|
||||
这份包**还没公证、没 staple**。别人下载后仍可能被 Gatekeeper 拦。你这边只差公证:
|
||||
|
||||
1. Finder 里弹出 `/Volumes/OpsXShell`(挂载中无法 staple)。
|
||||
2. 开 Clash:`proxy-on`(公证走 HTTPS;时间戳构建阶段已经用 HTTP 回退签过了)。
|
||||
3. 不要把 App 专用密码发到聊天。终端执行其一:
|
||||
|
||||
```bash
|
||||
APPLE_ID='1024way@gmail.com' ./scripts/macos-notarize.sh
|
||||
# 或
|
||||
bash ~/Documents/apple签名/公证OpsXShell.sh
|
||||
```
|
||||
|
||||
4. 弹出框里填 **App 专用密码**(appleid.apple.com → 登录与安全 → App 专用密码,标签如 `app notary`)。
|
||||
5. 脚本会 `notarytool submit --wait --timeout 15m`。第一次可能很久;`--wait` 卡住时等它超时,再跑同一条命令——**已 Accepted 的不会重新上传,只会 staple**。不要对同一份未变化的 DMG 连续第三次 `submit`。
|
||||
6. 成功标志:`xcrun stapler validate` 通过,且 `codesign -dv` 能看到钉票。把 **这一份** DMG 发出去(尽量不要走微信,隔离属性会更烦)。
|
||||
7. 改代码后再 `./scripts/build.sh mac-arm64 --quick` 会得到新哈希,必须重新公证。
|
||||
|
||||
Windows Authenticode、自动更新仍未做,不要对外写「已公证」直到第 6 步完成。
|
||||
|
||||
## 构建命令
|
||||
|
||||
```bash
|
||||
@@ -9,6 +38,10 @@
|
||||
# macOS Apple Silicon 指定版本
|
||||
./scripts/build.sh v2.1.1 mac-arm64
|
||||
|
||||
# 构建完成后公证并钉票(新 DMG 必须再跑;已 staple 会直接退出)
|
||||
proxy-on # 本机 Clash;codesign 时间戳另见下文 HTTP 回退
|
||||
APPLE_ID='你的开发者邮箱' ./scripts/macos-notarize.sh
|
||||
|
||||
# Windows x86_64(推荐在 Windows CMD/PowerShell 中执行)
|
||||
scripts\build.bat v2.1.1 win-x64
|
||||
|
||||
@@ -87,64 +120,79 @@ lsof +D /Volumes/<临时卷名>
|
||||
|
||||
先正常退出该 OpsXShell 进程,再卸载对应的临时卷并重新构建。不要在应用仍从卷内运行时直接删除 `rw.*.dmg`。
|
||||
|
||||
## 签名、公证和自动更新(未完成)
|
||||
<a id="签名公证和自动更新未完成"></a>
|
||||
|
||||
**当前状态:没有签名凭据,公开包是 ad-hoc / 未签名,未公证,没有自动更新。**
|
||||
没有 `APPLE_*` / Authenticode 证书、minisign 私钥和 HTTPS 更新源之前,不得在 README、界面、Release 说明或 CI 绿勾上写「已签名」「已公证」「支持自动更新」。`tauri.conf.json` 的 `signingIdentity` 现在是 `"-"`;`plugins` 只有 deep-link,没有 updater。
|
||||
## 签名、公证和自动更新
|
||||
|
||||
现有门禁只做「有凭据才验、没凭据就标明开发包」:
|
||||
申请账号和 Developer ID、导入钥匙串的完整步骤见 [download/Apple开发者签名.md](../download/Apple开发者签名.md)。
|
||||
|
||||
**当前状态(本机可签 macOS;这份 DMG 待公证;自动更新仍未做):**
|
||||
|
||||
- 仓库 `tauri.conf.json` 的 `signingIdentity` 仍是 `"-"`,没证书的环境还能打 ad-hoc 开发包。
|
||||
- 本机若已导入 **Developer ID Application**,把身份写进 **gitignore** 的 `src-tauri/tauri.signing.conf.json`。`./scripts/build.sh` 发现该文件就会按 Developer ID 签名。
|
||||
- DMG 必须走 Tauri bundler(`bundle.macOS.dmg`:窗口 720×440,应用图标与 Applications 分列)。不要用手搓 `hdiutil create`,Finder 图标会变小。
|
||||
- `timestamp.apple.com:443` 不通时,构建会用 `scripts/macos-codesign-wrapper.sh` 走 HTTP 时间戳。可设 `CODESIGN_USE_SYSTEM_TIMESTAMP=1` 关闭。
|
||||
- **每次新打的 DMG 都必须再公证并 staple**:`APPLE_ID='…' ./scripts/macos-notarize.sh`。已钉票会直接退出。`--wait` 卡住时脚本会超时后尝试 staple。
|
||||
- 证书、CSR、私钥放本机文稿 `~/Documents/apple签名/`,不要进 git。Team 内其他 Mac 桌面应用可复用同一张 Developer ID。
|
||||
- Windows Authenticode、minisign 自动更新仍未接线。没有凭据、或尚未 staple 时,不得在 README / 界面 / CI 上写「已签名」「已公证」「支持自动更新」。
|
||||
|
||||
门禁:
|
||||
|
||||
- `REQUIRE_SIGNING=true` 时,`scripts/release-preflight.mjs` 检查环境变量齐全,`scripts/verify-release.mjs` 拒绝 macOS ad-hoc、要求 Windows Authenticode `Valid`。
|
||||
- 未设该变量时,`./scripts/build.sh` 仍可打开发包,验证只检查架构和体积。
|
||||
- CI 工作流 `.github/workflows/tauri-release.yml` 已能在 Secrets 齐全时写入 `src-tauri/tauri.signing.conf.json` 再构建;**本地 `build.sh` 还不会生成这份配置**,所以本机即使导出了同样的环境变量,也不会走出 ad-hoc。
|
||||
- 全仓库没有 `notarytool` / `staple`。Developer ID 签过但未公证的 `.app`,用户机器上仍会被 Gatekeeper 拦。
|
||||
- `scripts/release.py` 仍提示「右键打开」——这是 ad-hoc 包的权宜说明,正式公证后必须删掉。
|
||||
- 未设该变量时,`./scripts/build.sh` 仍可打开发包;若存在 `tauri.signing.conf.json` 则会签 Developer ID。
|
||||
- CI `.github/workflows/tauri-release.yml` 在 Secrets 齐全时写入 `tauri.signing.conf.json`。本机用同一文件名即可,不要提交。
|
||||
|
||||
下面是有凭据之后的做法。没齐之前不要改 `signingIdentity`、不要接 updater 插件、不要在设置里放「检查更新」。
|
||||
### 1. 凭据
|
||||
|
||||
### 1. 凭据(先买/申请,再接线)
|
||||
| 平台 | 要什么 | 放到哪里 |
|
||||
| -------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| macOS | Apple Developer Program;**Developer ID Application**(不是 Development);App 专用密码 | 本机钥匙串 + gitignore 的 `tauri.signing.conf.json`;CI Secrets:`APPLE_CERTIFICATE`(p12 的 base64)、`APPLE_CERTIFICATE_PASSWORD`、`APPLE_SIGNING_IDENTITY`、`APPLE_ID`、`APPLE_PASSWORD`、`APPLE_TEAM_ID` |
|
||||
| Windows | 代码签名证书(OV/EV PFX) | `WINDOWS_CERTIFICATE_BASE64`、`WINDOWS_CERTIFICATE_PASSWORD` |
|
||||
| 自动更新 | 单独的 **minisign** 密钥对,与 Apple/Authenticode 不是同一把 | 私钥只进 CI Secrets;**公钥**写进仓库的 updater 配置 |
|
||||
|
||||
| 平台 | 要什么 | 放到哪里 |
|
||||
| -------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| macOS | Apple Developer Program;**Developer ID Application** 证书(`.p12`);App 专用密码 | 本机环境变量 + GitHub/Gitee Secrets:`APPLE_CERTIFICATE`(p12 的 base64)、`APPLE_CERTIFICATE_PASSWORD`、`APPLE_SIGNING_IDENTITY`(如 `Developer ID Application: …`)、`APPLE_ID`、`APPLE_PASSWORD`、`APPLE_TEAM_ID` |
|
||||
| Windows | 代码签名证书(OV/EV PFX) | `WINDOWS_CERTIFICATE_BASE64`、`WINDOWS_CERTIFICATE_PASSWORD` |
|
||||
| 自动更新 | 单独的 **minisign** 密钥对,与 Apple/Authenticode 不是同一把 | 私钥只进 CI Secrets;**公钥**写进仓库的 updater 配置 |
|
||||
`src-tauri/tauri.signing.conf.json` 必须进 `.gitignore`,不得提交。macOS 示例:
|
||||
|
||||
Linux 安装包(deb/rpm/AppImage)不走 Apple/Authenticode。自动更新的 `.sig` 是另一套签名,三平台都要。
|
||||
|
||||
`src-tauri/tauri.signing.conf.json` 由脚本现场生成,必须进 `.gitignore`,不得提交。
|
||||
```json
|
||||
{
|
||||
"bundle": {
|
||||
"macOS": {
|
||||
"signingIdentity": "Developer ID Application: Logan Li (B8DVUUUF6W)"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 本地与 CI 同源签名
|
||||
|
||||
目标:本机 `REQUIRE_SIGNING=true ./scripts/build.sh` 与 CI 走同一条路径,都能复现同一类签名产物。
|
||||
|
||||
1. `build.sh` / `build.bat` 在签名变量齐全时,生成 `tauri.signing.conf.json`(macOS 写 `bundle.macOS.signingIdentity`;Windows 写 thumbprint / sha256 / timestamp),再 `npx tauri build --config src-tauri/tauri.signing.conf.json`。
|
||||
2. 不要改仓库里的 `tauri.conf.json` 默认 `"-"`——没凭据的开发机构必须还能打 ad-hoc。
|
||||
3. `REQUIRE_SIGNING=true` 时 `verify-release.mjs` 必须失败于 ad-hoc / 无效 Authenticode;不要为了绿而关校验。
|
||||
4. 正式标签才设 `REQUIRE_SIGNING=true`。日常 PR 构建保持未签名。
|
||||
1. 不要改仓库里的 `tauri.conf.json` 默认 `"-"`。
|
||||
2. `REQUIRE_SIGNING=true` 时 `verify-release.mjs` 必须失败于 ad-hoc。
|
||||
3. 正式标签才设 `REQUIRE_SIGNING=true`。日常 PR 保持未签名。
|
||||
|
||||
### 3. macOS 公证(notarytool + staple)
|
||||
|
||||
签名成功 ≠ 用户能打开。必须公证并钉票:
|
||||
签名成功 ≠ 用户能打开。必须公证并钉票。不要对同一份未变化的 DMG 连续多次 `submit`。
|
||||
|
||||
```bash
|
||||
# 构建并 Developer ID 签名之后
|
||||
xcrun notarytool submit "$DMG" \
|
||||
--apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID" \
|
||||
--wait
|
||||
xcrun stapler staple "$DMG"
|
||||
# 若 staple 的是 .app,再重新打 DMG,或对 DMG 再 staple
|
||||
spctl --assess --type install --verbose "$DMG"
|
||||
# 先弹出已挂载的安装盘,再:
|
||||
APPLE_ID='开发者邮箱' ./scripts/macos-notarize.sh
|
||||
# 可选:指定 DMG 路径
|
||||
APPLE_ID='开发者邮箱' ./scripts/macos-notarize.sh release/v2.1.1/macos-arm64/OpsXShell_2.1.1_aarch64.dmg
|
||||
```
|
||||
|
||||
验收(必须在**另一台未关 SIP、未关 Gatekeeper 的 Mac**上,不要只在构建机看 `spctl`):
|
||||
脚本会:已 staple 则退出;否则先 staple;没有票据才要 App 专用密码并 `notarytool submit --wait --timeout 15m`,超时仍 staple。开发者后台网页没有这份 DMG 的进度页,用终端或:
|
||||
|
||||
1. 下载 DMG,双击打开,拖到「应用程序」,**不要**右键「打开」、不要 `xattr -d`。
|
||||
```bash
|
||||
xcrun notarytool info <submission-id> --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID"
|
||||
xcrun notarytool log <submission-id> --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID"
|
||||
```
|
||||
|
||||
验收(必须在**另一台未关 SIP、未关 Gatekeeper 的 Mac**上):
|
||||
|
||||
1. 下载 **staple 之后** 的 DMG,双击打开,拖到「应用程序」,不要右键「打开」、不要 `xattr -d`。
|
||||
2. 能启动、能连 SSH,菜单里没有 DevTools。
|
||||
3. `codesign --verify --deep --strict` 且 `codesign -dv` 不是 `Signature=adhoc`。
|
||||
4. 删掉 Release 说明和 `release.py` 里的「右键绕过 Gatekeeper」。
|
||||
3. `codesign --verify --deep --strict` 且 `codesign -dv` 含 `Notarization Ticket=stapled`,不是 `Signature=adhoc`。
|
||||
|
||||
没做完 staple 之前,文档继续写「未公证」。
|
||||
没做完 staple 之前,对外说明不要写「已公证」。
|
||||
|
||||
### 4. Windows Authenticode
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ npm run acceptance:full
|
||||
9. 新增 Provider 后测试前“保存”禁用;测试实际发送“你是什么模型?请简短回答。”,期间 settings 与 secrets 均无写入;只有非空成功回答显示 HTTP 200,随后保存才写入加密 Key。
|
||||
10. 切换 Endpoint、模型、协议或 API Key 后“保存”立即重新禁用。
|
||||
11. 每一轮只接受唯一单行命令;默认宽松模式下 safe/low/medium 自动执行、high 先确认;严格模式从 medium 起确认;critical 前后端均硬拒绝。需要确认时,对话框打开前和用户取消后 `ssh_exec` 调用次数必须为 0。Agent 发起的 `ssh_exec` 必须带 `fromAgent`。
|
||||
12. 确认后只向当前 connectionId 执行;真实输出和 exit code 必须交给下一轮,直到 FINAL 或 8 步上限。
|
||||
12. 确认后只向当前 connectionId 执行;真实输出和 exit code 必须交给下一轮,直到 FINAL 或 12 步上限。
|
||||
13. `rm -rf /`、`mkfs`、对磁盘覆盖的 `dd` 等 critical 命令必须在确认对话框前硬拒绝。
|
||||
14. 相同命令第 3 次必须在执行前拦截;连续失败的下一轮 Prompt 必须要求更换路径。
|
||||
15. 超长工具输出必须限长,`analyze_output` 保留总行数、错误行和首尾;关闭面板后命令与输出消失。
|
||||
|
||||
+21
-5
@@ -2,6 +2,12 @@
|
||||
|
||||
OpsXShell 主打简单好用:常用远程操作走短路径,高级选项默认收起。不做堡垒审计。
|
||||
|
||||
## 安装(macOS)
|
||||
|
||||
从 `release/v*/macos-arm64/` 打开 `.dmg`,把 OpsXShell 拖到「应用程序」。Finder 里应是 Tauri 那套大图标布局(应用在左、Applications 在右)。**已经 staple 的正式包**一般可直接打开。
|
||||
|
||||
自己改代码后重新打包:先 `./scripts/build.sh mac-arm64 --quick`(本机有签名配置就会签 Developer ID),再 `./scripts/macos-notarize.sh`(会要 App 专用密码)。新打的 DMG 在公证完成前仍会被 Gatekeeper 拦截。你现在要做的步骤见 [发布指南](./release.md#macos-next-steps)。
|
||||
|
||||
## 快速连接
|
||||
|
||||
按 `Cmd/Ctrl + K`,输入 `user@host` 或 `user@host:port` 后连接。默认端口为 `22`。快速连接适合临时使用,不会自动保存会话。
|
||||
@@ -47,13 +53,23 @@ socks5://127.0.0.1:1080
|
||||
- 保存的是规则,不是“运行中”状态;应用重启后需要用户再次启动。同一窗口内只要没点「停止」,SSH 断线、保活失败、空闲挂起或重连都会继续监听(本地 / SOCKS 端口不拆,远程转发会自动重听)。点停止、关闭标签、连接面板手动断开,或退出应用,才会关闭真实 Listener。关闭标签后再开同一会话不会自动拉起转发。
|
||||
- SOCKS5 默认仅监听本机,当前不提供局域网开放和客户端密码认证。
|
||||
|
||||
## 连接保活与重连
|
||||
|
||||
默认对齐常见 SSH / RDP 客户端,分三层,不要把「窗口还开着」理解成无视服务器超时:
|
||||
|
||||
- **链路心跳**:空闲时发 SSH 协议 keep-alive(不写入 Shell)。挡的是 NAT/防火墙丢掉空闲 TCP,挡不住 Bash `TMOUT` 或 Windows 空闲 GPO。
|
||||
- **意外断线**:终端、SFTP 或远程桌面标签还开着时,有限次自动重连。SSH 重连后是新 Shell(长任务请在服务器上用 `tmux`/`screen`);SFTP 会回到断线前的远程目录;RDP/VNC 重连是新会话,不会发假鼠标。
|
||||
- **会话策略**:远端 `TMOUT` / `logout` 只关掉那一格 Shell,点「重新打开 Shell」即可。关标签、空闲挂起不会硬续连接。
|
||||
|
||||
设置里的「保持心跳」和「自动重连」对应前两层;第三层尊重服务器,客户端不伪造按键。
|
||||
|
||||
## 远程桌面(RDP / VNC)
|
||||
|
||||
欢迎页或会话入口可打开 RDP / VNC 连接对话框(仅原生桌面应用):
|
||||
|
||||
- RDP:填写主机、端口、用户名、密码与可选域名;原生 IronRDP 引擎,支持剪贴板同步与共享盘(本机下载目录对应远端共享盘)。
|
||||
- RDP:填写主机、端口、用户名、密码与可选域名;原生 IronRDP 引擎,支持剪贴板同步与共享盘(本机下载目录对应远端共享盘)。画面铺满当前窗口(任务栏贴底),连接时按窗口客户区请求分辨率;窗口缩放后再走 Display Control。
|
||||
- VNC:填写主机、端口与可选密码;原生 RFB 引擎,剪贴板按 UTF-8 发送。
|
||||
- 连接后在独立标签中显示画面;工具栏可粘贴本机剪贴板、放入共享盘文件(RDP)或断开。
|
||||
- 连接后在独立标签中显示画面;工具栏可粘贴本机剪贴板、放入共享盘文件(RDP)或重新连接。标签还开着时网络闪断会有限次自动重连(新会话,不对抗服务器空闲注销)。
|
||||
- 桌面会话不参与批量命令与终端广播。
|
||||
|
||||
## 导入 OpenSSH 配置
|
||||
@@ -69,7 +85,7 @@ socks5://127.0.0.1:1080
|
||||
|
||||
包含 `*`、`?` 或 `!` 的 Host 模式不会生成会话。导入后请检查目标、用户名、密钥路径和跳板关系再连接。
|
||||
|
||||
会话导出为 JSON,不包含密码、私钥口令或代理密码;私钥路径会保留,便于在同一台电脑恢复配置。
|
||||
会话导出默认保存为 `opsxshell-hosts.oxs`(密码加密的主机清单,格式类似 KeePass `.kdbx`、MobaXterm `.mxtsessions`)。旧版 `.bak` 与 OpenSSH `config` 仍可导入。导出包含主机分组、密钥和密码,务必保管备份密码。
|
||||
|
||||
## SFTP
|
||||
|
||||
@@ -82,7 +98,7 @@ SFTP 使用双栏文件管理器。中间箭头可上传/下载;上传和下
|
||||
- 当前拖拽只支持单个文件,不递归传输目录;同侧拖拽不会执行移动或重命名。
|
||||
- 远程文本编辑直接读写远程文件,不会在本地创建同名文件,也不等同于上传或下载。可编辑类型按扩展名排除归档/二进制等;单文件约 **2MB** 上限,超限、二进制或非 UTF-8 会提示并拒绝打开,请下载后用本地编辑器处理。
|
||||
- 上传/下载若目标已存在同名项,会询问「覆盖」或「跳过」;失败或取消的任务可在传输队列里一键重试。
|
||||
- SSH 断线后,SFTP 一键重连会优先回到断线前的远程目录;进行中的传输会标为失败,可重试。终端标签本身也支持一键重新连接(及可选自动重连)。
|
||||
- SSH 断线后,SFTP 会按与终端相同的有限次自动重连,并优先回到断线前的远程目录;进行中的传输会标为失败,可重试。也可一键手动重连。
|
||||
- 刷新时状态栏显示「正在刷新…」,传输忙时浏览类操作会立即返回忙提示而不是卡住界面。
|
||||
- 传输失败会在传输队列和界面通知中显示经多语言映射后的原因。
|
||||
|
||||
@@ -120,7 +136,7 @@ SFTP 使用双栏文件管理器。中间箭头可上传/下载;上传和下
|
||||
|
||||
- 直接输入任务,例如“排查 nginx 为什么启动失败并修复”。回车发送,`Shift+回车` 换行;不需要先选“解释 / 生成命令 / 排障 / 执行”。
|
||||
- 有终端选区时会作为初始上下文;没有选区时不抓取整屏,Agent 通过真实 `ssh-exec` 自主取证。
|
||||
- Agent 最多执行 8 步。每步只能准备一条单行命令,界面会显示当前主机、命令和五级风险。默认「宽松」仅高风险命令确认;「严格」从中风险开始确认。critical(格式化磁盘、删除根目录等)始终硬拒绝,后端也会再拦一次。
|
||||
- Agent 最多执行 12 步。每步只能准备一条单行命令,界面会显示当前主机、命令和五级风险。默认「宽松」仅高风险命令确认;「严格」从中风险开始确认。critical(格式化磁盘、删除根目录等)始终硬拒绝,后端也会再拦一次。
|
||||
- 格式化磁盘、删除根目录等 critical 命令会被硬拒绝,不提供确认放行。相同命令第 3 次会在执行前拦截。
|
||||
- 执行后的真实输出和退出码会回给 AI,用于判断下一步或收敛结论。连续失败时 Agent 会被要求换方法。
|
||||
- 长输出由本地 `analyze_output` 提取错误行并保留首尾;Rust 在发给云模型前做常见敏感信息脱敏。
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
+16
-3
@@ -176,10 +176,23 @@ esac
|
||||
node scripts/release-preflight.mjs "$RELEASE_PLATFORM"
|
||||
|
||||
log "构建原生安装包"
|
||||
TAURI_ARGS=(--ci --target "$RUST_TARGET")
|
||||
CODESIGN_WRAP_DIR=""
|
||||
if [ "$RELEASE_PLATFORM" = "macos" ]; then
|
||||
npx tauri build --ci --target "$RUST_TARGET" --bundles app,dmg
|
||||
else
|
||||
npx tauri build --ci --target "$RUST_TARGET"
|
||||
TAURI_ARGS+=(--bundles app,dmg)
|
||||
CODESIGN_WRAP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/opsx-codesign.XXXXXX")"
|
||||
cp "$ROOT_DIR/scripts/macos-codesign-wrapper.sh" "$CODESIGN_WRAP_DIR/codesign"
|
||||
chmod +x "$CODESIGN_WRAP_DIR/codesign"
|
||||
export PATH="$CODESIGN_WRAP_DIR:$PATH"
|
||||
log "macOS 签名使用 HTTP 时间戳(可用 CODESIGN_USE_SYSTEM_TIMESTAMP=1 关闭)"
|
||||
fi
|
||||
if [ -f "$ROOT_DIR/src-tauri/tauri.signing.conf.json" ]; then
|
||||
TAURI_ARGS+=(--config src-tauri/tauri.signing.conf.json)
|
||||
log "使用 src-tauri/tauri.signing.conf.json 签名"
|
||||
fi
|
||||
npx tauri build "${TAURI_ARGS[@]}"
|
||||
if [ -n "$CODESIGN_WRAP_DIR" ]; then
|
||||
rm -rf "$CODESIGN_WRAP_DIR"
|
||||
fi
|
||||
|
||||
BUNDLE_ROOT="${CARGO_TARGET_DIR:-$ROOT_DIR/src-tauri/target}/$RUST_TARGET/release/bundle"
|
||||
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tauri 默认 `codesign --timestamp` 走 timestamp.apple.com:443。
|
||||
# 部分网络(含仅开 HTTP 代理、443 不通)会报 “The timestamp service is not available.”
|
||||
# 构建时把本脚本以 `codesign` 之名放进 PATH,改为 RFC 3161 HTTP 时间戳。
|
||||
# 关闭:CODESIGN_USE_SYSTEM_TIMESTAMP=1
|
||||
set -euo pipefail
|
||||
REAL="/usr/bin/codesign"
|
||||
TS="${APPLE_TIMESTAMP_URL:-http://timestamp.apple.com/ts01}"
|
||||
|
||||
if [ "${CODESIGN_USE_SYSTEM_TIMESTAMP:-}" = "1" ]; then
|
||||
exec "$REAL" "$@"
|
||||
fi
|
||||
|
||||
signing=0
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--sign|-s) signing=1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$signing" -eq 0 ]; then
|
||||
exec "$REAL" "$@"
|
||||
fi
|
||||
|
||||
new=()
|
||||
found=0
|
||||
for a in "$@"; do
|
||||
if [ "$a" = "--timestamp" ] || [[ "$a" == --timestamp=* ]]; then
|
||||
new+=("--timestamp=$TS")
|
||||
found=1
|
||||
else
|
||||
new+=("$a")
|
||||
fi
|
||||
done
|
||||
if [ "$found" -eq 0 ]; then
|
||||
new+=("--timestamp=$TS")
|
||||
fi
|
||||
exec "$REAL" "${new[@]}"
|
||||
Executable
+129
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env bash
|
||||
# 对已经 Developer ID 签名的 DMG 做 Apple 公证并 staple。
|
||||
# 已钉票则直接退出,避免重复上传。
|
||||
# notarytool --wait 有时会一直 In Progress,即使苹果已 Accepted;超时后仍尝试 staple。
|
||||
set -u
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
VERSION="$(node -p "require('$ROOT_DIR/package.json').version")"
|
||||
DMG="${1:-$ROOT_DIR/release/v$VERSION/macos-arm64/OpsXShell_${VERSION}_aarch64.dmg}"
|
||||
APPLE_ID="${APPLE_ID:-}"
|
||||
TEAM_ID="${APPLE_TEAM_ID:-B8DVUUUF6W}"
|
||||
WAIT_TIMEOUT="${NOTARY_WAIT_TIMEOUT:-15m}"
|
||||
: "${WAIT_TIMEOUT:=15m}"
|
||||
|
||||
if [ ! -f "$DMG" ]; then
|
||||
echo "找不到安装包:$DMG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 挂载中的 DMG 无法 staple / validate
|
||||
if hdiutil info 2>/dev/null | grep -Fq "$DMG"; then
|
||||
echo "DMG 仍挂载,请先在 Finder 弹出 /Volumes 下的 OpsXShell,再重试。" >&2
|
||||
hdiutil info | grep -E "image-path|/Volumes/" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export http_proxy="${http_proxy:-http://127.0.0.1:7897}"
|
||||
export https_proxy="${https_proxy:-http://127.0.0.1:7897}"
|
||||
export HTTP_PROXY="$http_proxy"
|
||||
export HTTPS_PROXY="$https_proxy"
|
||||
export ALL_PROXY="${ALL_PROXY:-socks5://127.0.0.1:7897}"
|
||||
export no_proxy="localhost,127.0.0.1,::1"
|
||||
export NO_PROXY="$no_proxy"
|
||||
|
||||
already_stapled() {
|
||||
xcrun stapler validate "$DMG" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
try_staple() {
|
||||
echo "尝试 staple(从苹果拉票据钉进 DMG,不需要专用密码)…"
|
||||
if xcrun stapler staple "$DMG"; then
|
||||
xcrun stapler validate "$DMG"
|
||||
echo
|
||||
echo "完成(已钉票):$DMG"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
if already_stapled; then
|
||||
echo "这份 DMG 已经公证并 staple,不用再执行。"
|
||||
echo "$DMG"
|
||||
xcrun stapler validate "$DMG"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "尚未钉票。先看苹果是否已经公证通过(常见于 --wait 卡住)。"
|
||||
if try_staple; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "$APPLE_ID" ]; then
|
||||
APPLE_ID="$(
|
||||
osascript <<'APPLESCRIPT'
|
||||
tell application "System Events"
|
||||
activate
|
||||
set dlg to display dialog "Apple ID(开发者账号邮箱)" default answer "" with title "OpsXShell 公证" buttons {"取消", "继续"} default button "继续" cancel button "取消"
|
||||
return text returned of dlg
|
||||
end tell
|
||||
APPLESCRIPT
|
||||
)" || exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "需要提交公证。请输入 App 专用密码,不要发到聊天。"
|
||||
PASSWORD="$(
|
||||
osascript <<'APPLESCRIPT'
|
||||
tell application "System Events"
|
||||
activate
|
||||
set dlg to display dialog "App 专用密码(xxxx-xxxx-xxxx-xxxx)" default answer "" with hidden answer with title "OpsXShell 公证" buttons {"取消", "提交"} default button "提交" cancel button "取消"
|
||||
return text returned of dlg
|
||||
end tell
|
||||
APPLESCRIPT
|
||||
)" || {
|
||||
echo "已取消。" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [ -z "$PASSWORD" ] || [ -z "$APPLE_ID" ]; then
|
||||
echo "未输入 Apple ID 或专用密码,已取消。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "正在提交 ${DMG} (最多等 ${WAIT_TIMEOUT}) …"
|
||||
set +e
|
||||
SUBMIT_LOG="$(mktemp)"
|
||||
xcrun notarytool submit "$DMG" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$PASSWORD" \
|
||||
--team-id "$TEAM_ID" \
|
||||
--wait \
|
||||
--timeout "${WAIT_TIMEOUT}" | tee "$SUBMIT_LOG"
|
||||
SUBMIT_STATUS="${PIPESTATUS[0]}"
|
||||
set -e
|
||||
|
||||
SUB_ID="$(awk '/id: / { print $2; exit }' "$SUBMIT_LOG")"
|
||||
rm -f "$SUBMIT_LOG"
|
||||
|
||||
if [ -n "$SUB_ID" ]; then
|
||||
echo "提交号:$SUB_ID"
|
||||
fi
|
||||
|
||||
if [ "$SUBMIT_STATUS" -eq 0 ]; then
|
||||
echo "notarytool 报告处理结束。"
|
||||
else
|
||||
echo "等待超时或状态未刷新 (exit ${SUBMIT_STATUS}). 改为直接 staple。"
|
||||
fi
|
||||
|
||||
if try_staple; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo >&2
|
||||
echo "staple 仍失败。可等几分钟再执行本脚本(已公证则不会重新上传)。" >&2
|
||||
if [ -n "${SUB_ID:-}" ]; then
|
||||
echo " xcrun notarytool info $SUB_ID --apple-id \"$APPLE_ID\" --team-id \"$TEAM_ID\"" >&2
|
||||
fi
|
||||
echo "不要对同一份未变化的 DMG 连续第三次提交。" >&2
|
||||
exit 1
|
||||
+1
-1
@@ -137,7 +137,7 @@ def release_body(version: str, repo: str, assets: list[Path]) -> str:
|
||||
"### macOS 安装",
|
||||
"",
|
||||
"1. 打开 DMG,将 OpsXShell 拖入 Applications。",
|
||||
"2. 当前公开包为 ad-hoc 开发签名且尚未 Apple 公证;首次启动如被 Gatekeeper 拦截,请在 Finder 中右键应用并选择“打开”。",
|
||||
"2. 已 Developer ID 签名并公证、staple 的包可直接打开。若仍提示无法验证,请确认下载的是钉票后的 DMG,或在 Finder 右键应用选择“打开”。",
|
||||
"",
|
||||
"### 完整性校验",
|
||||
"",
|
||||
|
||||
+13
-21
@@ -21,10 +21,15 @@ export interface SSHConnectRequest {
|
||||
keepAlive?: boolean
|
||||
}
|
||||
|
||||
export interface SSHConnectResponse {
|
||||
/** Business command envelope. Do not invent a third failure shape beside this and thrown RDP/VNC runtime errors. */
|
||||
export interface IpcEnvelope {
|
||||
success: boolean
|
||||
connectionId?: string
|
||||
error?: string
|
||||
errorCode?: string
|
||||
}
|
||||
|
||||
export interface SSHConnectResponse extends IpcEnvelope {
|
||||
connectionId?: string
|
||||
}
|
||||
|
||||
export interface SSHExecRequest {
|
||||
@@ -32,11 +37,9 @@ export interface SSHExecRequest {
|
||||
command: string
|
||||
}
|
||||
|
||||
export interface SSHExecResponse {
|
||||
success: boolean
|
||||
export interface SSHExecResponse extends IpcEnvelope {
|
||||
result?: string
|
||||
exitCode?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface SSHCreateShellRequest {
|
||||
@@ -83,11 +86,9 @@ export interface SSHForwardDynamicRequest {
|
||||
localPort: number
|
||||
}
|
||||
|
||||
export interface SSHForwardResponse {
|
||||
success: boolean
|
||||
export interface SSHForwardResponse extends IpcEnvelope {
|
||||
forwardId?: string
|
||||
boundPort?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface SSHForwardStatus {
|
||||
@@ -105,30 +106,21 @@ export interface SSHForwardStatus {
|
||||
bytesDown: number
|
||||
}
|
||||
|
||||
export interface SSHForwardListResponse {
|
||||
success: boolean
|
||||
export interface SSHForwardListResponse extends IpcEnvelope {
|
||||
tunnels?: SSHForwardStatus[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
// ============== Session IPC ==============
|
||||
|
||||
export interface SessionCreateResponse {
|
||||
success: boolean
|
||||
export interface SessionCreateResponse extends IpcEnvelope {
|
||||
session?: Session
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface SessionUpdateResponse {
|
||||
success: boolean
|
||||
export interface SessionUpdateResponse extends IpcEnvelope {
|
||||
session?: Session
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface SessionDeleteResponse {
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
export type SessionDeleteResponse = IpcEnvelope
|
||||
|
||||
// ============== Settings IPC ==============
|
||||
|
||||
|
||||
+180
-46
@@ -118,16 +118,28 @@ pub fn show_open_dialog(options: DialogOptions) -> DialogResult {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn show_save_dialog(options: DialogOptions) -> DialogResult {
|
||||
configure_dialog(rfd::FileDialog::new(), &options)
|
||||
pub fn show_save_dialog(app: AppHandle, options: DialogOptions) -> DialogResult {
|
||||
let mut dialog = configure_dialog(rfd::FileDialog::new(), &options);
|
||||
let missing_dir = options
|
||||
.default_path
|
||||
.as_deref()
|
||||
.map(resolve_dialog_path)
|
||||
.map(|path| path.directory.is_none())
|
||||
.unwrap_or(true);
|
||||
if missing_dir {
|
||||
if let Ok(downloads) = app.path().download_dir() {
|
||||
dialog = dialog.set_directory(downloads);
|
||||
}
|
||||
}
|
||||
dialog
|
||||
.save_file()
|
||||
.map(|path| path_result(path, false))
|
||||
.unwrap_or_else(canceled)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn read_file(file_path: String) -> ReadFileResponse {
|
||||
match fs::read(expand_home(&file_path)) {
|
||||
pub async fn read_file(file_path: String) -> ReadFileResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || match fs::read(expand_home(&file_path)) {
|
||||
Ok(data) => ReadFileResponse {
|
||||
success: true,
|
||||
data: Some(STANDARD.encode(data)),
|
||||
@@ -138,18 +150,33 @@ pub fn read_file(file_path: String) -> ReadFileResponse {
|
||||
data: None,
|
||||
error: Some(error.to_string()),
|
||||
},
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| ReadFileResponse {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some(crate::domain::error::codes::TASK_JOIN.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn write_file(file_path: String, data: String) -> OperationResponse {
|
||||
let result = STANDARD
|
||||
.decode(data)
|
||||
.map_err(|error| format!("Base64 数据无效: {error}"))
|
||||
.and_then(|bytes| {
|
||||
fs::write(expand_home(&file_path), bytes).map_err(|error| error.to_string())
|
||||
});
|
||||
operation(result)
|
||||
pub async fn write_file(file_path: String, data: String) -> OperationResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || match STANDARD.decode(&data) {
|
||||
Err(error) => OperationResponse::coded(
|
||||
crate::domain::error::codes::INVALID_BASE64,
|
||||
error.to_string(),
|
||||
),
|
||||
Ok(bytes) => {
|
||||
operation(fs::write(expand_home(&file_path), bytes).map_err(|error| error.to_string()))
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"write file background task failed",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -187,8 +214,8 @@ pub fn open_downloads_folder(app: AppHandle) -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_read_dir(path: String) -> LocalFilesResponse {
|
||||
match read_local_dir(&expand_home(&path)) {
|
||||
pub async fn local_read_dir(path: String) -> LocalFilesResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || match read_local_dir(&expand_home(&path)) {
|
||||
Ok(files) => LocalFilesResponse {
|
||||
success: true,
|
||||
files: Some(files),
|
||||
@@ -199,48 +226,89 @@ pub fn local_read_dir(path: String) -> LocalFilesResponse {
|
||||
files: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| LocalFilesResponse {
|
||||
success: false,
|
||||
files: None,
|
||||
error: Some(crate::domain::error::codes::TASK_JOIN.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_get_stats(path: String) -> LocalStatsResponse {
|
||||
let path = expand_home(&path);
|
||||
match local_entry(&path) {
|
||||
Ok(stats) => LocalStatsResponse {
|
||||
success: true,
|
||||
stats: Some(stats),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => LocalStatsResponse {
|
||||
success: false,
|
||||
stats: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
pub async fn local_get_stats(path: String) -> LocalStatsResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let path = expand_home(&path);
|
||||
match local_entry(&path) {
|
||||
Ok(stats) => LocalStatsResponse {
|
||||
success: true,
|
||||
stats: Some(stats),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => LocalStatsResponse {
|
||||
success: false,
|
||||
stats: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| LocalStatsResponse {
|
||||
success: false,
|
||||
stats: None,
|
||||
error: Some(crate::domain::error::codes::TASK_JOIN.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_mkdir(path: String) -> OperationResponse {
|
||||
operation(fs::create_dir_all(expand_home(&path)).map_err(|error| error.to_string()))
|
||||
pub async fn local_mkdir(path: String) -> OperationResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
operation(fs::create_dir_all(expand_home(&path)).map_err(|error| error.to_string()))
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"mkdir background task failed",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_delete(path: String) -> OperationResponse {
|
||||
let path = expand_home(&path);
|
||||
let result = if path.is_dir() {
|
||||
fs::remove_dir_all(path)
|
||||
} else {
|
||||
fs::remove_file(path)
|
||||
};
|
||||
operation(result.map_err(|error| error.to_string()))
|
||||
pub async fn local_delete(path: String) -> OperationResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let path = expand_home(&path);
|
||||
let result = if path.is_dir() {
|
||||
fs::remove_dir_all(path)
|
||||
} else {
|
||||
fs::remove_file(path)
|
||||
};
|
||||
operation(result.map_err(|error| error.to_string()))
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"delete background task failed",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn local_rename(old_path: String, new_path: String) -> OperationResponse {
|
||||
operation(
|
||||
fs::rename(expand_home(&old_path), expand_home(&new_path))
|
||||
.map_err(|error| error.to_string()),
|
||||
)
|
||||
pub async fn local_rename(old_path: String, new_path: String) -> OperationResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
operation(
|
||||
fs::rename(expand_home(&old_path), expand_home(&new_path))
|
||||
.map_err(|error| error.to_string()),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"rename background task failed",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -251,12 +319,52 @@ pub fn open_external(url: String) -> Result<(), String> {
|
||||
open::that(url).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
struct DialogPath {
|
||||
directory: Option<PathBuf>,
|
||||
file_name: Option<String>,
|
||||
}
|
||||
|
||||
fn resolve_dialog_path(raw: &str) -> DialogPath {
|
||||
let expanded = expand_home(raw);
|
||||
if expanded.as_os_str().is_empty() {
|
||||
return DialogPath::default();
|
||||
}
|
||||
if expanded.is_dir() {
|
||||
return DialogPath {
|
||||
directory: Some(expanded),
|
||||
file_name: None,
|
||||
};
|
||||
}
|
||||
let file_name = expanded
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.filter(|name| !name.is_empty());
|
||||
let directory = expanded.parent().and_then(|parent| {
|
||||
if parent.as_os_str().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parent.to_path_buf())
|
||||
}
|
||||
});
|
||||
DialogPath {
|
||||
directory,
|
||||
file_name,
|
||||
}
|
||||
}
|
||||
|
||||
fn configure_dialog(mut dialog: rfd::FileDialog, options: &DialogOptions) -> rfd::FileDialog {
|
||||
if let Some(title) = &options.title {
|
||||
dialog = dialog.set_title(title);
|
||||
}
|
||||
if let Some(path) = &options.default_path {
|
||||
dialog = dialog.set_directory(expand_home(path));
|
||||
let resolved = resolve_dialog_path(path);
|
||||
if let Some(dir) = resolved.directory {
|
||||
dialog = dialog.set_directory(dir);
|
||||
}
|
||||
if let Some(name) = resolved.file_name {
|
||||
dialog = dialog.set_file_name(name);
|
||||
}
|
||||
}
|
||||
for filter in &options.filters {
|
||||
dialog = dialog.add_filter(&filter.name, &filter.extensions);
|
||||
@@ -393,3 +501,29 @@ fn operation(result: Result<(), String>) -> OperationResponse {
|
||||
Err(error) => OperationResponse::error(error),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bare_backup_filename_fills_save_as_not_directory() {
|
||||
let path = resolve_dialog_path("opsxshell-hosts.oxs");
|
||||
assert_eq!(path.file_name.as_deref(), Some("opsxshell-hosts.oxs"));
|
||||
assert_eq!(path.directory, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_file_path_splits_directory_and_name() {
|
||||
let path = resolve_dialog_path("/tmp/opsxshell-hosts.oxs");
|
||||
assert_eq!(path.file_name.as_deref(), Some("opsxshell-hosts.oxs"));
|
||||
assert_eq!(path.directory.as_deref(), Some(Path::new("/tmp")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_directory_is_picker_folder() {
|
||||
let path = resolve_dialog_path("/tmp");
|
||||
assert_eq!(path.file_name, None);
|
||||
assert_eq!(path.directory.as_deref(), Some(Path::new("/tmp")));
|
||||
}
|
||||
}
|
||||
@@ -29,25 +29,52 @@ pub struct KnownHostsResponse {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn known_hosts_list() -> KnownHostsResponse {
|
||||
match list_entries() {
|
||||
Ok(entries) => KnownHostsResponse {
|
||||
pub async fn known_hosts_list() -> KnownHostsResponse {
|
||||
tauri::async_runtime::spawn_blocking(list_entries)
|
||||
.await
|
||||
.unwrap_or_else(|_| Err(crate::domain::error::codes::TASK_JOIN.to_string()))
|
||||
.map(|entries| KnownHostsResponse {
|
||||
success: true,
|
||||
entries: Some(entries),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => KnownHostsResponse {
|
||||
})
|
||||
.unwrap_or_else(|error| KnownHostsResponse {
|
||||
success: false,
|
||||
entries: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn known_hosts_remove(host: String, port: u16) -> OperationResponse {
|
||||
pub async fn known_hosts_remove(host: String, port: u16) -> OperationResponse {
|
||||
tauri::async_runtime::spawn_blocking(move || known_hosts_remove_inner(host, port))
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"known_hosts remove background task failed",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn known_hosts_clear() -> OperationResponse {
|
||||
tauri::async_runtime::spawn_blocking(known_hosts_clear_inner)
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"known_hosts clear background task failed",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn known_hosts_remove_inner(host: String, port: u16) -> OperationResponse {
|
||||
let Some(path) = known_hosts_path() else {
|
||||
return OperationResponse::error("无法确定 known_hosts 路径");
|
||||
return OperationResponse::coded(
|
||||
crate::domain::error::codes::KNOWN_HOSTS_PATH,
|
||||
"known_hosts path unavailable",
|
||||
);
|
||||
};
|
||||
if !path.is_file() {
|
||||
return OperationResponse::ok();
|
||||
@@ -85,10 +112,12 @@ pub fn known_hosts_remove(host: String, port: u16) -> OperationResponse {
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn known_hosts_clear() -> OperationResponse {
|
||||
fn known_hosts_clear_inner() -> OperationResponse {
|
||||
let Some(path) = known_hosts_path() else {
|
||||
return OperationResponse::error("无法确定 known_hosts 路径");
|
||||
return OperationResponse::coded(
|
||||
crate::domain::error::codes::KNOWN_HOSTS_PATH,
|
||||
"known_hosts path unavailable",
|
||||
);
|
||||
};
|
||||
match fs::write(path, "") {
|
||||
Ok(()) => OperationResponse::ok(),
|
||||
@@ -99,7 +128,10 @@ pub fn known_hosts_clear() -> OperationResponse {
|
||||
#[tauri::command]
|
||||
pub fn ssh_respond_host_key(request_id: String, decision: String) -> OperationResponse {
|
||||
let Some(parsed) = HostKeyDecision::parse(&decision) else {
|
||||
return OperationResponse::error("无效的主机密钥决定");
|
||||
return OperationResponse::coded(
|
||||
crate::domain::error::codes::INVALID_HOST_KEY_DECISION,
|
||||
"invalid host key decision",
|
||||
);
|
||||
};
|
||||
match respond_host_key(&request_id, parsed) {
|
||||
Ok(()) => OperationResponse::ok(),
|
||||
|
||||
@@ -19,7 +19,10 @@ pub async fn ssh_connect(
|
||||
match tauri::async_runtime::spawn_blocking(move || service.connect(config)).await {
|
||||
Ok(Ok(id)) => IdResponse::connection(id),
|
||||
Ok(Err(error)) => IdResponse::error(error),
|
||||
Err(error) => IdResponse::error(format!("SSH 连接任务失败: {error}")),
|
||||
Err(_) => IdResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"SSH connect background task failed",
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -30,18 +33,20 @@ pub async fn ssh_disconnect(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
retain_forwards: Option<bool>,
|
||||
reason: Option<String>,
|
||||
) -> Result<OperationResponse, String> {
|
||||
let service = state.ssh.clone();
|
||||
let retain = retain_forwards.unwrap_or(false);
|
||||
match tauri::async_runtime::spawn_blocking(move || {
|
||||
service.disconnect(&app, &connection_id, retain)
|
||||
service.disconnect(&app, &connection_id, retain, reason)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => Ok(operation(result)),
|
||||
Err(error) => Ok(OperationResponse::error(format!(
|
||||
"断开连接任务失败: {error}"
|
||||
))),
|
||||
Err(_) => Ok(OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"disconnect background task failed",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +57,8 @@ pub async fn ssh_exec(
|
||||
command: String,
|
||||
from_agent: Option<bool>,
|
||||
) -> Result<ExecResponse, String> {
|
||||
if from_agent.unwrap_or(false) {
|
||||
let from_agent = from_agent.unwrap_or(false);
|
||||
if from_agent {
|
||||
if let Err(error) = crate::services::ai::guardrail::reject_critical(&command) {
|
||||
return Ok(ExecResponse {
|
||||
success: false,
|
||||
@@ -71,7 +77,11 @@ pub async fn ssh_exec(
|
||||
{
|
||||
Ok(Ok((result, exit_code))) => ExecResponse {
|
||||
success: true,
|
||||
result: Some(result),
|
||||
result: Some(if from_agent {
|
||||
crate::services::ai::redact_sensitive(&result).0
|
||||
} else {
|
||||
result
|
||||
}),
|
||||
exit_code: Some(exit_code),
|
||||
error: None,
|
||||
},
|
||||
@@ -79,7 +89,11 @@ pub async fn ssh_exec(
|
||||
success: false,
|
||||
result: None,
|
||||
exit_code: None,
|
||||
error: Some(error),
|
||||
error: Some(if from_agent {
|
||||
crate::services::ai::redact_sensitive(&error).0
|
||||
} else {
|
||||
error
|
||||
}),
|
||||
},
|
||||
Err(error) => ExecResponse {
|
||||
success: false,
|
||||
@@ -118,7 +132,10 @@ pub async fn ssh_keepalive(
|
||||
let service = state.ssh.clone();
|
||||
match tauri::async_runtime::spawn_blocking(move || service.keepalive(&connection_id)).await {
|
||||
Ok(result) => Ok(operation(result)),
|
||||
Err(error) => Ok(OperationResponse::error(format!("保活任务失败: {error}"))),
|
||||
Err(_) => Ok(OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"keepalive background task failed",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +163,10 @@ pub async fn ssh_create_shell(
|
||||
{
|
||||
Ok(Ok(id)) => IdResponse::shell(id),
|
||||
Ok(Err(error)) => IdResponse::error(error),
|
||||
Err(error) => IdResponse::error(format!("创建 Shell 任务失败: {error}")),
|
||||
Err(_) => IdResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"create shell background task failed",
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -202,7 +222,10 @@ pub async fn sftp_connect(
|
||||
{
|
||||
Ok(Ok(id)) => IdResponse::sftp(id),
|
||||
Ok(Err(error)) => IdResponse::error(error),
|
||||
Err(error) => IdResponse::error(format!("创建 SFTP 任务失败: {error}")),
|
||||
Err(_) => IdResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"create SFTP background task failed",
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,154 +5,269 @@ use tauri::{AppHandle, State};
|
||||
use crate::{application::AppState, domain::ssh::OperationResponse};
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionsResponse {
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
sessions: Option<Vec<Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionResponse {
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
session: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SettingResponse {
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
value: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error_code: Option<String>,
|
||||
}
|
||||
|
||||
fn join_failed() -> String {
|
||||
crate::domain::error::codes::TASK_JOIN.to_string()
|
||||
}
|
||||
|
||||
fn join_code() -> Option<String> {
|
||||
Some(crate::domain::error::codes::TASK_JOIN.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn session_get_all(app: AppHandle, state: State<'_, AppState>) -> SessionsResponse {
|
||||
match state.storage.sessions(&app) {
|
||||
Ok(sessions) => SessionsResponse {
|
||||
success: true,
|
||||
sessions: Some(sessions),
|
||||
error: None,
|
||||
pub async fn session_get_all(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<SessionsResponse, ()> {
|
||||
let storage = state.storage.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || storage.sessions(&app)).await {
|
||||
Ok(Ok(sessions)) => SessionsResponse {
|
||||
success: true,
|
||||
sessions: Some(sessions),
|
||||
error: None,
|
||||
error_code: None,
|
||||
},
|
||||
Ok(Err(error)) => SessionsResponse {
|
||||
success: false,
|
||||
sessions: None,
|
||||
error: Some(error),
|
||||
error_code: None,
|
||||
},
|
||||
Err(_) => SessionsResponse {
|
||||
success: false,
|
||||
sessions: None,
|
||||
error: Some(join_failed()),
|
||||
error_code: join_code(),
|
||||
},
|
||||
},
|
||||
Err(error) => SessionsResponse {
|
||||
success: false,
|
||||
sessions: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn session_get_by_id(
|
||||
pub async fn session_get_by_id(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> SessionResponse {
|
||||
match state.storage.session(&app, &id) {
|
||||
Ok(session) => SessionResponse {
|
||||
success: session.is_some(),
|
||||
session,
|
||||
error: None,
|
||||
) -> Result<SessionResponse, ()> {
|
||||
let storage = state.storage.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || storage.session(&app, &id)).await {
|
||||
Ok(Ok(session)) => SessionResponse {
|
||||
success: session.is_some(),
|
||||
session,
|
||||
error: None,
|
||||
error_code: None,
|
||||
},
|
||||
Ok(Err(error)) => SessionResponse {
|
||||
success: false,
|
||||
session: None,
|
||||
error: Some(error),
|
||||
error_code: None,
|
||||
},
|
||||
Err(_) => SessionResponse {
|
||||
success: false,
|
||||
session: None,
|
||||
error: Some(join_failed()),
|
||||
error_code: join_code(),
|
||||
},
|
||||
},
|
||||
Err(error) => SessionResponse {
|
||||
success: false,
|
||||
session: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn session_create(
|
||||
pub async fn session_create(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
session: Value,
|
||||
) -> SessionResponse {
|
||||
match state.storage.create_session(&app, session) {
|
||||
Ok(session) => SessionResponse {
|
||||
success: true,
|
||||
session: Some(session),
|
||||
error: None,
|
||||
) -> Result<SessionResponse, ()> {
|
||||
let storage = state.storage.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || storage.create_session(&app, session))
|
||||
.await
|
||||
{
|
||||
Ok(Ok(session)) => SessionResponse {
|
||||
success: true,
|
||||
session: Some(session),
|
||||
error: None,
|
||||
error_code: None,
|
||||
},
|
||||
Ok(Err(error)) => SessionResponse {
|
||||
success: false,
|
||||
session: None,
|
||||
error: Some(error),
|
||||
error_code: None,
|
||||
},
|
||||
Err(_) => SessionResponse {
|
||||
success: false,
|
||||
session: None,
|
||||
error: Some(join_failed()),
|
||||
error_code: join_code(),
|
||||
},
|
||||
},
|
||||
Err(error) => SessionResponse {
|
||||
success: false,
|
||||
session: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn session_update(
|
||||
pub async fn session_update(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
updates: Value,
|
||||
) -> SessionResponse {
|
||||
match state.storage.update_session(&app, &id, updates) {
|
||||
Ok(session) => SessionResponse {
|
||||
success: true,
|
||||
session: Some(session),
|
||||
error: None,
|
||||
) -> Result<SessionResponse, ()> {
|
||||
let storage = state.storage.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || {
|
||||
storage.update_session(&app, &id, updates)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(session)) => SessionResponse {
|
||||
success: true,
|
||||
session: Some(session),
|
||||
error: None,
|
||||
error_code: None,
|
||||
},
|
||||
Ok(Err(error)) => SessionResponse {
|
||||
success: false,
|
||||
session: None,
|
||||
error: Some(error),
|
||||
error_code: None,
|
||||
},
|
||||
Err(_) => SessionResponse {
|
||||
success: false,
|
||||
session: None,
|
||||
error: Some(join_failed()),
|
||||
error_code: join_code(),
|
||||
},
|
||||
},
|
||||
Err(error) => SessionResponse {
|
||||
success: false,
|
||||
session: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn session_delete(app: AppHandle, state: State<'_, AppState>, id: String) -> OperationResponse {
|
||||
match state.storage.delete_session(&app, &id) {
|
||||
Ok(()) => OperationResponse::ok(),
|
||||
Err(error) => OperationResponse::error(error),
|
||||
}
|
||||
pub async fn session_delete(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<OperationResponse, ()> {
|
||||
let storage = state.storage.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || storage.delete_session(&app, &id)).await
|
||||
{
|
||||
Ok(Ok(())) => OperationResponse::ok(),
|
||||
Ok(Err(error)) => OperationResponse::error(error),
|
||||
Err(_) => OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"delete session background task failed",
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn session_reorder(
|
||||
pub async fn session_reorder(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
updates: Vec<Value>,
|
||||
) -> OperationResponse {
|
||||
match state.storage.reorder_sessions(&app, updates) {
|
||||
Ok(()) => OperationResponse::ok(),
|
||||
Err(error) => OperationResponse::error(error),
|
||||
}
|
||||
) -> Result<OperationResponse, ()> {
|
||||
let storage = state.storage.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || storage.reorder_sessions(&app, updates))
|
||||
.await
|
||||
{
|
||||
Ok(Ok(())) => OperationResponse::ok(),
|
||||
Ok(Err(error)) => OperationResponse::error(error),
|
||||
Err(_) => OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"reorder sessions background task failed",
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn settings_get(app: AppHandle, state: State<'_, AppState>, key: String) -> SettingResponse {
|
||||
match state.storage.get_setting(&app, &key) {
|
||||
Ok(value) => SettingResponse {
|
||||
success: true,
|
||||
value,
|
||||
error: None,
|
||||
pub async fn settings_get(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
key: String,
|
||||
) -> Result<SettingResponse, ()> {
|
||||
let storage = state.storage.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || storage.get_setting(&app, &key)).await {
|
||||
Ok(Ok(value)) => SettingResponse {
|
||||
success: true,
|
||||
value,
|
||||
error: None,
|
||||
error_code: None,
|
||||
},
|
||||
Ok(Err(error)) => SettingResponse {
|
||||
success: false,
|
||||
value: None,
|
||||
error: Some(error),
|
||||
error_code: None,
|
||||
},
|
||||
Err(_) => SettingResponse {
|
||||
success: false,
|
||||
value: None,
|
||||
error: Some(join_failed()),
|
||||
error_code: join_code(),
|
||||
},
|
||||
},
|
||||
Err(error) => SettingResponse {
|
||||
success: false,
|
||||
value: None,
|
||||
error: Some(error),
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn settings_set(
|
||||
pub async fn settings_set(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
key: String,
|
||||
value: Value,
|
||||
) -> OperationResponse {
|
||||
match state.storage.set_setting(&app, key, value) {
|
||||
Ok(()) => OperationResponse::ok(),
|
||||
Err(error) => OperationResponse::error(error),
|
||||
}
|
||||
) -> Result<OperationResponse, ()> {
|
||||
let storage = state.storage.clone();
|
||||
Ok(
|
||||
match tauri::async_runtime::spawn_blocking(move || storage.set_setting(&app, key, value))
|
||||
.await
|
||||
{
|
||||
Ok(Ok(())) => OperationResponse::ok(),
|
||||
Ok(Err(error)) => OperationResponse::error(error),
|
||||
Err(_) => OperationResponse::coded(
|
||||
crate::domain::error::codes::TASK_JOIN,
|
||||
"settings set background task failed",
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DomainError {
|
||||
#[error("resource not found: {0}")]
|
||||
NotFound(String),
|
||||
#[error("operation is not available: {0}")]
|
||||
Unsupported(String),
|
||||
/**
|
||||
* Stable IPC error codes. The renderer maps `errors:native.*` (and other
|
||||
* `errors:` keys that match these dotted paths). Command envelopes may also
|
||||
* still carry a human `error` string for logs.
|
||||
*/
|
||||
pub mod codes {
|
||||
pub const TASK_JOIN: &str = "native.taskJoin";
|
||||
pub const INVALID_BASE64: &str = "native.invalidBase64";
|
||||
pub const KNOWN_HOSTS_PATH: &str = "native.knownHostsPath";
|
||||
pub const INVALID_HOST_KEY_DECISION: &str = "native.invalidHostKeyDecision";
|
||||
}
|
||||
@@ -27,6 +27,8 @@ pub struct OperationResponse {
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_code: Option<String>,
|
||||
}
|
||||
|
||||
impl OperationResponse {
|
||||
@@ -34,6 +36,7 @@ impl OperationResponse {
|
||||
Self {
|
||||
success: true,
|
||||
error: None,
|
||||
error_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +44,15 @@ impl OperationResponse {
|
||||
Self {
|
||||
success: false,
|
||||
error: Some(error.into()),
|
||||
error_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn coded(code: &'static str, error: impl Into<String>) -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
error: Some(error.into()),
|
||||
error_code: Some(code.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,6 +69,8 @@ pub struct IdResponse {
|
||||
pub sftp_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_code: Option<String>,
|
||||
}
|
||||
|
||||
impl IdResponse {
|
||||
@@ -67,6 +81,7 @@ impl IdResponse {
|
||||
shell_id: None,
|
||||
sftp_id: None,
|
||||
error: None,
|
||||
error_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +92,7 @@ impl IdResponse {
|
||||
shell_id: Some(id),
|
||||
sftp_id: None,
|
||||
error: None,
|
||||
error_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +103,7 @@ impl IdResponse {
|
||||
shell_id: None,
|
||||
sftp_id: Some(id),
|
||||
error: None,
|
||||
error_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +114,18 @@ impl IdResponse {
|
||||
shell_id: None,
|
||||
sftp_id: None,
|
||||
error: Some(error.into()),
|
||||
error_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn coded(code: &'static str, error: impl Into<String>) -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
connection_id: None,
|
||||
shell_id: None,
|
||||
sftp_id: None,
|
||||
error: Some(error.into()),
|
||||
error_code: Some(code.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use url::Url;
|
||||
|
||||
use crate::domain::ai::{AiCatalogDocument, AiCompleteRequest, AiProviderConfig};
|
||||
|
||||
use redaction::redact_sensitive;
|
||||
pub(crate) use redaction::redact_sensitive;
|
||||
|
||||
pub struct AiCompletion {
|
||||
pub text: String,
|
||||
|
||||
@@ -168,14 +168,21 @@ fn set_file_permissions(_path: &std::path::Path) -> Result<(), String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
static TEST_HOME_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn unique_home() -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("time")
|
||||
.as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("opsxshell-cred-test-{nanos}"));
|
||||
let seq = TEST_HOME_SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"opsxshell-cred-test-{nanos}-{seq}-{}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir_all(&dir).expect("temp home");
|
||||
dir
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Clipboard text cleanup shared by VNC CutText (and tests).
|
||||
//!
|
||||
//! Terminal emulators wrap clipboard paste in bracketed-paste markers
|
||||
//! (`ESC [ 200 ~` … `ESC [ 201 ~`). If we inject Ctrl+V into a remote
|
||||
//! terminal, those markers show up as `^[[200~…~`. Strip them on both
|
||||
//! directions and type Unicode keysyms instead of a paste chord.
|
||||
|
||||
/// Max characters typed as RFB key events. Larger payloads stay on CutText
|
||||
/// and fall back to Ctrl+V so a huge clipboard cannot stall the session.
|
||||
pub const MAX_TYPE_CHARS: usize = 8192;
|
||||
|
||||
pub fn sanitize_clipboard_text(text: &str) -> String {
|
||||
let stripped = text
|
||||
.replace("\u{1b}[200~", "")
|
||||
.replace("\u{1b}[201~", "")
|
||||
.replace("^[[200~", "")
|
||||
.replace("^[[201~", "");
|
||||
|
||||
let mut out = String::with_capacity(stripped.len());
|
||||
let mut prev_cr = false;
|
||||
for ch in stripped.chars() {
|
||||
if prev_cr && ch == '\n' {
|
||||
prev_cr = false;
|
||||
continue;
|
||||
}
|
||||
prev_cr = false;
|
||||
match ch {
|
||||
'\n' => out.push('\n'),
|
||||
'\r' => {
|
||||
out.push('\n');
|
||||
prev_cr = true;
|
||||
}
|
||||
'\t' => out.push('\t'),
|
||||
'\u{feff}' => {}
|
||||
c if c.is_control() => {}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn vnc_keysym_for_char(ch: char) -> Option<u32> {
|
||||
match ch {
|
||||
'\n' => Some(0xff0d),
|
||||
'\t' => Some(0xff09),
|
||||
c if c.is_control() => None,
|
||||
c => {
|
||||
let codepoint = c as u32;
|
||||
if codepoint < 0x100 {
|
||||
Some(codepoint)
|
||||
} else {
|
||||
Some(0x01000000 | codepoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn should_type_as_keys(text: &str) -> bool {
|
||||
!text.is_empty() && text.chars().count() <= MAX_TYPE_CHARS
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strips_bracketed_paste_around_ip() {
|
||||
let wrapped = "\u{1b}[200~47.110.68.252\u{1b}[201~";
|
||||
assert_eq!(sanitize_clipboard_text(wrapped), "47.110.68.252");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_caret_notation_copied_from_screen() {
|
||||
assert_eq!(
|
||||
sanitize_clipboard_text("^[[200~47.110.68.252^[[201~"),
|
||||
"47.110.68.252"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_crlf_and_drops_other_controls() {
|
||||
assert_eq!(sanitize_clipboard_text("a\r\nb\rc\u{0007}d"), "a\nb\ncd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_cjk_and_tabs() {
|
||||
assert_eq!(sanitize_clipboard_text("主机\t名称"), "主机\t名称");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keysym_for_ascii_and_return() {
|
||||
assert_eq!(vnc_keysym_for_char('4'), Some(0x34));
|
||||
assert_eq!(vnc_keysym_for_char('.'), Some(0x2e));
|
||||
assert_eq!(vnc_keysym_for_char('\n'), Some(0xff0d));
|
||||
assert_eq!(vnc_keysym_for_char('中'), Some(0x01004e2d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn types_small_payloads_not_huge_ones() {
|
||||
assert!(should_type_as_keys("47.110.68.252"));
|
||||
let huge = "x".repeat(MAX_TYPE_CHARS + 1);
|
||||
assert!(!should_type_as_keys(&huge));
|
||||
assert!(!should_type_as_keys(""));
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ use tracing::{debug, warn};
|
||||
|
||||
use crate::domain::desktop::RdpClipboardEvent;
|
||||
|
||||
use super::clipboard_text::sanitize_clipboard_text;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct OpsxCliprdrBackend {
|
||||
app: AppHandle,
|
||||
@@ -144,6 +146,7 @@ impl CliprdrBackend for OpsxCliprdrBackend {
|
||||
.to_unicode_string()
|
||||
.or_else(|_| response.to_string())
|
||||
.unwrap_or_default();
|
||||
let text = sanitize_clipboard_text(&text);
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -179,6 +182,10 @@ pub fn queue_local_clipboard_set(
|
||||
want_paste_inject: &AtomicBool,
|
||||
text: String,
|
||||
) {
|
||||
let text = sanitize_clipboard_text(&text);
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut guard) = local_text.lock() {
|
||||
*guard = Some(text);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! IronRDP and vnc-rs drive Canvas frames over Tauri events. Session runtime
|
||||
//! state lives only in memory; disconnect cancels the worker thread.
|
||||
|
||||
mod clipboard_text;
|
||||
mod cliprdr_backend;
|
||||
mod common;
|
||||
mod input_map;
|
||||
|
||||
@@ -16,6 +16,7 @@ use vnc::{
|
||||
|
||||
use crate::domain::desktop::{VncClipboardEvent, VncConfig};
|
||||
|
||||
use super::clipboard_text::{sanitize_clipboard_text, should_type_as_keys, vnc_keysym_for_char};
|
||||
use super::common::{coalesce_desktop_inputs, emit_frame, emit_status, DesktopInput};
|
||||
use super::input_map;
|
||||
|
||||
@@ -125,6 +126,7 @@ async fn run_session_async(
|
||||
let mut fb_height = height;
|
||||
let mut framebuffer: Vec<u8> = vec![0; usize::from(fb_width) * usize::from(fb_height) * 4];
|
||||
let mut buttons: u8 = 0;
|
||||
let mut last_outbound_cut: Option<String> = None;
|
||||
let mut last_refresh = std::time::Instant::now();
|
||||
|
||||
emit_status(
|
||||
@@ -152,7 +154,7 @@ async fn run_session_async(
|
||||
let _ = vnc.input(X11Event::FullRefresh).await;
|
||||
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
drain_input(&vnc, &input, &mut buttons).await?;
|
||||
drain_input(&vnc, &input, &mut buttons, &mut last_outbound_cut).await?;
|
||||
|
||||
match vnc.poll_event().await {
|
||||
Ok(Some(event)) => {
|
||||
@@ -163,6 +165,7 @@ async fn run_session_async(
|
||||
&mut fb_width,
|
||||
&mut fb_height,
|
||||
&mut framebuffer,
|
||||
&mut last_outbound_cut,
|
||||
)?;
|
||||
}
|
||||
Ok(None) => {
|
||||
@@ -187,6 +190,7 @@ async fn drain_input(
|
||||
vnc: &vnc::VncClient,
|
||||
input: &Mutex<Vec<DesktopInput>>,
|
||||
buttons: &mut u8,
|
||||
last_outbound_cut: &mut Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let queued = {
|
||||
let mut guard = input
|
||||
@@ -233,15 +237,20 @@ async fn drain_input(
|
||||
.map_err(|error| format!("发送键盘事件失败: {error}"))?;
|
||||
}
|
||||
DesktopInput::ClipboardSet { text } => {
|
||||
let cut = sanitize_clipboard_text(&text);
|
||||
if cut.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Classic RFB CutText is Latin-1, but vnc-rs writes String::as_bytes()
|
||||
// (UTF-8). Prefer UTF-8 on the wire so CJK survives on modern servers;
|
||||
// fall back to Latin-1 substitution only when the payload is pure BMP Latin-1.
|
||||
let cut = prepare_cut_text(&text);
|
||||
vnc.input(X11Event::CopyText(cut))
|
||||
// (UTF-8). Prefer UTF-8 on the wire so CJK survives on modern servers.
|
||||
*last_outbound_cut = Some(cut.clone());
|
||||
vnc.input(X11Event::CopyText(cut.clone()))
|
||||
.await
|
||||
.map_err(|error| format!("发送剪贴板失败: {error}"))?;
|
||||
// Most servers only update remote clipboard; inject Ctrl+V for paste UX.
|
||||
inject_remote_paste(vnc).await?;
|
||||
// Do not inject Ctrl+V: remote terminals wrap clipboard paste in
|
||||
// bracketed-paste (`ESC[200~` … `ESC[201~`), which shows up as
|
||||
// `^[[200~47.110.68.252~`. Type Unicode keysyms instead.
|
||||
inject_clipboard_into_remote(vnc, &cut).await?;
|
||||
}
|
||||
DesktopInput::UploadFile { .. } | DesktopInput::Resize { .. } => {}
|
||||
}
|
||||
@@ -249,7 +258,46 @@ async fn drain_input(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn inject_remote_paste(vnc: &vnc::VncClient) -> Result<(), String> {
|
||||
async fn inject_clipboard_into_remote(vnc: &vnc::VncClient, text: &str) -> Result<(), String> {
|
||||
release_modifier_keys(vnc).await?;
|
||||
if should_type_as_keys(text) {
|
||||
inject_text_as_keys(vnc, text).await
|
||||
} else {
|
||||
inject_ctrl_v(vnc).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Cmd/Ctrl was already forwarded as Super/Control-down before the V key.
|
||||
/// Release modifiers so typed characters are not Super+digit / Ctrl+letter.
|
||||
async fn release_modifier_keys(vnc: &vnc::VncClient) -> Result<(), String> {
|
||||
const MODIFIERS: [u32; 8] = [
|
||||
0xffe3, // Control_L
|
||||
0xffe4, // Control_R
|
||||
0xffe1, // Shift_L
|
||||
0xffe2, // Shift_R
|
||||
0xffe9, // Alt_L
|
||||
0xffea, // Alt_R
|
||||
0xffeb, // Super_L
|
||||
0xffec, // Super_R
|
||||
];
|
||||
for keycode in MODIFIERS {
|
||||
send_key(vnc, keycode, false).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn inject_text_as_keys(vnc: &vnc::VncClient, text: &str) -> Result<(), String> {
|
||||
for ch in text.chars() {
|
||||
let Some(keycode) = vnc_keysym_for_char(ch) else {
|
||||
continue;
|
||||
};
|
||||
send_key(vnc, keycode, true).await?;
|
||||
send_key(vnc, keycode, false).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn inject_ctrl_v(vnc: &vnc::VncClient) -> Result<(), String> {
|
||||
const CONTROL: u32 = 0xffe3;
|
||||
const KEY_V: u32 = 0x0076;
|
||||
for (keycode, down) in [
|
||||
@@ -258,18 +306,15 @@ async fn inject_remote_paste(vnc: &vnc::VncClient) -> Result<(), String> {
|
||||
(KEY_V, false),
|
||||
(CONTROL, false),
|
||||
] {
|
||||
vnc.input(X11Event::KeyEvent(ClientKeyEvent { keycode, down }))
|
||||
.await
|
||||
.map_err(|error| format!("注入粘贴快捷键失败: {error}"))?;
|
||||
send_key(vnc, keycode, down).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prepare_cut_text(text: &str) -> String {
|
||||
// Send UTF-8 as-is. Classic Latin-1 servers may mojibake non-ASCII, but replacing
|
||||
// CJK with '?' is worse for OpsXShell's primary locale. Extended Clipboard (UTF-8)
|
||||
// needs a vnc-rs protocol upgrade beyond CopyText.
|
||||
text.to_string()
|
||||
async fn send_key(vnc: &vnc::VncClient, keycode: u32, down: bool) -> Result<(), String> {
|
||||
vnc.input(X11Event::KeyEvent(ClientKeyEvent { keycode, down }))
|
||||
.await
|
||||
.map_err(|error| format!("发送键盘事件失败: {error}"))
|
||||
}
|
||||
|
||||
fn decode_tight_jpeg(data: &[u8]) -> Result<Vec<u8>, String> {
|
||||
@@ -292,6 +337,7 @@ fn handle_vnc_event(
|
||||
fb_width: &mut u16,
|
||||
fb_height: &mut u16,
|
||||
framebuffer: &mut Vec<u8>,
|
||||
last_outbound_cut: &mut Option<String>,
|
||||
) -> Result<(), String> {
|
||||
match event {
|
||||
VncEvent::SetResolution(screen) => {
|
||||
@@ -372,9 +418,13 @@ fn handle_vnc_event(
|
||||
},
|
||||
VncEvent::Error(message) => return Err(message),
|
||||
VncEvent::Text(text) => {
|
||||
let text = sanitize_clipboard_text(&text);
|
||||
if text.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if last_outbound_cut.as_ref() == Some(&text) {
|
||||
return Ok(());
|
||||
}
|
||||
if let Ok(mut clipboard) = arboard::Clipboard::new() {
|
||||
let _ = clipboard.set_text(text.clone());
|
||||
}
|
||||
|
||||
@@ -96,7 +96,12 @@ impl SshService {
|
||||
app: &AppHandle,
|
||||
connection_id: &str,
|
||||
retain_forwards: bool,
|
||||
reason: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let reason = reason
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "connection closed".to_string());
|
||||
// Dedicated tunnel sessions keep listening across terminal flaps when
|
||||
// retain_forwards is true. Explicit teardown (tab close / panel disconnect)
|
||||
// must still close real Listeners.
|
||||
@@ -123,7 +128,7 @@ impl SshService {
|
||||
for shell_id in shell_ids {
|
||||
if let Some(mut shell) = self.shells.lock().map_err(lock_error)?.remove(&shell_id) {
|
||||
let _ = shell.channel.close();
|
||||
let _ = shell.session.disconnect(None, "closed by user", None);
|
||||
let _ = shell.session.disconnect(None, &reason, None);
|
||||
}
|
||||
}
|
||||
let sftp_ids = {
|
||||
@@ -148,13 +153,13 @@ impl SshService {
|
||||
.map_err(lock_error)?
|
||||
.remove(connection_id)
|
||||
{
|
||||
let _ = connection.session.disconnect(None, "closed by user", None);
|
||||
let _ = connection.session.disconnect(None, &reason, None);
|
||||
}
|
||||
let _ = app.emit(
|
||||
"ssh:connection-closed",
|
||||
ConnectionClosedEvent {
|
||||
connection_id: connection_id.to_string(),
|
||||
reason: "closed by user".to_string(),
|
||||
reason,
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
@@ -657,15 +662,12 @@ fn classify_shell_close(output_tail: &[u8], read_error: Option<&str>) -> (String
|
||||
|| output.contains("auto-logout")
|
||||
|| output.contains("auto logout");
|
||||
if idle_timeout {
|
||||
return ("远端 Shell 因空闲策略(TMOUT)自动退出".to_string(), true);
|
||||
return ("shell.idleTimeout".to_string(), true);
|
||||
}
|
||||
if let Some(error) = read_error {
|
||||
return (format!("远端 Shell 读取失败: {error}"), false);
|
||||
return (format!("shell.readFailed: {error}"), false);
|
||||
}
|
||||
(
|
||||
"远端 Shell 已退出(可能执行了 exit、Ctrl+D,或被服务器策略关闭)".to_string(),
|
||||
false,
|
||||
)
|
||||
("shell.exited".to_string(), false)
|
||||
}
|
||||
|
||||
fn write_nonblocking(channel: &mut ssh2::Channel, data: &[u8]) -> io::Result<()> {
|
||||
@@ -814,7 +816,7 @@ mod tests {
|
||||
classify_shell_close(b"timed out waiting for input: auto-logout\r\n", None);
|
||||
|
||||
assert!(idle_timeout);
|
||||
assert!(reason.contains("TMOUT"));
|
||||
assert_eq!(reason, "shell.idleTimeout");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -831,7 +833,7 @@ mod tests {
|
||||
let (reason, idle_timeout) = classify_shell_close(b"logout\r\n", None);
|
||||
|
||||
assert!(!idle_timeout);
|
||||
assert!(reason.contains("Shell 已退出"));
|
||||
assert_eq!(reason, "shell.exited");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -34,7 +34,21 @@
|
||||
"targets": "all",
|
||||
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"],
|
||||
"macOS": {
|
||||
"signingIdentity": "-"
|
||||
"signingIdentity": "-",
|
||||
"dmg": {
|
||||
"windowSize": {
|
||||
"width": 720,
|
||||
"height": 440
|
||||
},
|
||||
"appPosition": {
|
||||
"x": 180,
|
||||
"y": 190
|
||||
},
|
||||
"applicationFolderPosition": {
|
||||
"x": 520,
|
||||
"y": 190
|
||||
}
|
||||
}
|
||||
},
|
||||
"category": "DeveloperTool",
|
||||
"shortDescription": "Simple native SSH / SFTP / desktop ops terminal",
|
||||
|
||||
+33
-17
@@ -1,9 +1,4 @@
|
||||
import type {
|
||||
AiBillingMode,
|
||||
AiModelConfig,
|
||||
AiProviderConfig,
|
||||
AiProviderFamily,
|
||||
} from '../../shared/types/ai.types'
|
||||
import type { AiBillingMode, AiModelConfig, AiProviderConfig, AiProviderFamily } from '../../shared/types/ai.types'
|
||||
|
||||
export interface AiProviderPreset extends Omit<AiProviderConfig, 'id' | 'enabled' | 'hasApiKey'> {
|
||||
id: string
|
||||
@@ -11,10 +6,7 @@ export interface AiProviderPreset extends Omit<AiProviderConfig, 'id' | 'enabled
|
||||
family: AiProviderFamily
|
||||
}
|
||||
|
||||
export const AI_PROVIDER_FAMILY_META: Record<
|
||||
AiProviderFamily,
|
||||
{ label: string; description: string }
|
||||
> = {
|
||||
export const AI_PROVIDER_FAMILY_META: Record<AiProviderFamily, { label: string; description: string }> = {
|
||||
openai_compatible: {
|
||||
label: 'OpenAI compatible',
|
||||
description: 'payg',
|
||||
@@ -73,7 +65,7 @@ export const AI_PROVIDER_PRESETS: AiProviderPreset[] = [
|
||||
allowInsecureHttp: false,
|
||||
models: [
|
||||
model('deepseek-v4-pro', 'deepseek-v4-pro', 'DeepSeek V4 Pro', 'reasoning'),
|
||||
model('deepseek-v4-flash', 'deepseek-v4-flash', 'DeepSeek V4 Flash'),
|
||||
model('deepseek-flash', 'deepseek-flash', 'DeepSeek Flash (V4.1)'),
|
||||
],
|
||||
defaultModelId: 'deepseek-v4-pro',
|
||||
},
|
||||
@@ -88,12 +80,13 @@ export const AI_PROVIDER_PRESETS: AiProviderPreset[] = [
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
allowInsecureHttp: false,
|
||||
models: [
|
||||
model('gpt-6-astra', 'gpt-6-astra', 'GPT-6 Astra', 'reasoning'),
|
||||
model('gpt-5.6-sol', 'gpt-5.6-sol', 'GPT-5.6 Sol', 'reasoning'),
|
||||
model('gpt-5.6-terra', 'gpt-5.6-terra', 'GPT-5.6 Terra', 'reasoning'),
|
||||
model('gpt-5.6-luna', 'gpt-5.6-luna', 'GPT-5.6 Luna'),
|
||||
model('gpt-5.5', 'gpt-5.5', 'GPT-5.5', 'reasoning'),
|
||||
],
|
||||
defaultModelId: 'gpt-5.6-sol',
|
||||
defaultModelId: 'gpt-6-astra',
|
||||
},
|
||||
{
|
||||
id: 'qwen',
|
||||
@@ -107,6 +100,7 @@ export const AI_PROVIDER_PRESETS: AiProviderPreset[] = [
|
||||
allowInsecureHttp: false,
|
||||
models: [
|
||||
model('qwen3.8-max', 'qwen3.8-max', 'Qwen3.8 Max', 'reasoning'),
|
||||
model('qwen3.8-flash', 'qwen3.8-flash', 'Qwen3.8 Flash'),
|
||||
model('qwen3.7-plus', 'qwen3.7-plus', 'Qwen3.7 Plus', 'reasoning'),
|
||||
model('qwen3.7-flash', 'qwen3.7-flash', 'Qwen3.7 Flash'),
|
||||
],
|
||||
@@ -141,7 +135,7 @@ export const AI_PROVIDER_PRESETS: AiProviderPreset[] = [
|
||||
allowInsecureHttp: false,
|
||||
models: [
|
||||
model('mimo-v2.5-pro', 'mimo-v2.5-pro', 'MiMo V2.5 Pro', 'reasoning'),
|
||||
model('mimo-v2.5-flash', 'mimo-v2.5-flash', 'MiMo V2.5 Flash'),
|
||||
model('mimo-v2.5', 'mimo-v2.5', 'MiMo V2.5'),
|
||||
],
|
||||
defaultModelId: 'mimo-v2.5-pro',
|
||||
},
|
||||
@@ -175,11 +169,13 @@ export const AI_PROVIDER_PRESETS: AiProviderPreset[] = [
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
allowInsecureHttp: false,
|
||||
models: [
|
||||
model('claude-fable-5-1', 'claude-fable-5-1', 'Claude Fable 5.1', 'reasoning'),
|
||||
model('claude-fable-5', 'claude-fable-5', 'Claude Fable 5', 'reasoning'),
|
||||
model('claude-opus-5', 'claude-opus-5', 'Claude Opus 5', 'reasoning'),
|
||||
model('claude-sonnet-5', 'claude-sonnet-5', 'Claude Sonnet 5', 'reasoning'),
|
||||
model('claude-haiku-4-5', 'claude-haiku-4-5', 'Claude Haiku 4.5'),
|
||||
],
|
||||
defaultModelId: 'claude-opus-5',
|
||||
defaultModelId: 'claude-fable-5-1',
|
||||
},
|
||||
{
|
||||
id: 'google',
|
||||
@@ -193,8 +189,9 @@ export const AI_PROVIDER_PRESETS: AiProviderPreset[] = [
|
||||
allowInsecureHttp: false,
|
||||
models: [
|
||||
model('gemini-3.1-pro-preview', 'gemini-3.1-pro-preview', 'Gemini 3.1 Pro', 'reasoning'),
|
||||
model('gemini-3-flash-preview', 'gemini-3-flash-preview', 'Gemini 3 Flash'),
|
||||
model('gemini-3.1-flash-lite', 'gemini-3.1-flash-lite', 'Gemini 3.1 Flash-Lite'),
|
||||
model('gemini-3.6-flash', 'gemini-3.6-flash', 'Gemini 3.6 Flash'),
|
||||
model('gemini-3.5-flash', 'gemini-3.5-flash', 'Gemini 3.5 Flash'),
|
||||
model('gemini-3.5-flash-lite', 'gemini-3.5-flash-lite', 'Gemini 3.5 Flash-Lite'),
|
||||
],
|
||||
defaultModelId: 'gemini-3.1-pro-preview',
|
||||
},
|
||||
@@ -211,6 +208,9 @@ export const AI_PROVIDER_PRESETS: AiProviderPreset[] = [
|
||||
baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
|
||||
allowInsecureHttp: false,
|
||||
models: [
|
||||
model('glm-5.3-plan', 'glm-5.3', 'GLM-5.3', 'plan', 'plan', {
|
||||
baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
|
||||
}),
|
||||
model('glm-5.2-plan', 'glm-5.2', 'GLM-5.2', 'plan', 'plan', {
|
||||
baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
|
||||
}),
|
||||
@@ -221,7 +221,7 @@ export const AI_PROVIDER_PRESETS: AiProviderPreset[] = [
|
||||
baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
|
||||
}),
|
||||
],
|
||||
defaultModelId: 'glm-5.2-plan',
|
||||
defaultModelId: 'glm-5.3-plan',
|
||||
},
|
||||
{
|
||||
id: 'minimax-plan',
|
||||
@@ -257,6 +257,22 @@ export const AI_PROVIDER_PRESETS: AiProviderPreset[] = [
|
||||
],
|
||||
defaultModelId: 'kimi-k3-plan',
|
||||
},
|
||||
{
|
||||
id: 'mimo-plan',
|
||||
name: 'Xiaomi MiMo',
|
||||
description: 'Token Plan 包月',
|
||||
family: 'plan',
|
||||
presetId: 'mimo-plan',
|
||||
protocol: 'openai-chat',
|
||||
authStyle: 'bearer',
|
||||
baseUrl: 'https://token-plan-cn.xiaomimimo.com/v1',
|
||||
allowInsecureHttp: false,
|
||||
models: [
|
||||
model('mimo-v2.5-pro-plan', 'mimo-v2.5-pro', 'MiMo V2.5 Pro', 'plan', 'plan'),
|
||||
model('mimo-v2.5-plan', 'mimo-v2.5', 'MiMo V2.5', 'plan', 'plan'),
|
||||
],
|
||||
defaultModelId: 'mimo-v2.5-pro-plan',
|
||||
},
|
||||
|
||||
// ——— 私有化 ———
|
||||
{
|
||||
|
||||
+5
-1
@@ -14,6 +14,7 @@ import AppShell from './AppShell'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useSessionStore, useCommandPaletteStore, useEntitlementStore, type Session } from '../stores'
|
||||
import { getConnectionStore } from '../stores/connection'
|
||||
import { classifyDisconnectReason, shouldForgetNativeConnection } from '../utils/disconnectReason'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { createLogger } from '../utils/logger'
|
||||
import { useToast } from '../hooks/useToast'
|
||||
@@ -164,7 +165,8 @@ function App() {
|
||||
// Keep renderer state authoritative when the native SSH service closes a
|
||||
// connection (network loss, keepalive failure, or remote shell failure).
|
||||
useEffect(() => {
|
||||
const handleConnectionClosed = (_event: unknown, connectionId: string) => {
|
||||
const handleConnectionClosed = (_event: unknown, connectionId: string, reason?: string) => {
|
||||
if (!shouldForgetNativeConnection(classifyDisconnectReason(reason))) return
|
||||
getConnectionStore().forgetNativeConnection(connectionId)
|
||||
const { tabs, updateTab } = useTabStore.getState()
|
||||
for (const tab of tabs) {
|
||||
@@ -210,6 +212,7 @@ function App() {
|
||||
title: t('sessions:importExport.exportTitle'),
|
||||
defaultPath: t('sessions:importExport.exportDefaultPath'),
|
||||
filters: [
|
||||
{ name: t('sessions:importExport.exportFilterOxs'), extensions: ['oxs'] },
|
||||
{ name: t('sessions:importExport.exportFilterBak'), extensions: ['bak'] },
|
||||
{ name: t('sessions:importExport.exportFilterJson'), extensions: ['json'] },
|
||||
],
|
||||
@@ -291,6 +294,7 @@ function App() {
|
||||
const result = await window.opsxApi?.showOpenDialog({
|
||||
title: t('sessions:importExport.importTitle'),
|
||||
filters: [
|
||||
{ name: t('sessions:importExport.importFilterOxs'), extensions: ['oxs'] },
|
||||
{ name: t('sessions:importExport.importFilterBak'), extensions: ['bak'] },
|
||||
{ name: t('sessions:importExport.importFilterJson'), extensions: ['json'] },
|
||||
{ name: t('sessions:importExport.importFilterSshConfig'), extensions: ['conf', 'config'] },
|
||||
|
||||
+6
-15
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* 窗口拖拽约定(Tauri overlay title bar):
|
||||
* - 仅使用 data-tauri-drag-region 处理拖拽和双击缩放
|
||||
* - 不叠加 CSS app-region 或 React 双击处理,避免一次双击切换两次
|
||||
* - 不要再叠加 startDragging() 或 CSS app-region: drag,两者会抢鼠标导致窗口拖不动
|
||||
* - 标签栏空白区也由 Tauri 原生拖拽区处理
|
||||
*
|
||||
* RDP/VNC 全屏时只隐藏 chrome,保持 TabContent 挂载,避免会话 remount 立刻退出全屏。
|
||||
@@ -19,7 +19,6 @@ import StatusBar from '../components/layout/StatusBar'
|
||||
import type { Session } from '../stores'
|
||||
import { useDesktopFullscreenStore } from '../stores/desktopFullscreenStore'
|
||||
import { getDesktopPlatform } from '../platform/runtime'
|
||||
import { startWindowDragging } from '../platform/tauri'
|
||||
|
||||
export interface AppShellProps {
|
||||
activeTabId: string | null
|
||||
@@ -47,10 +46,6 @@ function TitleBar() {
|
||||
return (
|
||||
<header
|
||||
data-tauri-drag-region
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== 0) return
|
||||
void startWindowDragging()
|
||||
}}
|
||||
className="bg-dark-900/95 relative z-20 flex h-10 flex-shrink-0 select-none items-center border-b border-dark-800"
|
||||
>
|
||||
<div data-tauri-drag-region className="h-full w-[78px] flex-shrink-0" aria-hidden />
|
||||
@@ -127,11 +122,7 @@ export default function AppShell({
|
||||
const desktopFullscreen = useDesktopFullscreenStore((state) => state.active)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-screen flex-col overflow-hidden text-content-primary ${
|
||||
desktopFullscreen ? 'bg-black' : 'bg-dark-900'
|
||||
}`}
|
||||
>
|
||||
<div className={`app-shell text-content-primary ${desktopFullscreen ? 'bg-black' : 'bg-dark-900'}`}>
|
||||
{!desktopFullscreen && <TitleBar />}
|
||||
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
@@ -145,7 +136,7 @@ export default function AppShell({
|
||||
|
||||
{!desktopFullscreen && (
|
||||
<aside
|
||||
className={`titlebar-no-drag h-full flex-shrink-0 transition-[width,transform] duration-200 ease-in-out ${
|
||||
className={`titlebar-no-drag h-full min-h-0 flex-shrink-0 transition-[width,transform] duration-200 ease-in-out ${
|
||||
isMobile
|
||||
? `fixed inset-y-0 left-0 top-10 z-50 ${mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`
|
||||
: desktopSidebarHidden
|
||||
@@ -163,7 +154,7 @@ export default function AppShell({
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<main className={`flex min-w-0 flex-1 flex-col ${desktopFullscreen ? 'bg-black' : 'bg-dark-800'}`}>
|
||||
<main className={`flex min-h-0 min-w-0 flex-1 flex-col ${desktopFullscreen ? 'bg-black' : 'bg-dark-800'}`}>
|
||||
{!desktopFullscreen && (
|
||||
<div className="flex items-stretch">
|
||||
{isMobile && <MobileMenuButton onClick={onToggleMobileSidebar} />}
|
||||
@@ -188,12 +179,12 @@ export default function AppShell({
|
||||
</div>
|
||||
|
||||
{!desktopFullscreen && (
|
||||
<div className="titlebar-no-drag">
|
||||
<div className="titlebar-no-drag flex-shrink-0">
|
||||
<StatusBar />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="titlebar-no-drag">{children}</div>
|
||||
<div className="contents">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Loader2,
|
||||
Plus,
|
||||
Send,
|
||||
ShieldAlert,
|
||||
Sparkles,
|
||||
Square,
|
||||
TerminalSquare,
|
||||
@@ -31,7 +30,7 @@ import Button from '../common/Button'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { AgentMarkdown, looksLikeMarkdown } from './AgentMarkdown'
|
||||
import i18n from '../../i18n'
|
||||
import { translateErrorMessage } from '../../i18n/format'
|
||||
import { isSshExecCancelled, translateErrorMessage } from '../../i18n/format'
|
||||
|
||||
export interface TerminalAiContext {
|
||||
selection: string
|
||||
@@ -64,9 +63,10 @@ interface ThreadMessage {
|
||||
steps?: AgentStep[]
|
||||
}
|
||||
|
||||
const MAX_AGENT_STEPS = 8
|
||||
const MAX_TOOL_OUTPUT_CHARS = 12_000
|
||||
const MAX_AGENT_CONTEXT_CHARS = 48_000
|
||||
const MAX_AGENT_STEPS = 12
|
||||
const MAX_TOOL_OUTPUT_CHARS = 8_000
|
||||
const MAX_SUMMARIZE_CHARS = 6_000
|
||||
const MAX_AGENT_CONTEXT_CHARS = 32_000
|
||||
const SAFETY_MODE_ORDER: AgentSafetyMode[] = ['strict', 'relaxed']
|
||||
|
||||
function truncateMiddle(value: string, limit: number): string {
|
||||
@@ -80,6 +80,27 @@ function messageId(): string {
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`
|
||||
}
|
||||
|
||||
/** Collapse "DeepSeek · DeepSeek V4 Pro" when the model label already names the vendor. */
|
||||
function modelSubtitle(providerName: string, modelLabel: string): string {
|
||||
const provider = providerName.trim()
|
||||
const model = modelLabel.trim()
|
||||
if (!provider) return model
|
||||
if (!model) return provider
|
||||
if (model.toLowerCase().startsWith(provider.toLowerCase())) return model
|
||||
return `${provider} · ${model}`
|
||||
}
|
||||
|
||||
/** Long command / viewport dumps: keep errors + head/tail instead of the raw wall of text. */
|
||||
function compactToolOutput(raw: string, limit = MAX_TOOL_OUTPUT_CHARS): string {
|
||||
const text = raw.trim()
|
||||
if (!text) return text
|
||||
const lines = text.split('\n').length
|
||||
if (text.length > Math.floor(limit / 2) || lines > 80) {
|
||||
return truncateMiddle(analyzeAgentOutput(text).text, limit)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
function agentPrompt(
|
||||
task: string,
|
||||
targetLabel: string,
|
||||
@@ -91,7 +112,7 @@ function agentPrompt(
|
||||
const checkpoint =
|
||||
consecutiveFailures >= 2
|
||||
? `最近已连续失败 ${consecutiveFailures} 次,必须更换排查路径,不得只换参数重试相同操作。`
|
||||
: round === 4
|
||||
: round === Math.floor(MAX_AGENT_STEPS / 2)
|
||||
? '请检查已有证据是否足够;如果足够应立即收敛,不要为了调用工具而调用。'
|
||||
: round === MAX_AGENT_STEPS
|
||||
? '这是最后一轮,除非缺少唯一关键证据,否则必须返回 FINAL 结论。'
|
||||
@@ -165,6 +186,7 @@ export default function AiTerminalPanel({
|
||||
const [running, setRunning] = useState(false)
|
||||
const [copiedId, setCopiedId] = useState('')
|
||||
const runIdRef = useRef(0)
|
||||
const runConnectionIdRef = useRef<string | null>(null)
|
||||
const sessionAllowRef = useRef(new Set<string>())
|
||||
const timelineRef = useRef<HTMLDivElement>(null)
|
||||
const initialSelectionRef = useRef('')
|
||||
@@ -229,6 +251,8 @@ export default function AiTerminalPanel({
|
||||
|
||||
const runId = runIdRef.current + 1
|
||||
runIdRef.current = runId
|
||||
const runConnectionId = connectionId
|
||||
runConnectionIdRef.current = runConnectionId
|
||||
if (!isFollowUp) {
|
||||
initialSelectionRef.current = truncateMiddle(capture().selection.trim(), MAX_TOOL_OUTPUT_CHARS)
|
||||
}
|
||||
@@ -351,18 +375,15 @@ export default function AiTerminalPanel({
|
||||
}
|
||||
|
||||
setLiveSteps([...completedSteps, proposed])
|
||||
const executed = await window.opsxApi.sshExec(connectionId, command, { fromAgent: true })
|
||||
const executed = await window.opsxApi.sshExec(runConnectionId, command, { fromAgent: true })
|
||||
if (runIdRef.current !== runId) return
|
||||
if (!executed.success && /已终止/.test(executed.error || '')) {
|
||||
if (!executed.success && isSshExecCancelled(executed.error)) {
|
||||
return
|
||||
}
|
||||
const rawOutput = executed.success
|
||||
? executed.result?.trim() || i18n.t('ai:error.execNoOutput')
|
||||
: i18n.t('ai:error.execFailed', { error: executed.error || i18n.t('ai:error.execUnknown') })
|
||||
const output =
|
||||
rawOutput.length > MAX_TOOL_OUTPUT_CHARS / 2
|
||||
? truncateMiddle(analyzeAgentOutput(rawOutput).text, MAX_TOOL_OUTPUT_CHARS)
|
||||
: rawOutput
|
||||
const output = compactToolOutput(rawOutput)
|
||||
const step: AgentStep = {
|
||||
round,
|
||||
command,
|
||||
@@ -388,6 +409,7 @@ export default function AiTerminalPanel({
|
||||
}
|
||||
} finally {
|
||||
if (runIdRef.current === runId) {
|
||||
runConnectionIdRef.current = null
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
@@ -426,7 +448,7 @@ export default function AiTerminalPanel({
|
||||
model,
|
||||
action: 'explain',
|
||||
prompt: `请总结「${targetLabel}」终端输出的要点:发生了什么、是否有错误、建议的下一步。用简短的 Markdown 列表回答,不要建议执行任何写操作命令。`,
|
||||
context: truncateMiddle(context, MAX_TOOL_OUTPUT_CHARS),
|
||||
context: compactToolOutput(context, MAX_SUMMARIZE_CHARS),
|
||||
})
|
||||
if (runIdRef.current !== runId) return
|
||||
if (!completion.success || !completion.text) {
|
||||
@@ -446,8 +468,10 @@ export default function AiTerminalPanel({
|
||||
const stopAgent = () => {
|
||||
runIdRef.current += 1
|
||||
dismissAgentRisk()
|
||||
if (connectionId) {
|
||||
void window.opsxApi.sshExecCancel(connectionId)
|
||||
const runConnectionId = runConnectionIdRef.current
|
||||
runConnectionIdRef.current = null
|
||||
if (runConnectionId) {
|
||||
void window.opsxApi.sshExecCancel(runConnectionId)
|
||||
}
|
||||
setRunning(false)
|
||||
const snapped = liveStepsRef.current
|
||||
@@ -470,73 +494,69 @@ export default function AiTerminalPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="border-dark-700/50 flex w-[430px] max-w-[42vw] flex-shrink-0 flex-col border-l bg-dark-850">
|
||||
<header className="border-dark-700/50 flex-shrink-0 border-b px-3 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-accent-blue" />
|
||||
<aside className="app-agent-panel">
|
||||
<header className="border-dark-700/50 flex-shrink-0 border-b px-3 py-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Sparkles className="mt-0.5 h-4 w-4 flex-shrink-0 text-accent-blue" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-content-primary">{t('panel.title')}</div>
|
||||
<div className="truncate text-[10px] text-txt-faint">
|
||||
{provider && model
|
||||
? t('panel.modelConfigured', { provider: provider.name, model: model.label })
|
||||
: t('panel.modelNotConfigured')}
|
||||
{' · '}
|
||||
{sshReady ? t('panel.sshReady', { target: targetLabel }) : t('panel.sshNotConnected')}
|
||||
<div className="mt-0.5 text-[10px] leading-snug text-txt-faint">
|
||||
<div
|
||||
className="truncate"
|
||||
title={provider && model ? `${provider.name} · ${model.label}` : t('panel.modelNotConfigured')}
|
||||
>
|
||||
{provider && model ? modelSubtitle(provider.name, model.label) : t('panel.modelNotConfigured')}
|
||||
</div>
|
||||
<div className="break-all" title={sshReady ? targetLabel : undefined}>
|
||||
{sshReady ? t('panel.sshReady', { target: targetLabel }) : t('panel.sshNotConnected')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5" role="radiogroup" aria-label={t('safety.aria')}>
|
||||
{SAFETY_MODE_ORDER.map((mode) => {
|
||||
const selected = safetyMode === mode
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
aria-label={t(`safety.${mode}.label`)}
|
||||
disabled={running}
|
||||
title={t(`safety.${mode}.description`)}
|
||||
onClick={() => void setSetting('agentSafetyMode', mode)}
|
||||
className={`rounded px-1.5 py-0.5 text-[10px] disabled:opacity-50 ${
|
||||
selected ? 'bg-accent-blue/15 text-accent-blue' : 'text-txt-faint hover:text-content-secondary'
|
||||
}`}
|
||||
>
|
||||
{t(`safety.${mode}.label`)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div className="flex flex-shrink-0 items-center gap-0.5">
|
||||
<div role="radiogroup" aria-label={t('safety.aria')} className="flex items-center">
|
||||
{SAFETY_MODE_ORDER.map((mode) => {
|
||||
const selected = safetyMode === mode
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
aria-label={t(`safety.${mode}.label`)}
|
||||
disabled={running}
|
||||
title={t(`safety.${mode}.description`)}
|
||||
onClick={() => void setSetting('agentSafetyMode', mode)}
|
||||
className={`rounded px-1.5 py-0.5 text-[10px] disabled:opacity-50 ${
|
||||
selected ? 'bg-accent-blue/15 text-accent-blue' : 'text-txt-faint hover:text-content-secondary'
|
||||
}`}
|
||||
>
|
||||
{t(`safety.${mode}.label`)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{(messages.length > 0 || liveSteps.length > 0) && (
|
||||
<button
|
||||
type="button"
|
||||
title={t('panel.newTopic')}
|
||||
aria-label={t('panel.newTopic')}
|
||||
disabled={running}
|
||||
onClick={startNewTopic}
|
||||
className="rounded p-1 text-txt-muted hover:bg-dark-700 disabled:opacity-40"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={confirmAllCommands}
|
||||
disabled={running}
|
||||
title={t('safety.confirmAllTitle')}
|
||||
aria-label={t('safety.confirmAllTitle')}
|
||||
onClick={() => void setSetting('agentConfirmAllCommands', !confirmAllCommands)}
|
||||
className={`ml-1 rounded p-1 disabled:opacity-50 ${
|
||||
confirmAllCommands ? 'text-amber-300' : 'text-txt-faint hover:text-content-secondary'
|
||||
}`}
|
||||
title={t('panel.close')}
|
||||
aria-label={t('panel.close')}
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-txt-muted hover:bg-dark-700"
|
||||
>
|
||||
<ShieldAlert className="h-3.5 w-3.5" />
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
title={t('panel.newTopic')}
|
||||
aria-label={t('panel.newTopic')}
|
||||
disabled={running}
|
||||
onClick={startNewTopic}
|
||||
className="rounded p-1 text-txt-muted hover:bg-dark-700 disabled:opacity-40"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t('panel.close')}
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-txt-muted hover:bg-dark-700"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { type MouseEvent, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
/**
|
||||
* Full-window layer (modal, palette, drawer). Portaled to document.body so
|
||||
* TabContent overflow / containing blocks cannot clip it or steal title-bar
|
||||
* hit testing. Only mount when `open` — a leftover inset-0 node blocks
|
||||
* terminal input and window dragging.
|
||||
*/
|
||||
export interface AppOverlayProps {
|
||||
open: boolean
|
||||
children: ReactNode
|
||||
className?: string
|
||||
onMouseDown?: (event: MouseEvent<HTMLDivElement>) => void
|
||||
}
|
||||
|
||||
export default function AppOverlay({ open, children, className = '', onMouseDown }: AppOverlayProps) {
|
||||
if (!open || typeof document === 'undefined') return null
|
||||
|
||||
return createPortal(
|
||||
<div className={`app-overlay ${className}`.trim()} data-app-overlay="" onMouseDown={onMouseDown}>
|
||||
{children}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../../stores'
|
||||
import { disposeAndCloseAllTabs, disposeAndCloseTab } from '../../utils/disposeTab'
|
||||
import { buildOpenTabPayload } from '../../utils/sessionProtocol'
|
||||
import AppOverlay from './AppOverlay'
|
||||
|
||||
// ─── 类型定义 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -309,13 +310,8 @@ export function CommandPalette({ onNewSession, onQuickConnect, onOpenSettings, o
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh]"
|
||||
onClick={handleBackdropClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 animate-fade-in bg-black/60 backdrop-blur-sm" />
|
||||
<AppOverlay open className="z-[100] flex items-start justify-center pt-[15vh]" onMouseDown={handleBackdropClick}>
|
||||
<div className="absolute inset-0 animate-fade-in bg-black/60 backdrop-blur-sm" onClick={close} />
|
||||
|
||||
{/* Palette container */}
|
||||
<div
|
||||
@@ -323,6 +319,7 @@ export function CommandPalette({ onNewSession, onQuickConnect, onOpenSettings, o
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('palette.aria')}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-3 border-b border-dark-700 px-4 py-3">
|
||||
@@ -451,7 +448,7 @@ export function CommandPalette({ onNewSession, onQuickConnect, onOpenSettings, o
|
||||
<span>{t('palette.resultCount', { count: filteredItems.length })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppOverlay>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,52 +4,56 @@ import { handleError } from '../../utils/error-handler'
|
||||
interface DefaultFallbackProps {
|
||||
error: Error
|
||||
onReset: () => void
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export default function DefaultFallback({ error, onReset }: DefaultFallbackProps) {
|
||||
export default function DefaultFallback({ error, onReset, compact = false }: DefaultFallbackProps) {
|
||||
const { t } = useTranslation('common')
|
||||
const appError = handleError(error)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-dark-900 p-4">
|
||||
<div className="max-w-md w-full bg-dark-800 rounded-xl p-6 border border-dark-700">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-red-500/20 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
<div
|
||||
className={`flex items-center justify-center bg-dark-900 p-4 ${compact ? 'h-full min-h-[12rem]' : 'min-h-screen'}`}
|
||||
>
|
||||
<div className="w-full max-w-md rounded-xl border border-dark-700 bg-dark-800 p-6">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-500/20">
|
||||
<svg className="h-6 w-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-content-primary">{t('error.title')}</h2>
|
||||
<p className="text-sm text-txt-muted">{t('error.body')}</p>
|
||||
<p className="text-sm text-txt-muted">{compact ? t('error.bodyPane') : t('error.body')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-dark-900 rounded-lg p-4 mb-4">
|
||||
<p className="text-sm text-content-secondary mb-2">{appError.message}</p>
|
||||
{appError.recoverable && (
|
||||
<p className="text-xs text-green-400">{t('error.recoverable')}</p>
|
||||
)}
|
||||
<div className="mb-4 rounded-lg bg-dark-900 p-4">
|
||||
<p className="mb-2 text-sm text-content-secondary">{appError.message}</p>
|
||||
{appError.recoverable && <p className="text-xs text-green-400">{t('error.recoverable')}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="flex-1 px-4 py-2 bg-accent-blue hover:bg-blue-600 text-content-primary rounded-lg transition-colors text-sm font-medium"
|
||||
className="flex-1 rounded-lg bg-accent-blue px-4 py-2 text-sm font-medium text-content-primary transition-colors hover:bg-blue-600"
|
||||
>
|
||||
{t('error.refreshPage')}
|
||||
{compact ? t('error.retryPane') : t('error.refreshPage')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-dark-700 hover:bg-dark-600 text-content-secondary rounded-lg transition-colors text-sm"
|
||||
className="rounded-lg bg-dark-700 px-4 py-2 text-sm text-content-secondary transition-colors hover:bg-dark-600"
|
||||
>
|
||||
{t('error.restartApp')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-xs text-txt-faint text-center">
|
||||
{t('error.supportHint')}
|
||||
</p>
|
||||
<p className="mt-4 text-center text-xs text-txt-faint">{t('error.supportHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
/**
|
||||
* 错误边界组件
|
||||
*
|
||||
* 捕获子组件树中的 JavaScript 错误,防止整个应用崩溃
|
||||
* 体现 "安全可靠" 和 "操作便捷人性化" 理念
|
||||
*/
|
||||
|
||||
import { Component, type ReactNode } from 'react'
|
||||
import { createLogger } from '../../utils/logger'
|
||||
import DefaultFallback from './DefaultFallback'
|
||||
@@ -14,6 +7,10 @@ const logger = createLogger('ErrorBoundary')
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
/** In-pane fallback so one tab crash does not look like a full-app failure. */
|
||||
compact?: boolean
|
||||
/** Changing this key clears a previously captured error. */
|
||||
resetKey?: string | number
|
||||
onError?: (error: Error, errorInfo: React.ErrorInfo) => void
|
||||
}
|
||||
|
||||
@@ -22,14 +19,6 @@ interface State {
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误边界组件
|
||||
*
|
||||
* @example
|
||||
* <ErrorBoundary>
|
||||
* <MyComponent />
|
||||
* </ErrorBoundary>
|
||||
*/
|
||||
export default class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
@@ -40,15 +29,18 @@ export default class ErrorBoundary extends Component<Props, State> {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
// 记录错误日志
|
||||
logger.error('组件错误边界捕获到异常', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
componentStack: errorInfo.componentStack
|
||||
})
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (this.props.resetKey !== prevProps.resetKey && this.state.hasError) {
|
||||
this.setState({ hasError: false, error: null })
|
||||
}
|
||||
}
|
||||
|
||||
// 调用外部错误处理回调
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
logger.error('组件错误边界捕获到异常', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
componentStack: errorInfo.componentStack,
|
||||
})
|
||||
this.props.onError?.(error, errorInfo)
|
||||
}
|
||||
|
||||
@@ -58,16 +50,10 @@ export default class ErrorBoundary extends Component<Props, State> {
|
||||
|
||||
render() {
|
||||
if (this.state.hasError && this.state.error) {
|
||||
// 使用自定义 fallback 或默认 fallback
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback
|
||||
}
|
||||
return (
|
||||
<DefaultFallback
|
||||
error={this.state.error}
|
||||
onReset={this.handleReset}
|
||||
/>
|
||||
)
|
||||
return <DefaultFallback error={this.state.error} onReset={this.handleReset} compact={this.props.compact} />
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { type ReactNode, useEffect, useId, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { X } from 'lucide-react'
|
||||
import AppOverlay from './AppOverlay'
|
||||
import Button from './Button'
|
||||
|
||||
export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full'
|
||||
@@ -69,9 +70,7 @@ export default function Modal({
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
restoreFocusRef.current = document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null
|
||||
restoreFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
|
||||
const focusables = () =>
|
||||
Array.from(
|
||||
@@ -125,59 +124,58 @@ export default function Modal({
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
<AppOverlay
|
||||
open
|
||||
className="z-[100] flex items-center justify-center p-4 sm:p-6"
|
||||
onMouseDown={
|
||||
disableBackdropClose
|
||||
? undefined
|
||||
: (event) => {
|
||||
if (event.target === event.currentTarget) onClose()
|
||||
}
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/55 backdrop-blur-[2px] animate-fade-in"
|
||||
className="absolute inset-0 animate-fade-in bg-black/55 backdrop-blur-[2px]"
|
||||
onClick={disableBackdropClose ? undefined : onClose}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`
|
||||
relative w-full ${sizeClass[size]} max-h-[min(90vh,880px)]
|
||||
flex flex-col
|
||||
bg-dark-800 border border-dark-700/80 rounded-2xl
|
||||
shadow-[0_25px_50px_-12px_rgba(0,0,0,0.55)]
|
||||
animate-scale-in
|
||||
${className}
|
||||
`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
className={`relative w-full ${sizeClass[size]} border-dark-700/80 flex max-h-[min(90vh,880px)] animate-scale-in flex-col rounded-2xl border bg-dark-800 shadow-[0_25px_50px_-12px_rgba(0,0,0,0.55)] ${className} `}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-3 px-5 sm:px-6 py-4 border-b border-dark-700/60 flex-shrink-0">
|
||||
<div className="border-dark-700/60 flex flex-shrink-0 items-start gap-3 border-b px-5 py-4 sm:px-6">
|
||||
{icon && (
|
||||
<div className="w-10 h-10 rounded-xl bg-accent-blue/15 border border-accent-blue/20 flex items-center justify-center flex-shrink-0 text-accent-blue">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl border border-accent-blue/20 bg-accent-blue/15 text-accent-blue">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<h3 id={titleId} className="text-base sm:text-lg font-semibold text-content-primary tracking-tight">
|
||||
<h3 id={titleId} className="text-base font-semibold tracking-tight text-content-primary sm:text-lg">
|
||||
{title}
|
||||
</h3>
|
||||
{description && (
|
||||
<p className="text-xs sm:text-sm text-txt-muted mt-0.5 leading-relaxed">{description}</p>
|
||||
)}
|
||||
{description && <p className="mt-0.5 text-xs leading-relaxed text-txt-muted sm:text-sm">{description}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 -mr-1 rounded-lg text-txt-muted hover:text-content-primary hover:bg-dark-700 transition-colors flex-shrink-0"
|
||||
className="-mr-1 flex-shrink-0 rounded-lg p-1.5 text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
|
||||
aria-label={t('action.close')}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 sm:px-6 py-4 overflow-y-auto flex-1 min-h-0">{children}</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4 sm:px-6">{children}</div>
|
||||
|
||||
{/* Footer */}
|
||||
{showFooter && (
|
||||
<div className="flex items-center justify-end gap-2.5 px-5 sm:px-6 py-3.5 border-t border-dark-700/60 bg-dark-900/30 rounded-b-2xl flex-shrink-0">
|
||||
<div className="border-dark-700/60 bg-dark-900/30 flex flex-shrink-0 items-center justify-end gap-2.5 rounded-b-2xl border-t px-5 py-3.5 sm:px-6">
|
||||
{footer ?? (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={onClose} disabled={loading}>
|
||||
@@ -199,6 +197,6 @@ export default function Modal({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AppOverlay>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { useConnectionPanelStore } from '../../stores/connectionPanelStore'
|
||||
import type { PooledConnection } from '../../stores/connection/types'
|
||||
import EmptyState from '../common/EmptyState'
|
||||
import StatusDot from '../common/StatusDot'
|
||||
import AppOverlay from '../common/AppOverlay'
|
||||
|
||||
// ============== Helpers ==============
|
||||
|
||||
@@ -222,12 +223,9 @@ export default function ConnectionPanel() {
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 z-40 animate-fade-in bg-black/30" onClick={close} />
|
||||
|
||||
{/* Panel */}
|
||||
<div className="bg-dark-800/95 border-dark-700/30 animate-slide-in-right fixed bottom-0 right-0 top-0 z-50 flex w-[360px] flex-col border-l shadow-xl backdrop-blur">
|
||||
<AppOverlay open className="z-40">
|
||||
<div className="absolute inset-0 animate-fade-in bg-black/30" onClick={close} />
|
||||
<div className="bg-dark-800/95 border-dark-700/30 animate-slide-in-right absolute bottom-0 right-0 top-0 flex w-[360px] flex-col border-l shadow-xl backdrop-blur">
|
||||
{/* Header */}
|
||||
<div className="border-dark-700/30 flex h-12 shrink-0 items-center justify-between border-b px-4">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-content-primary">
|
||||
@@ -274,6 +272,6 @@ export default function ConnectionPanel() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</AppOverlay>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,24 @@
|
||||
/**
|
||||
* Right drawer for SSH port forwarding (SessionDialog-style).
|
||||
* Portaled to document.body so TabContent overflow-hidden cannot clip it.
|
||||
* Uses AppOverlay (portaled to document.body) so TabContent overflow cannot clip it.
|
||||
*
|
||||
* Dynamic is a single SOCKS5 on 127.0.0.1 (default port 1080; port set in the form below).
|
||||
* Local / Remote rules appear in the list and can be added repeatedly.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ArrowRightLeft, Globe, Play, Server, Square, Trash2, X } from 'lucide-react'
|
||||
import AppOverlay from '../common/AppOverlay'
|
||||
import ForwardArchitectureDiagram from './ForwardArchitectureDiagram'
|
||||
import {
|
||||
DEFAULT_DYNAMIC_PORT,
|
||||
DEFAULT_LOCAL_BIND_HOST,
|
||||
DEFAULT_REMOTE_BIND_HOST,
|
||||
formatHostPort,
|
||||
isAllInterfacesBind,
|
||||
parseHostPort,
|
||||
resolveDynamicPort,
|
||||
resolveForwardTargetHost,
|
||||
resolveLocalBindHost,
|
||||
resolveRemoteBindHost,
|
||||
@@ -24,15 +26,6 @@ import {
|
||||
type ForwardRuleType,
|
||||
} from './forwardRules'
|
||||
|
||||
export const DEFAULT_DYNAMIC_PORT = 1080
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- shared helpers with drawer UI
|
||||
export function resolveDynamicPort(port: unknown): number {
|
||||
const value = Number(port)
|
||||
if (Number.isInteger(value) && value >= 1 && value <= 65535) return value
|
||||
return DEFAULT_DYNAMIC_PORT
|
||||
}
|
||||
|
||||
export interface ForwardRuleDraft {
|
||||
type: ForwardRuleType
|
||||
name: string
|
||||
@@ -164,9 +157,7 @@ export default function ForwardRuleDrawer({
|
||||
const localRemoteRules = (rules || []).filter((rule) => rule.type !== 'dynamic')
|
||||
const dynamicRuntime = dynamicRule ? runtimes?.get(dynamicRule.id) : undefined
|
||||
const dynamicRunning = Boolean(dynamicRuntime?.running)
|
||||
const dynamicPort = resolveDynamicPort(
|
||||
dynamicRuntime?.boundPort || dynamicRule?.localPort || draft.localPort
|
||||
)
|
||||
const dynamicPort = resolveDynamicPort(dynamicRuntime?.boundPort || dynamicRule?.localPort || draft.localPort)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
@@ -190,16 +181,9 @@ export default function ForwardRuleDrawer({
|
||||
const localName = t('portForward.defaultLocalName')
|
||||
const remoteName = t('portForward.defaultRemoteName')
|
||||
const current = (draft.name || '').trim()
|
||||
const isPreset =
|
||||
!current || current === socksName || current === localName || current === remoteName
|
||||
const isPreset = !current || current === socksName || current === localName || current === remoteName
|
||||
|
||||
const nextName = !isPreset
|
||||
? current
|
||||
: next === 'dynamic'
|
||||
? socksName
|
||||
: next === 'remote'
|
||||
? remoteName
|
||||
: localName
|
||||
const nextName = !isPreset ? current : next === 'dynamic' ? socksName : next === 'remote' ? remoteName : localName
|
||||
|
||||
if (next === 'dynamic') {
|
||||
onChange({
|
||||
@@ -217,21 +201,15 @@ export default function ForwardRuleDrawer({
|
||||
|
||||
const nextLocalPort = draft.type === 'dynamic' ? 8080 : draft.localPort || 8080
|
||||
const nextRemotePort =
|
||||
draft.type === 'dynamic'
|
||||
? next === 'remote'
|
||||
? 18080
|
||||
: 80
|
||||
: draft.remotePort || (next === 'remote' ? 18080 : 80)
|
||||
draft.type === 'dynamic' ? (next === 'remote' ? 18080 : 80) : draft.remotePort || (next === 'remote' ? 18080 : 80)
|
||||
|
||||
onChange({
|
||||
...draft,
|
||||
type: next,
|
||||
name: nextName,
|
||||
localPort: nextLocalPort,
|
||||
localBindHost:
|
||||
next === 'local' ? resolveLocalBindHost(draft.localBindHost) : '',
|
||||
remoteBindHost:
|
||||
next === 'remote' ? resolveRemoteBindHost(draft.remoteBindHost) : '',
|
||||
localBindHost: next === 'local' ? resolveLocalBindHost(draft.localBindHost) : '',
|
||||
remoteBindHost: next === 'remote' ? resolveRemoteBindHost(draft.remoteBindHost) : '',
|
||||
remoteHost: resolveForwardTargetHost(draft.remoteHost) || '127.0.0.1',
|
||||
remotePort: nextRemotePort,
|
||||
})
|
||||
@@ -246,16 +224,16 @@ export default function ForwardRuleDrawer({
|
||||
? t('action.save')
|
||||
: t('portForward.addRule')
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[100] flex justify-end">
|
||||
<div className="absolute inset-0 bg-black/45 backdrop-blur-[1px] animate-fade-in" onClick={onClose} />
|
||||
return (
|
||||
<AppOverlay open className="z-[100] flex justify-end">
|
||||
<div className="absolute inset-0 animate-fade-in bg-black/45 backdrop-blur-[1px]" onClick={onClose} />
|
||||
<aside
|
||||
className="relative flex h-full w-full max-w-[420px] flex-col border-l border-dark-700/70 bg-dark-800 shadow-2xl animate-slide-in-right"
|
||||
className="border-dark-700/70 animate-slide-in-right relative flex h-full w-full max-w-[420px] flex-col border-l bg-dark-800 shadow-2xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="forward-drawer-title"
|
||||
>
|
||||
<div className="flex flex-shrink-0 items-center gap-3 border-b border-dark-700/50 px-4 py-3.5">
|
||||
<div className="border-dark-700/50 flex flex-shrink-0 items-center gap-3 border-b px-4 py-3.5">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl border border-accent-blue/20 bg-accent-blue/15 text-accent-blue">
|
||||
<ArrowRightLeft className="h-4 w-4" />
|
||||
</div>
|
||||
@@ -286,9 +264,7 @@ export default function ForwardRuleDrawer({
|
||||
{/* Fixed Dynamic slot */}
|
||||
<div
|
||||
className={`rounded-xl border px-3 py-2.5 ${
|
||||
dynamicRunning
|
||||
? 'border-accent-blue/40 bg-accent-blue/10'
|
||||
: 'border-dark-700/60 bg-dark-900/30'
|
||||
dynamicRunning ? 'border-accent-blue/40 bg-accent-blue/10' : 'border-dark-700/60 bg-dark-900/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
@@ -297,21 +273,15 @@ export default function ForwardRuleDrawer({
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-xs font-medium text-content-primary">
|
||||
{t('portForward.typeDynamic')}
|
||||
</span>
|
||||
<span className="rounded bg-orange-500/15 px-1.5 py-px text-[10px] text-orange-300">
|
||||
SOCKS5
|
||||
</span>
|
||||
<span className="text-xs font-medium text-content-primary">{t('portForward.typeDynamic')}</span>
|
||||
<span className="rounded bg-orange-500/15 px-1.5 py-px text-[10px] text-orange-300">SOCKS5</span>
|
||||
{dynamicRunning && (
|
||||
<span className="rounded bg-green-500/15 px-1.5 py-px text-[10px] text-green-400">
|
||||
{t('portForward.running')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 font-mono text-[11px] text-txt-muted">
|
||||
127.0.0.1:{dynamicPort}
|
||||
</p>
|
||||
<p className="mt-0.5 font-mono text-[11px] text-txt-muted">127.0.0.1:{dynamicPort}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
{dynamicRule && dynamicRunning && onStopRule && (
|
||||
@@ -369,9 +339,7 @@ export default function ForwardRuleDrawer({
|
||||
<div
|
||||
key={rule.id}
|
||||
className={`rounded-xl border px-3 py-2 ${
|
||||
running
|
||||
? 'border-green-500/30 bg-green-500/10'
|
||||
: 'border-dark-700/60 bg-dark-900/30'
|
||||
running ? 'border-green-500/30 bg-green-500/10' : 'border-dark-700/60 bg-dark-900/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
@@ -390,9 +358,7 @@ export default function ForwardRuleDrawer({
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="truncate text-xs font-medium text-content-primary">
|
||||
{rule.name}
|
||||
</span>
|
||||
<span className="truncate text-xs font-medium text-content-primary">{rule.name}</span>
|
||||
<span
|
||||
className={`rounded px-1.5 py-px text-[10px] ${
|
||||
rule.type === 'local'
|
||||
@@ -400,9 +366,7 @@ export default function ForwardRuleDrawer({
|
||||
: 'bg-purple-500/15 text-purple-300'
|
||||
}`}
|
||||
>
|
||||
{rule.type === 'local'
|
||||
? t('portForward.typeLocal')
|
||||
: t('portForward.typeRemote')}
|
||||
{rule.type === 'local' ? t('portForward.typeLocal') : t('portForward.typeRemote')}
|
||||
</span>
|
||||
{running && (
|
||||
<span className="rounded bg-green-500/15 px-1.5 py-px text-[10px] text-green-400">
|
||||
@@ -463,7 +427,7 @@ export default function ForwardRuleDrawer({
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
|
||||
{showList ? t('portForward.addSection') : t('portForward.selectType')}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-0 overflow-hidden rounded-lg border border-dark-600 bg-dark-900/40 p-0.5">
|
||||
<div className="bg-dark-900/40 grid grid-cols-3 gap-0 overflow-hidden rounded-lg border border-dark-600 p-0.5">
|
||||
{types.map((item) => (
|
||||
<button
|
||||
key={item.type}
|
||||
@@ -487,7 +451,7 @@ export default function ForwardRuleDrawer({
|
||||
<ForwardArchitectureDiagram type={type} />
|
||||
|
||||
{type === 'dynamic' ? (
|
||||
<section className="space-y-2 rounded-xl border border-dark-700/50 bg-dark-900/30 p-3.5">
|
||||
<section className="border-dark-700/50 bg-dark-900/30 space-y-2 rounded-xl border p-3.5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
|
||||
{t('portForward.typeDynamic')}
|
||||
</div>
|
||||
@@ -511,7 +475,7 @@ export default function ForwardRuleDrawer({
|
||||
</p>
|
||||
</section>
|
||||
) : (
|
||||
<section className="space-y-3 rounded-xl border border-dark-700/50 bg-dark-900/30 p-3.5">
|
||||
<section className="border-dark-700/50 bg-dark-900/30 space-y-3 rounded-xl border p-3.5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
|
||||
{t('portForward.ruleFields')}
|
||||
</div>
|
||||
@@ -531,7 +495,7 @@ export default function ForwardRuleDrawer({
|
||||
{type === 'local' ? (
|
||||
<>
|
||||
{viaSession ? (
|
||||
<p className="rounded-lg border border-dark-700/50 bg-dark-900/40 px-2.5 py-1.5 font-mono text-[11px] text-txt-muted">
|
||||
<p className="border-dark-700/50 bg-dark-900/40 rounded-lg border px-2.5 py-1.5 font-mono text-[11px] text-txt-muted">
|
||||
{t('portForward.viaSession', { target: viaSession })}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -561,10 +525,7 @@ export default function ForwardRuleDrawer({
|
||||
<label className="ui-label">{t('portForward.targetAddress')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formatHostPort(
|
||||
resolveForwardTargetHost(draft.remoteHost),
|
||||
draft.remotePort
|
||||
)}
|
||||
value={formatHostPort(resolveForwardTargetHost(draft.remoteHost), draft.remotePort)}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value.trim()
|
||||
const parsed = parseHostPort(raw)
|
||||
@@ -600,7 +561,7 @@ export default function ForwardRuleDrawer({
|
||||
: t('portForward.targetAddressHint')}
|
||||
</p>
|
||||
</div>
|
||||
<details className="rounded-lg border border-dark-700/40 bg-dark-900/20 px-2.5 py-2">
|
||||
<details className="border-dark-700/40 bg-dark-900/20 rounded-lg border px-2.5 py-2">
|
||||
<summary className="cursor-pointer select-none text-[11px] text-txt-muted hover:text-content-secondary">
|
||||
{t('portForward.localBindAdvanced')}
|
||||
</summary>
|
||||
@@ -628,7 +589,7 @@ export default function ForwardRuleDrawer({
|
||||
) : (
|
||||
<>
|
||||
{viaSession ? (
|
||||
<p className="rounded-lg border border-dark-700/50 bg-dark-900/40 px-2.5 py-1.5 font-mono text-[11px] text-txt-muted">
|
||||
<p className="border-dark-700/50 bg-dark-900/40 rounded-lg border px-2.5 py-1.5 font-mono text-[11px] text-txt-muted">
|
||||
{t('portForward.viaSessionRemote', { target: viaSession })}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -658,10 +619,7 @@ export default function ForwardRuleDrawer({
|
||||
<label className="ui-label">{t('portForward.localTargetAddress')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formatHostPort(
|
||||
resolveForwardTargetHost(draft.remoteHost),
|
||||
draft.localPort
|
||||
)}
|
||||
value={formatHostPort(resolveForwardTargetHost(draft.remoteHost), draft.localPort)}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value.trim()
|
||||
const parsed = parseHostPort(raw)
|
||||
@@ -696,7 +654,7 @@ export default function ForwardRuleDrawer({
|
||||
</p>
|
||||
</div>
|
||||
<details
|
||||
className="rounded-lg border border-dark-700/40 bg-dark-900/20 px-2.5 py-2"
|
||||
className="border-dark-700/40 bg-dark-900/20 rounded-lg border px-2.5 py-2"
|
||||
open={isAllInterfacesBind(resolveRemoteBindHost(draft.remoteBindHost))}
|
||||
>
|
||||
<summary className="cursor-pointer select-none text-[11px] text-txt-muted hover:text-content-secondary">
|
||||
@@ -734,7 +692,7 @@ export default function ForwardRuleDrawer({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 gap-2 border-t border-dark-700/50 px-4 py-3">
|
||||
<div className="border-dark-700/50 flex flex-shrink-0 gap-2 border-t px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
@@ -753,7 +711,6 @@ export default function ForwardRuleDrawer({
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>,
|
||||
document.body
|
||||
</AppOverlay>
|
||||
)
|
||||
}
|
||||
@@ -10,18 +10,17 @@ import { useTabStore } from '../../stores/tabStore'
|
||||
import { useConnectionPool } from '../../stores/connection'
|
||||
import { ArrowRightLeft, Copy, CopyPlus, Globe, Pencil, Server, Plus, Trash2, Play, Square } from 'lucide-react'
|
||||
import { useConfirmDialog } from '../common/ConfirmDialog'
|
||||
import { translateErrorMessage } from '../../i18n/format'
|
||||
import { translateErrorMessage, translateIpcError } from '../../i18n/format'
|
||||
import type { SSHForwardStatus } from '../../../shared/types/ipc.types'
|
||||
import ForwardRuleDrawer, {
|
||||
DEFAULT_DYNAMIC_PORT,
|
||||
emptyForwardDraft,
|
||||
resolveDynamicPort,
|
||||
} from './ForwardRuleDrawer'
|
||||
import ForwardRuleDrawer, { emptyForwardDraft } from './ForwardRuleDrawer'
|
||||
import { startNativeForward, stopNativeForward } from './forwardRuntime'
|
||||
import {
|
||||
DEFAULT_DYNAMIC_PORT,
|
||||
formatTransferBytes,
|
||||
duplicateForwardRule,
|
||||
parseForwardRules,
|
||||
portForwardStorageKey,
|
||||
resolveDynamicPort,
|
||||
resolveForwardTargetHost,
|
||||
resolveLocalBindHost,
|
||||
resolveRemoteBindHost,
|
||||
@@ -156,11 +155,7 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
|
||||
)
|
||||
return
|
||||
}
|
||||
if (
|
||||
newRule.type === 'dynamic' &&
|
||||
!editingRuleId &&
|
||||
rules.some((item) => item.type === 'dynamic')
|
||||
) {
|
||||
if (newRule.type === 'dynamic' && !editingRuleId && rules.some((item) => item.type === 'dynamic')) {
|
||||
setDrawerError(t('common:portForward.errorDynamicExists'))
|
||||
return
|
||||
}
|
||||
@@ -170,12 +165,9 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
|
||||
type: newRule.type as 'local' | 'remote' | 'dynamic',
|
||||
name: newRule.name.trim(),
|
||||
localPort: newRule.type === 'dynamic' ? resolveDynamicPort(newRule.localPort) : newRule.localPort!,
|
||||
localBindHost:
|
||||
newRule.type === 'local' ? resolveLocalBindHost(newRule.localBindHost) : undefined,
|
||||
remoteBindHost:
|
||||
newRule.type === 'remote' ? resolveRemoteBindHost(newRule.remoteBindHost) : undefined,
|
||||
remoteHost:
|
||||
newRule.type === 'dynamic' ? undefined : resolveForwardTargetHost(newRule.remoteHost),
|
||||
localBindHost: newRule.type === 'local' ? resolveLocalBindHost(newRule.localBindHost) : undefined,
|
||||
remoteBindHost: newRule.type === 'remote' ? resolveRemoteBindHost(newRule.remoteBindHost) : undefined,
|
||||
remoteHost: newRule.type === 'dynamic' ? undefined : resolveForwardTargetHost(newRule.remoteHost),
|
||||
remotePort: newRule.remotePort,
|
||||
}
|
||||
|
||||
@@ -194,43 +186,13 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
let result
|
||||
if (rule.type === 'local') {
|
||||
result = await window.opsxApi?.sshForwardLocal(
|
||||
connectionId,
|
||||
rule.localPort,
|
||||
resolveForwardTargetHost(rule.remoteHost),
|
||||
rule.remotePort || 0,
|
||||
rule.id,
|
||||
resolveLocalBindHost(rule.localBindHost)
|
||||
)
|
||||
} else if (rule.type === 'remote') {
|
||||
result = await window.opsxApi?.sshForwardRemote(
|
||||
connectionId,
|
||||
rule.remotePort || 0,
|
||||
resolveForwardTargetHost(rule.remoteHost),
|
||||
rule.localPort,
|
||||
rule.id,
|
||||
resolveRemoteBindHost(rule.remoteBindHost)
|
||||
)
|
||||
} else {
|
||||
result = await window.opsxApi?.sshForwardDynamic(
|
||||
connectionId,
|
||||
resolveDynamicPort(rule.localPort),
|
||||
rule.id
|
||||
)
|
||||
}
|
||||
|
||||
if (result?.success) {
|
||||
const result = await startNativeForward(connectionId, rule)
|
||||
if (result.success) {
|
||||
rememberForwardIntent(intentKey, rule)
|
||||
await refreshTunnels()
|
||||
return true
|
||||
}
|
||||
setError(
|
||||
result?.error
|
||||
? translateErrorMessage(result.error)
|
||||
: t('common:portForward.errorStartFailed')
|
||||
)
|
||||
setError(translateIpcError(result.error, result.errorCode) || t('common:portForward.errorStartFailed'))
|
||||
} catch (err: unknown) {
|
||||
const raw = err instanceof Error ? err.message : String(err)
|
||||
setError(raw ? translateErrorMessage(raw) : t('common:portForward.errorStartFailed'))
|
||||
@@ -284,23 +246,19 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
let result = await window.opsxApi?.sshForwardStop(rule.id)
|
||||
if (!result?.success && connectionId) {
|
||||
const listed = await window.opsxApi?.sshForwardList(connectionId)
|
||||
const match = listed?.tunnels?.find((tunnel) => tunnelCoversRule([tunnel], rule))
|
||||
if (match) {
|
||||
result = await window.opsxApi?.sshForwardStop(match.forwardId)
|
||||
}
|
||||
}
|
||||
if (!result?.success) {
|
||||
setError(result?.error || t('common:portForward.errorStopFailed'))
|
||||
const result = await stopNativeForward(connectionId, rule)
|
||||
if (!result.success) {
|
||||
setError(translateIpcError(result.error, result.errorCode) || t('common:portForward.errorStopFailed'))
|
||||
return false
|
||||
}
|
||||
forgetForwardIntent(intentKey, rule.id)
|
||||
await refreshTunnels()
|
||||
return true
|
||||
} catch (err: unknown) {
|
||||
setError((err instanceof Error ? err.message : String(err)) || t('common:portForward.errorStopFailed'))
|
||||
setError(
|
||||
translateErrorMessage(err instanceof Error ? err.message : String(err)) ||
|
||||
t('common:portForward.errorStopFailed')
|
||||
)
|
||||
return false
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -351,7 +309,7 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
|
||||
{needsReconnect && currentTab?.sessionId && (
|
||||
<div className="bg-dark-900/90 absolute inset-0 z-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="bg-accent-blue/20 mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-accent-blue/20">
|
||||
<ArrowRightLeft className="h-8 w-8 text-accent-blue" />
|
||||
</div>
|
||||
<div className="mb-2 text-lg text-content-primary">{t('common:portForward.sessionRestoredTitle')}</div>
|
||||
@@ -597,7 +555,6 @@ export default function PortForwardView({ tabId, isActive = true }: PortForwardV
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<ForwardRuleDrawer
|
||||
|
||||
@@ -3,6 +3,15 @@ import i18n from '../../i18n'
|
||||
|
||||
export type ForwardRuleType = 'local' | 'remote' | 'dynamic'
|
||||
|
||||
/** Default SOCKS5 listen port (`-D`). */
|
||||
export const DEFAULT_DYNAMIC_PORT = 1080
|
||||
|
||||
export function resolveDynamicPort(port: unknown): number {
|
||||
const value = Number(port)
|
||||
if (Number.isInteger(value) && value >= 1 && value <= 65535) return value
|
||||
return DEFAULT_DYNAMIC_PORT
|
||||
}
|
||||
|
||||
/** Default local listen bind for `-L` (this machine only). */
|
||||
export const DEFAULT_LOCAL_BIND_HOST = '127.0.0.1'
|
||||
|
||||
@@ -93,11 +102,7 @@ export function isInvalidForwardTargetHost(host: string): boolean {
|
||||
return isListenOnlyAddress(host)
|
||||
}
|
||||
|
||||
export function portForwardStorageKey(
|
||||
sessionId: string | undefined,
|
||||
tabId: string,
|
||||
connectionId?: string
|
||||
): string {
|
||||
export function portForwardStorageKey(sessionId: string | undefined, tabId: string, connectionId?: string): string {
|
||||
if (sessionId) return `portForwards_session_${sessionId}`
|
||||
if (connectionId) return `portForwards_conn_${connectionId}`
|
||||
return `portForwards_tab_${tabId}`
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { SSHForwardResponse } from '../../../shared/types/ipc.types'
|
||||
import { tunnelCoversRule } from './forwardIntent'
|
||||
import {
|
||||
resolveDynamicPort,
|
||||
resolveForwardTargetHost,
|
||||
resolveLocalBindHost,
|
||||
resolveRemoteBindHost,
|
||||
type ForwardRule,
|
||||
} from './forwardRules'
|
||||
|
||||
export type ForwardCommandResult = Pick<SSHForwardResponse, 'success' | 'error' | 'errorCode'>
|
||||
|
||||
function asResult(result?: SSHForwardResponse | null): ForwardCommandResult {
|
||||
return {
|
||||
success: Boolean(result?.success),
|
||||
error: result?.error,
|
||||
errorCode: result?.errorCode,
|
||||
}
|
||||
}
|
||||
|
||||
/** Start the native listener for a persisted rule. UI layers own toast / intent / refresh. */
|
||||
export async function startNativeForward(connectionId: string, rule: ForwardRule): Promise<ForwardCommandResult> {
|
||||
if (!window.opsxApi) return { success: false }
|
||||
|
||||
if (rule.type === 'local') {
|
||||
return asResult(
|
||||
await window.opsxApi.sshForwardLocal(
|
||||
connectionId,
|
||||
rule.localPort,
|
||||
resolveForwardTargetHost(rule.remoteHost),
|
||||
rule.remotePort || 0,
|
||||
rule.id,
|
||||
resolveLocalBindHost(rule.localBindHost)
|
||||
)
|
||||
)
|
||||
}
|
||||
if (rule.type === 'remote') {
|
||||
return asResult(
|
||||
await window.opsxApi.sshForwardRemote(
|
||||
connectionId,
|
||||
rule.remotePort || 0,
|
||||
resolveForwardTargetHost(rule.remoteHost),
|
||||
rule.localPort,
|
||||
rule.id,
|
||||
resolveRemoteBindHost(rule.remoteBindHost)
|
||||
)
|
||||
)
|
||||
}
|
||||
return asResult(await window.opsxApi.sshForwardDynamic(connectionId, resolveDynamicPort(rule.localPort), rule.id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the real native listener. After reconnect the runtime id may differ from
|
||||
* the persisted rule id — fall back to a tunnel that still covers the rule.
|
||||
*/
|
||||
export async function stopNativeForward(
|
||||
connectionId: string | undefined,
|
||||
rule: ForwardRule
|
||||
): Promise<ForwardCommandResult> {
|
||||
if (!window.opsxApi?.sshForwardStop) return { success: false }
|
||||
|
||||
let result = await window.opsxApi.sshForwardStop(rule.id)
|
||||
if (!result?.success && connectionId) {
|
||||
const listed = await window.opsxApi.sshForwardList(connectionId)
|
||||
const match = listed?.tunnels?.find((tunnel) => tunnelCoversRule([tunnel], rule))
|
||||
if (match) {
|
||||
result = await window.opsxApi.sshForwardStop(match.forwardId)
|
||||
}
|
||||
}
|
||||
return asResult(result)
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ClipboardEvent,
|
||||
type KeyboardEvent,
|
||||
type MouseEvent,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Cast, ClipboardPaste, FolderInput, FolderOpen, Maximize2, Minimize2, Monitor, RefreshCw } from 'lucide-react'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useDesktopFullscreenStore } from '../../stores/desktopFullscreenStore'
|
||||
import StatusDot from '../common/StatusDot'
|
||||
import type {
|
||||
@@ -35,8 +44,17 @@ import {
|
||||
vncInputKey,
|
||||
vncInputPointer,
|
||||
} from '../../platform/vnc'
|
||||
import { readClipboardText, writeClipboardText } from '../../platform/clipboard'
|
||||
import { sessionToRdpConfig, sessionToVncConfig } from '../../utils/sessionProtocol'
|
||||
import { clampDesktopResolution } from '../../utils/desktopResolution'
|
||||
import {
|
||||
clampDesktopResolution,
|
||||
desktopCanvasDisplaySize,
|
||||
isUsableDesktopPane,
|
||||
resolutionFromPane,
|
||||
type DesktopResolution,
|
||||
} from '../../utils/desktopResolution'
|
||||
import { reconnectDelayMs } from '../../utils/disconnectReason'
|
||||
import { subscribeTabReconnect } from '../../utils/tabReconnect'
|
||||
|
||||
interface DesktopSessionViewProps {
|
||||
tabId: string
|
||||
@@ -97,6 +115,12 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
const [fbSize, setFbSize] = useState({ width: 1920, height: 1080 })
|
||||
const [resolvedConfig, setResolvedConfig] = useState<RdpConfig | VncConfig | null>(null)
|
||||
const [chromeVisible, setChromeVisible] = useState(true)
|
||||
const [reconnectGeneration, setReconnectGeneration] = useState(0)
|
||||
const reconnectAttemptRef = useRef(0)
|
||||
const reconnectTimerRef = useRef<number | null>(null)
|
||||
const intentionalDisconnectRef = useRef(false)
|
||||
const hadConnectedRef = useRef(false)
|
||||
const pasteLockRef = useRef(0)
|
||||
|
||||
const protocol: DesktopProtocol = tab?.type === 'vnc' ? 'vnc' : 'rdp'
|
||||
isActiveRef.current = isActive
|
||||
@@ -134,6 +158,51 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
}
|
||||
}, [protocol, sessions, tab?.rdpConfig, tab?.sessionId, tab?.vncConfig])
|
||||
|
||||
const clearDesktopReconnectTimer = useCallback(() => {
|
||||
if (reconnectTimerRef.current != null) {
|
||||
window.clearTimeout(reconnectTimerRef.current)
|
||||
reconnectTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const scheduleDesktopReconnect = useCallback(() => {
|
||||
const { settings } = useSettingsStore.getState()
|
||||
if (!settings.autoReconnect) return
|
||||
if (intentionalDisconnectRef.current) return
|
||||
if (!hadConnectedRef.current) return
|
||||
const maxAttempts = settings.maxReconnectAttempts ?? 5
|
||||
const baseDelay = settings.reconnectBaseDelayMs ?? 1500
|
||||
if (reconnectAttemptRef.current >= maxAttempts) {
|
||||
setError(t('status.reconnectLimit', { max: maxAttempts }))
|
||||
updateTabStatus(tabId, 'error')
|
||||
return
|
||||
}
|
||||
reconnectAttemptRef.current += 1
|
||||
const attempt = reconnectAttemptRef.current
|
||||
const delay = reconnectDelayMs(attempt, baseDelay)
|
||||
updateTabStatus(tabId, 'connecting')
|
||||
setStatusMessage(t('status.reconnecting', { attempt, max: maxAttempts }))
|
||||
clearDesktopReconnectTimer()
|
||||
reconnectTimerRef.current = window.setTimeout(() => {
|
||||
reconnectTimerRef.current = null
|
||||
setReconnectGeneration((value) => value + 1)
|
||||
}, delay)
|
||||
}, [clearDesktopReconnectTimer, t, tabId, updateTabStatus])
|
||||
|
||||
const handleDesktopReconnect = useCallback(() => {
|
||||
clearDesktopReconnectTimer()
|
||||
reconnectAttemptRef.current = 0
|
||||
intentionalDisconnectRef.current = false
|
||||
setError(null)
|
||||
setReconnectGeneration((value) => value + 1)
|
||||
}, [clearDesktopReconnectTimer])
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearDesktopReconnectTimer()
|
||||
}, [clearDesktopReconnectTimer])
|
||||
|
||||
useEffect(() => subscribeTabReconnect(tabId, handleDesktopReconnect), [tabId, handleDesktopReconnect])
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedConfig) return
|
||||
let disposed = false
|
||||
@@ -208,28 +277,51 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
connectedRef.current = false
|
||||
lastRemoteResizeRef.current = null
|
||||
|
||||
const waitForPaneResolution = async (fallback: DesktopResolution): Promise<DesktopResolution> => {
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const pane = paneRef.current
|
||||
const width = pane?.clientWidth ?? 0
|
||||
const height = pane?.clientHeight ?? 0
|
||||
if (pane && isUsableDesktopPane(width, height)) {
|
||||
return resolutionFromPane(width, height, fallback)
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
window.requestAnimationFrame(() => resolve())
|
||||
})
|
||||
}
|
||||
const pane = paneRef.current
|
||||
return resolutionFromPane(pane?.clientWidth ?? 0, pane?.clientHeight ?? 0, fallback)
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
if (protocol === 'rdp') {
|
||||
unlistenStatus = await onRdpStatus((payload) => {
|
||||
if (nativeSessionIdRef.current && payload.sessionId !== nativeSessionIdRef.current) return
|
||||
if (!nativeSessionIdRef.current) nativeSessionIdRef.current = payload.sessionId
|
||||
if (!nativeSessionIdRef.current) {
|
||||
if (payload.status === 'disconnected' || payload.status === 'error') return
|
||||
nativeSessionIdRef.current = payload.sessionId
|
||||
}
|
||||
if (payload.width && payload.height) {
|
||||
setFbSize({ width: payload.width, height: payload.height })
|
||||
lastRemoteResizeRef.current = { width: payload.width, height: payload.height }
|
||||
}
|
||||
if (payload.status === 'connected') {
|
||||
connectedRef.current = true
|
||||
hadConnectedRef.current = true
|
||||
reconnectAttemptRef.current = 0
|
||||
updateTabStatus(tabId, 'connected')
|
||||
setStatusMessage(payload.message || t('status.connected'))
|
||||
} else if (payload.status === 'error') {
|
||||
connectedRef.current = false
|
||||
updateTabStatus(tabId, 'error')
|
||||
setError(payload.message || t('status.connectFailed'))
|
||||
if (!disposed) scheduleDesktopReconnect()
|
||||
} else if (payload.status === 'disconnected') {
|
||||
connectedRef.current = false
|
||||
updateTabStatus(tabId, 'disconnected')
|
||||
setStatusMessage(t('status.disconnected'))
|
||||
if (!disposed) scheduleDesktopReconnect()
|
||||
} else if (payload.message) {
|
||||
setStatusMessage(payload.message)
|
||||
}
|
||||
@@ -241,7 +333,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
unlistenClipboard = await onRdpClipboard((payload) => {
|
||||
if (payload.sessionId !== nativeSessionIdRef.current) return
|
||||
if (payload.text) {
|
||||
void navigator.clipboard.writeText(payload.text).catch(() => undefined)
|
||||
void writeClipboardText(payload.text).catch(() => undefined)
|
||||
setStatusMessage(t('status.clipboardSynced'))
|
||||
}
|
||||
})
|
||||
@@ -249,7 +341,15 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
if (payload.sessionId !== nativeSessionIdRef.current) return
|
||||
setStatusMessage(t('status.savedToDownloads', { name: payload.name }))
|
||||
})
|
||||
const sessionId = await rdpConnect(resolvedConfig as RdpConfig)
|
||||
const rdpConfig = resolvedConfig as RdpConfig
|
||||
const paneSize = await waitForPaneResolution(clampDesktopResolution(rdpConfig.width, rdpConfig.height))
|
||||
if (disposed) return
|
||||
lastRemoteResizeRef.current = paneSize
|
||||
const sessionId = await rdpConnect({
|
||||
...rdpConfig,
|
||||
width: paneSize.width,
|
||||
height: paneSize.height,
|
||||
})
|
||||
if (disposed) {
|
||||
await rdpDisconnect(sessionId)
|
||||
return
|
||||
@@ -258,16 +358,26 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
} else {
|
||||
unlistenStatus = await onVncStatus((payload) => {
|
||||
if (nativeSessionIdRef.current && payload.sessionId !== nativeSessionIdRef.current) return
|
||||
if (!nativeSessionIdRef.current) nativeSessionIdRef.current = payload.sessionId
|
||||
if (!nativeSessionIdRef.current) {
|
||||
if (payload.status === 'disconnected' || payload.status === 'error') return
|
||||
nativeSessionIdRef.current = payload.sessionId
|
||||
}
|
||||
if (payload.status === 'connected') {
|
||||
connectedRef.current = true
|
||||
hadConnectedRef.current = true
|
||||
reconnectAttemptRef.current = 0
|
||||
updateTabStatus(tabId, 'connected')
|
||||
setStatusMessage(payload.message || t('status.connected'))
|
||||
} else if (payload.status === 'error') {
|
||||
connectedRef.current = false
|
||||
updateTabStatus(tabId, 'error')
|
||||
setError(payload.message || t('status.connectFailed'))
|
||||
if (!disposed) scheduleDesktopReconnect()
|
||||
} else if (payload.status === 'disconnected') {
|
||||
connectedRef.current = false
|
||||
updateTabStatus(tabId, 'disconnected')
|
||||
setStatusMessage(t('status.disconnected'))
|
||||
if (!disposed) scheduleDesktopReconnect()
|
||||
} else if (payload.message) {
|
||||
setStatusMessage(payload.message)
|
||||
}
|
||||
@@ -279,7 +389,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
unlistenClipboard = await onVncClipboard((payload) => {
|
||||
if (payload.sessionId !== nativeSessionIdRef.current) return
|
||||
if (payload.text) {
|
||||
void navigator.clipboard.writeText(payload.text).catch(() => undefined)
|
||||
void writeClipboardText(payload.text).catch(() => undefined)
|
||||
setStatusMessage(t('status.clipboardSynced'))
|
||||
}
|
||||
})
|
||||
@@ -320,7 +430,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
else void vncDisconnect(sessionId)
|
||||
}
|
||||
}
|
||||
}, [protocol, resolvedConfig, tabId, updateTabStatus, t])
|
||||
}, [protocol, resolvedConfig, tabId, updateTabStatus, t, reconnectGeneration, scheduleDesktopReconnect])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
@@ -334,13 +444,15 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
if (!pane || !canvas || fbSize.width <= 0 || fbSize.height <= 0) return
|
||||
|
||||
const updateFit = () => {
|
||||
const availW = Math.max(1, pane.clientWidth)
|
||||
const availH = Math.max(1, pane.clientHeight)
|
||||
const scale = Math.min(availW / fbSize.width, availH / fbSize.height)
|
||||
const displayW = Math.max(1, Math.floor(fbSize.width * scale))
|
||||
const displayH = Math.max(1, Math.floor(fbSize.height * scale))
|
||||
canvas.style.width = `${displayW}px`
|
||||
canvas.style.height = `${displayH}px`
|
||||
const display = desktopCanvasDisplaySize(
|
||||
protocol,
|
||||
pane.clientWidth,
|
||||
pane.clientHeight,
|
||||
fbSize.width,
|
||||
fbSize.height
|
||||
)
|
||||
canvas.style.width = `${display.width}px`
|
||||
canvas.style.height = `${display.height}px`
|
||||
}
|
||||
|
||||
const requestRemoteResize = () => {
|
||||
@@ -503,14 +615,28 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
}
|
||||
}
|
||||
|
||||
const pasteToRemote = async () => {
|
||||
const pasteToRemote = async (textFromEvent?: string) => {
|
||||
const sessionId = nativeSessionIdRef.current
|
||||
if (!sessionId) return
|
||||
const text = await navigator.clipboard.readText()
|
||||
if (!text) return
|
||||
if (protocol === 'rdp') await rdpClipboardSet(sessionId, text)
|
||||
else await vncClipboardSet(sessionId, text)
|
||||
setStatusMessage(t('status.pastedToRemote'))
|
||||
const now = Date.now()
|
||||
if (now - pasteLockRef.current < 400) return
|
||||
pasteLockRef.current = now
|
||||
try {
|
||||
const text = textFromEvent ?? (await readClipboardText())
|
||||
if (!text) return
|
||||
if (protocol === 'rdp') await rdpClipboardSet(sessionId, text)
|
||||
else await vncClipboardSet(sessionId, text)
|
||||
setStatusMessage(t('status.pastedToRemote'))
|
||||
} catch (reason) {
|
||||
setStatusMessage(reason instanceof Error ? reason.message : String(reason))
|
||||
}
|
||||
}
|
||||
|
||||
const onPasteClipboard = (event: ClipboardEvent<HTMLCanvasElement>) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const text = event.clipboardData?.getData('text/plain')
|
||||
void pasteToRemote(text || undefined)
|
||||
}
|
||||
|
||||
const onKey = (event: KeyboardEvent<HTMLCanvasElement>, pressed: boolean) => {
|
||||
@@ -533,12 +659,12 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const isPasteChord =
|
||||
pressed && (event.metaKey || event.ctrlKey) && event.code === 'KeyV' && !event.altKey && !event.shiftKey
|
||||
const isPasteChord = (event.metaKey || event.ctrlKey) && event.code === 'KeyV' && !event.altKey && !event.shiftKey
|
||||
|
||||
if (isPasteChord) {
|
||||
// Do not forward Ctrl/Cmd+V — sync remote clipboard then native injects Ctrl+V.
|
||||
void pasteToRemote()
|
||||
// Swallow Cmd/Ctrl+V (keydown and keyup). Native VNC types the text as
|
||||
// keysyms so remote terminals do not wrap it in bracketed paste.
|
||||
if (pressed) void pasteToRemote()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -624,6 +750,16 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
)}
|
||||
|
||||
<div className="ml-1 flex flex-shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className={toolBtn}
|
||||
disabled={connected || tab?.connectionStatus === 'connecting'}
|
||||
onClick={() => handleDesktopReconnect()}
|
||||
title={t('toolbar.reconnect')}
|
||||
aria-label={t('toolbar.reconnectAria')}
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={toolBtn}
|
||||
@@ -707,14 +843,18 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
|
||||
<div
|
||||
ref={paneRef}
|
||||
className={`flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-black ${
|
||||
fullscreenActive ? 'p-0' : 'p-2'
|
||||
className={`flex min-h-0 flex-1 overflow-hidden bg-black ${
|
||||
protocol === 'rdp' || fullscreenActive
|
||||
? 'items-stretch justify-stretch p-0'
|
||||
: 'items-center justify-center p-2'
|
||||
}`}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
tabIndex={0}
|
||||
className="cursor-default shadow-lg shadow-black/40 outline-none"
|
||||
className={`cursor-default outline-none ${
|
||||
protocol === 'rdp' ? 'h-full w-full' : 'shadow-lg shadow-black/40'
|
||||
}`}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
onMouseMove={(event) => mapPointer(event)}
|
||||
onMouseDown={(event) => mapPointer(event, true)}
|
||||
@@ -722,6 +862,7 @@ export default function DesktopSessionView({ tabId, isActive }: DesktopSessionVi
|
||||
onMouseLeave={() => {
|
||||
buttonsRef.current = { left: false, middle: false, right: false }
|
||||
}}
|
||||
onPaste={onPasteClipboard}
|
||||
onKeyDown={(event) => onKey(event, true)}
|
||||
onKeyUp={(event) => onKey(event, false)}
|
||||
/>
|
||||
|
||||
@@ -7,21 +7,20 @@ import type { ConnectionState } from '../../stores/connection'
|
||||
import { useConnectionPanelStore } from '../../stores/connectionPanelStore'
|
||||
import { useSessionStore, useEntitlementStore } from '../../stores'
|
||||
import type { HostMetrics } from '../../../shared/types/host-metrics.types'
|
||||
import { formatLocaleTime, translateErrorMessage } from '../../i18n/format'
|
||||
import { formatLocaleTime, translateErrorMessage, translateIpcError } from '../../i18n/format'
|
||||
import { useToast } from '../../hooks/useToast'
|
||||
import ForwardRuleDrawer, {
|
||||
DEFAULT_DYNAMIC_PORT,
|
||||
emptyForwardDraft,
|
||||
resolveDynamicPort,
|
||||
} from '../connection/ForwardRuleDrawer'
|
||||
import ForwardRuleDrawer, { emptyForwardDraft } from '../connection/ForwardRuleDrawer'
|
||||
import {
|
||||
DEFAULT_DYNAMIC_PORT,
|
||||
parseForwardRules,
|
||||
portForwardStorageKey,
|
||||
resolveDynamicPort,
|
||||
resolveForwardTargetHost,
|
||||
resolveLocalBindHost,
|
||||
resolveRemoteBindHost,
|
||||
type ForwardRule,
|
||||
} from '../connection/forwardRules'
|
||||
import { startNativeForward, stopNativeForward } from '../connection/forwardRuntime'
|
||||
import {
|
||||
forgetForwardIntent,
|
||||
forwardIntentKey,
|
||||
@@ -42,43 +41,43 @@ interface StatusDisplay {
|
||||
|
||||
const STATUS_CONFIG: Record<string, Omit<StatusDisplay, 'labelKey'> & { labelKey: string }> = {
|
||||
connected: {
|
||||
icon: <Wifi className="w-3 h-3" />,
|
||||
icon: <Wifi className="h-3 w-3" />,
|
||||
labelKey: 'status.connected',
|
||||
colorClass: 'text-feedback-success',
|
||||
animateClass: '',
|
||||
},
|
||||
connecting: {
|
||||
icon: <Loader2 className="w-3 h-3 animate-spin" />,
|
||||
icon: <Loader2 className="h-3 w-3 animate-spin" />,
|
||||
labelKey: 'status.connecting',
|
||||
colorClass: 'text-feedback-warning',
|
||||
animateClass: 'animate-pulse',
|
||||
},
|
||||
background: {
|
||||
icon: <Wifi className="w-3 h-3" />,
|
||||
icon: <Wifi className="h-3 w-3" />,
|
||||
labelKey: 'status.background',
|
||||
colorClass: 'text-feedback-success',
|
||||
animateClass: '',
|
||||
},
|
||||
suspended: {
|
||||
icon: <WifiOff className="w-3 h-3" />,
|
||||
icon: <WifiOff className="h-3 w-3" />,
|
||||
labelKey: 'status.suspended',
|
||||
colorClass: 'text-feedback-warning',
|
||||
animateClass: '',
|
||||
},
|
||||
reconnecting: {
|
||||
icon: <Loader2 className="w-3 h-3 animate-spin" />,
|
||||
icon: <Loader2 className="h-3 w-3 animate-spin" />,
|
||||
labelKey: 'status.reconnecting',
|
||||
colorClass: 'text-feedback-warning',
|
||||
animateClass: 'animate-pulse',
|
||||
},
|
||||
error: {
|
||||
icon: <AlertCircle className="w-3 h-3" />,
|
||||
icon: <AlertCircle className="h-3 w-3" />,
|
||||
labelKey: 'status.error',
|
||||
colorClass: 'text-feedback-error',
|
||||
animateClass: '',
|
||||
},
|
||||
disconnected: {
|
||||
icon: <WifiOff className="w-3 h-3" />,
|
||||
icon: <WifiOff className="h-3 w-3" />,
|
||||
labelKey: 'status.disconnected',
|
||||
colorClass: 'text-txt-muted',
|
||||
animateClass: '',
|
||||
@@ -276,8 +275,7 @@ function RemoteHostMetricsStrip({
|
||||
metrics?.memPercent != null ? t('metrics.memory', { percent: metrics.memPercent, detail: memDetail }) : null,
|
||||
transferLabel ? t('metrics.sessionTransfer', { value: transferLabel.replace(/^↑↓\s*/, '') }) : null,
|
||||
...rankedDisks.map(
|
||||
(d) =>
|
||||
`${d.mount} ${d.percent}% (${formatBytes(d.usedKb * 1024)}/${formatBytes(d.totalKb * 1024)})`
|
||||
(d) => `${d.mount} ${d.percent}% (${formatBytes(d.usedKb * 1024)}/${formatBytes(d.totalKb * 1024)})`
|
||||
),
|
||||
].filter(Boolean)
|
||||
|
||||
@@ -333,21 +331,19 @@ function RemoteHostMetricsStrip({
|
||||
title={tipParts.join(' · ')}
|
||||
>
|
||||
{osLabel && (
|
||||
<span className="text-content-secondary max-w-[10rem] shrink truncate" title={osLabel}>
|
||||
<span className="max-w-[10rem] shrink truncate text-content-secondary" title={osLabel}>
|
||||
{osLabel}
|
||||
</span>
|
||||
)}
|
||||
{tz && <span className="text-txt-faint shrink-0">{tz}</span>}
|
||||
{tz && <span className="shrink-0 text-txt-faint">{tz}</span>}
|
||||
{metrics?.cpuPercent != null && (
|
||||
<span className={`shrink-0 ${pctClass(metrics.cpuPercent)}`}>
|
||||
CPU {formatCpuPercent(metrics.cpuPercent)}
|
||||
</span>
|
||||
<span className={`shrink-0 ${pctClass(metrics.cpuPercent)}`}>CPU {formatCpuPercent(metrics.cpuPercent)}</span>
|
||||
)}
|
||||
{metrics?.memPercent != null && (
|
||||
<span className={`shrink-0 ${pctClass(metrics.memPercent)}`}>MEM {metrics.memPercent}%</span>
|
||||
)}
|
||||
{transferLabel && (
|
||||
<span className="text-txt-muted flex shrink-0 items-center gap-1">
|
||||
<span className="flex shrink-0 items-center gap-1 text-txt-muted">
|
||||
<HardDrive className="h-3 w-3" />
|
||||
{transferLabel}
|
||||
</span>
|
||||
@@ -385,8 +381,7 @@ export default function StatusBar() {
|
||||
|
||||
const activeTab = useMemo(() => tabs.find((t) => t.id === activeTabId), [tabs, activeTabId])
|
||||
|
||||
const { connectionState, hostLabel, stats, connectionId, isRemoteSsh, sessionOsPretty } =
|
||||
useMemo(() => {
|
||||
const { connectionState, hostLabel, stats, connectionId, isRemoteSsh, sessionOsPretty } = useMemo(() => {
|
||||
if (!activeTab || activeTab.type === 'welcome' || activeTab.type === 'settings') {
|
||||
return {
|
||||
connectionState: undefined,
|
||||
@@ -425,9 +420,7 @@ export default function StatusBar() {
|
||||
}
|
||||
}
|
||||
|
||||
const session = activeTab.sessionId
|
||||
? sessions.find((s) => s.id === activeTab.sessionId)
|
||||
: undefined
|
||||
const session = activeTab.sessionId ? sessions.find((s) => s.id === activeTab.sessionId) : undefined
|
||||
const sessionOsPretty = session?.osPrettyName
|
||||
|
||||
const instance = activeTab.connectionInstance
|
||||
@@ -458,9 +451,7 @@ export default function StatusBar() {
|
||||
|
||||
const state = mapTabStatusToConnectionState(activeTab.connectionStatus)
|
||||
const config = activeTab.connectionConfig
|
||||
const label = config
|
||||
? `${config.username}@${config.host}:${config.port || 22}`
|
||||
: undefined
|
||||
const label = config ? `${config.username}@${config.host}:${config.port || 22}` : undefined
|
||||
|
||||
return {
|
||||
connectionState: state,
|
||||
@@ -473,9 +464,7 @@ export default function StatusBar() {
|
||||
}, [activeTab, connections, sessions, t])
|
||||
|
||||
const metricsEnabled =
|
||||
isRemoteSsh &&
|
||||
Boolean(connectionId) &&
|
||||
(connectionState === 'connected' || connectionState === 'background')
|
||||
isRemoteSsh && Boolean(connectionId) && (connectionState === 'connected' || connectionState === 'background')
|
||||
|
||||
const socksAvailable = metricsEnabled
|
||||
const { metrics, stale } = useRemoteHostMetrics(connectionId, metricsEnabled)
|
||||
@@ -632,38 +621,11 @@ export default function StatusBar() {
|
||||
|
||||
const startForwardRule = useCallback(
|
||||
async (rule: ForwardRule): Promise<boolean> => {
|
||||
if (!connectionId || !window.opsxApi) return false
|
||||
let result
|
||||
if (rule.type === 'local') {
|
||||
result = await window.opsxApi.sshForwardLocal(
|
||||
connectionId,
|
||||
rule.localPort,
|
||||
resolveForwardTargetHost(rule.remoteHost),
|
||||
rule.remotePort || 0,
|
||||
rule.id,
|
||||
resolveLocalBindHost(rule.localBindHost)
|
||||
)
|
||||
} else if (rule.type === 'remote') {
|
||||
result = await window.opsxApi.sshForwardRemote(
|
||||
connectionId,
|
||||
rule.remotePort || 0,
|
||||
resolveForwardTargetHost(rule.remoteHost),
|
||||
rule.localPort,
|
||||
rule.id,
|
||||
resolveRemoteBindHost(rule.remoteBindHost)
|
||||
)
|
||||
} else {
|
||||
result = await window.opsxApi.sshForwardDynamic(
|
||||
connectionId,
|
||||
resolveDynamicPort(rule.localPort),
|
||||
rule.id
|
||||
)
|
||||
}
|
||||
if (!result?.success) {
|
||||
if (!connectionId) return false
|
||||
const result = await startNativeForward(connectionId, rule)
|
||||
if (!result.success) {
|
||||
setForwardDrawerError(
|
||||
result?.error
|
||||
? translateErrorMessage(result.error)
|
||||
: t('common:portForward.errorStartFailed')
|
||||
translateIpcError(result.error, result.errorCode) || t('common:portForward.errorStartFailed')
|
||||
)
|
||||
return false
|
||||
}
|
||||
@@ -695,21 +657,10 @@ export default function StatusBar() {
|
||||
|
||||
const stopForwardRule = useCallback(
|
||||
async (rule: ForwardRule): Promise<boolean> => {
|
||||
if (!window.opsxApi?.sshForwardStop) return false
|
||||
let result = await window.opsxApi.sshForwardStop(rule.id)
|
||||
if (!result?.success && connectionId) {
|
||||
// Runtime id may differ from persisted rule id after reconnect.
|
||||
const listed = await window.opsxApi.sshForwardList(connectionId)
|
||||
const match = listed?.tunnels?.find((tunnel) => tunnelCoversRule([tunnel], rule))
|
||||
if (match) {
|
||||
result = await window.opsxApi.sshForwardStop(match.forwardId)
|
||||
}
|
||||
}
|
||||
if (!result?.success) {
|
||||
const result = await stopNativeForward(connectionId, rule)
|
||||
if (!result.success) {
|
||||
setForwardDrawerError(
|
||||
result?.error
|
||||
? translateErrorMessage(result.error)
|
||||
: t('common:portForward.errorStopFailed')
|
||||
translateIpcError(result.error, result.errorCode) || t('common:portForward.errorStopFailed')
|
||||
)
|
||||
return false
|
||||
}
|
||||
@@ -767,16 +718,7 @@ export default function StatusBar() {
|
||||
setForwardSaving(false)
|
||||
}
|
||||
},
|
||||
[
|
||||
connectionId,
|
||||
forwardRules,
|
||||
forwardRuntimes,
|
||||
forwardSaving,
|
||||
persistForwardRules,
|
||||
startForwardRule,
|
||||
t,
|
||||
toast,
|
||||
]
|
||||
[connectionId, forwardRules, forwardRuntimes, forwardSaving, persistForwardRules, startForwardRule, t, toast]
|
||||
)
|
||||
|
||||
const handleStopRule = useCallback(
|
||||
@@ -788,8 +730,7 @@ export default function StatusBar() {
|
||||
await stopForwardRule(rule)
|
||||
} catch (error) {
|
||||
setForwardDrawerError(
|
||||
(error instanceof Error ? error.message : String(error)) ||
|
||||
t('common:portForward.errorStopFailed')
|
||||
(error instanceof Error ? error.message : String(error)) || t('common:portForward.errorStopFailed')
|
||||
)
|
||||
} finally {
|
||||
setForwardSaving(false)
|
||||
@@ -811,8 +752,7 @@ export default function StatusBar() {
|
||||
await persistForwardRules(forwardRules.filter((item) => item.id !== rule.id))
|
||||
} catch (error) {
|
||||
setForwardDrawerError(
|
||||
(error instanceof Error ? error.message : String(error)) ||
|
||||
t('common:portForward.errorStopFailed')
|
||||
(error instanceof Error ? error.message : String(error)) || t('common:portForward.errorStopFailed')
|
||||
)
|
||||
} finally {
|
||||
setForwardSaving(false)
|
||||
@@ -872,14 +812,8 @@ export default function StatusBar() {
|
||||
type: forwardDraft.type || 'local',
|
||||
name: forwardDraft.name.trim(),
|
||||
localPort: forwardDraft.localPort,
|
||||
localBindHost:
|
||||
forwardDraft.type === 'local'
|
||||
? resolveLocalBindHost(forwardDraft.localBindHost)
|
||||
: undefined,
|
||||
remoteBindHost:
|
||||
forwardDraft.type === 'remote'
|
||||
? resolveRemoteBindHost(forwardDraft.remoteBindHost)
|
||||
: undefined,
|
||||
localBindHost: forwardDraft.type === 'local' ? resolveLocalBindHost(forwardDraft.localBindHost) : undefined,
|
||||
remoteBindHost: forwardDraft.type === 'remote' ? resolveRemoteBindHost(forwardDraft.remoteBindHost) : undefined,
|
||||
remoteHost: resolveForwardTargetHost(forwardDraft.remoteHost),
|
||||
remotePort: forwardDraft.remotePort,
|
||||
}
|
||||
@@ -959,35 +893,32 @@ export default function StatusBar() {
|
||||
const statusCfg = STATUS_CONFIG[statusKey] ?? STATUS_CONFIG.disconnected
|
||||
const statusLabel = t(statusCfg.labelKey)
|
||||
|
||||
const transferLabel =
|
||||
stats && stats.bytesTransferred > 0 ? `↑↓ ${formatBytes(stats.bytesTransferred)}` : null
|
||||
const transferLabel = stats && stats.bytesTransferred > 0 ? `↑↓ ${formatBytes(stats.bytesTransferred)}` : null
|
||||
|
||||
return (
|
||||
<div className="border-dark-700/40 bg-dark-800/95 flex h-7 select-none items-center px-3 text-[11px] text-content-primary">
|
||||
<div className="app-statusbar border-dark-700/40 bg-dark-800/95 flex h-7 select-none items-center px-3 text-[11px] text-content-primary">
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePanel}
|
||||
className={`-ml-1 flex shrink-0 cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-md px-1.5 py-0.5 transition-colors
|
||||
hover:bg-dark-700/70 group
|
||||
${statusCfg.colorClass} ${statusCfg.animateClass}`}
|
||||
className={`hover:bg-dark-700/70 group -ml-1 flex shrink-0 cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-md px-1.5 py-0.5 transition-colors ${statusCfg.colorClass} ${statusCfg.animateClass}`}
|
||||
title={t('common:connectionPanel.title')}
|
||||
>
|
||||
{statusCfg.icon && <span className="transition-colors duration-300">{statusCfg.icon}</span>}
|
||||
<span className="font-medium transition-colors duration-300">{statusLabel}</span>
|
||||
{hostLabel && (
|
||||
<>
|
||||
<span className="text-txt-faint mx-0.5">·</span>
|
||||
<span className="text-content-secondary max-w-[280px] truncate font-mono" title={hostLabel}>
|
||||
<span className="mx-0.5 text-txt-faint">·</span>
|
||||
<span className="max-w-[280px] truncate font-mono text-content-secondary" title={hostLabel}>
|
||||
{hostLabel}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{totalConnections > 0 && (
|
||||
<span className="bg-accent-blue/20 ml-1 rounded-full px-1.5 py-px text-[10px] font-medium tabular-nums text-accent-blue">
|
||||
<span className="ml-1 rounded-full bg-accent-blue/20 px-1.5 py-px text-[10px] font-medium tabular-nums text-accent-blue">
|
||||
{totalConnections}
|
||||
</span>
|
||||
)}
|
||||
<ChevronUp className="text-txt-faint h-3 w-3 transition-transform duration-200 group-hover:rotate-180" />
|
||||
<ChevronUp className="h-3 w-3 text-txt-faint transition-transform duration-200 group-hover:rotate-180" />
|
||||
</button>
|
||||
|
||||
{metricsEnabled && (
|
||||
@@ -1014,12 +945,11 @@ export default function StatusBar() {
|
||||
}
|
||||
aria-label={t('common:statusBar.forwardOpenIdle', { port: dynamicListenPort })}
|
||||
aria-pressed={socksRunning}
|
||||
className={`mr-2 flex shrink-0 items-center gap-1 whitespace-nowrap rounded-md px-1.5 py-0.5 transition-colors disabled:opacity-50
|
||||
${
|
||||
socksRunning
|
||||
? 'bg-accent-blue/20 text-accent-blue hover:bg-accent-blue/30'
|
||||
: 'text-txt-muted hover:bg-dark-700/70 hover:text-content-secondary'
|
||||
}`}
|
||||
className={`mr-2 flex shrink-0 items-center gap-1 whitespace-nowrap rounded-md px-1.5 py-0.5 transition-colors disabled:opacity-50 ${
|
||||
socksRunning
|
||||
? 'bg-accent-blue/20 text-accent-blue hover:bg-accent-blue/30'
|
||||
: 'hover:bg-dark-700/70 text-txt-muted hover:text-content-secondary'
|
||||
}`}
|
||||
>
|
||||
{forwardSaving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Globe className="h-3.5 w-3.5" />}
|
||||
<span className="tabular-nums">
|
||||
@@ -1028,7 +958,7 @@ export default function StatusBar() {
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="text-txt-faint flex items-center gap-1 tabular-nums">
|
||||
<div className="flex items-center gap-1 tabular-nums text-txt-faint">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
</div>
|
||||
@@ -1046,18 +976,9 @@ export default function StatusBar() {
|
||||
setForwardDraft({
|
||||
type: next.type || 'local',
|
||||
name: next.name || '',
|
||||
localPort:
|
||||
next.type === 'dynamic'
|
||||
? resolveDynamicPort(next.localPort)
|
||||
: next.localPort || 0,
|
||||
localBindHost:
|
||||
next.type === 'local'
|
||||
? resolveLocalBindHost(next.localBindHost)
|
||||
: '',
|
||||
remoteBindHost:
|
||||
next.type === 'remote'
|
||||
? resolveRemoteBindHost(next.remoteBindHost)
|
||||
: '',
|
||||
localPort: next.type === 'dynamic' ? resolveDynamicPort(next.localPort) : next.localPort || 0,
|
||||
localBindHost: next.type === 'local' ? resolveLocalBindHost(next.localBindHost) : '',
|
||||
remoteBindHost: next.type === 'remote' ? resolveRemoteBindHost(next.remoteBindHost) : '',
|
||||
remoteHost: next.remoteHost || '',
|
||||
remotePort: next.remotePort || 0,
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '../../utils/disposeTab'
|
||||
import { TabItem } from './tabbar/TabItem'
|
||||
import { TabContextMenu } from './tabbar/TabContextMenu'
|
||||
import { requestTabReconnect, tabSupportsReconnect } from '../../utils/tabReconnect'
|
||||
|
||||
interface TabBarProps {
|
||||
onOpenLauncher?: () => void
|
||||
@@ -109,9 +110,9 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
|
||||
const currentTab = menuState.tabId ? tabs.find((t) => t.id === menuState.tabId) : null
|
||||
|
||||
return (
|
||||
<div data-tauri-drag-region className="bg-dark-800/90 border-dark-700/40 flex min-w-0 items-stretch border-b">
|
||||
<div className="bg-dark-800/90 border-dark-700/40 flex min-w-0 items-stretch border-b">
|
||||
{/* Tab List — 可滚动;+ 固定在外侧,避免标签多时被挤出视口 */}
|
||||
<div className="titlebar-no-drag flex min-w-0 flex-1 items-stretch">
|
||||
<div className="app-no-drag flex min-w-0 flex-1 items-stretch">
|
||||
<div
|
||||
className="flex min-w-0 flex-1 items-stretch overflow-x-auto"
|
||||
role="tablist"
|
||||
@@ -209,6 +210,15 @@ function TabBar({ onOpenLauncher }: TabBarProps) {
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onReconnect={
|
||||
tabSupportsReconnect(currentTab)
|
||||
? () => {
|
||||
if (!menuState.tabId) return
|
||||
activateTab(menuState.tabId)
|
||||
requestTabReconnect(menuState.tabId)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import ErrorBoundary from '../common/ErrorBoundary'
|
||||
import { useTabStore } from '../../stores/tabStore'
|
||||
import WelcomeView from '../sessions/WelcomeView'
|
||||
import TerminalWorkspace from '../terminal/TerminalWorkspace'
|
||||
@@ -39,7 +40,7 @@ function TabContent({
|
||||
const tabs = useTabStore((state) => state.tabs)
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<div className="relative h-full min-h-0 w-full overflow-hidden">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId
|
||||
|
||||
@@ -119,7 +120,9 @@ function TabContent({
|
||||
isActive ? 'visible z-10 opacity-100' : 'pointer-events-none invisible z-0 opacity-0'
|
||||
}`}
|
||||
>
|
||||
{content}
|
||||
<ErrorBoundary compact resetKey={tab.id}>
|
||||
{content}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -76,7 +76,7 @@ function quietIfNoOp(
|
||||
return { group, index: insertAt }
|
||||
}
|
||||
|
||||
export function resolveSessionDropTarget(
|
||||
function resolveSessionDropTarget(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
dragId: string,
|
||||
@@ -296,7 +296,7 @@ export function SessionTree({
|
||||
return (
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
className="flex-1 overflow-y-auto"
|
||||
className="session-tree flex-1 overflow-y-auto"
|
||||
onContextMenu={(e) => {
|
||||
if ((e.target as HTMLElement).closest('[data-session-row],[data-group-row]')) return
|
||||
onBlankContextMenu?.(e)
|
||||
@@ -320,7 +320,7 @@ export function SessionTree({
|
||||
e.stopPropagation()
|
||||
onGroupContextMenu?.(e, group)
|
||||
}}
|
||||
className={`hover:bg-dark-700/50 flex w-full items-center gap-2 px-4 py-2 text-sm text-txt-muted transition-colors ${
|
||||
className={`hover:bg-dark-700/50 flex w-full cursor-default items-center gap-2 px-4 py-2 text-sm text-txt-muted transition-colors ${
|
||||
groupDropActive ? 'bg-accent-blue/10 text-accent-blue' : ''
|
||||
}`}
|
||||
>
|
||||
@@ -391,7 +391,7 @@ export function SessionTree({
|
||||
? t('tree.selectSession')
|
||||
: `${session.username}@${session.host}:${session.port || 22}`
|
||||
}
|
||||
className={`group relative flex touch-none select-none items-center gap-1.5 px-3 py-1.5 pl-9 pr-2 text-sm transition-colors duration-150 ${dragEnabled ? 'cursor-grab' : 'cursor-pointer'} ${dragId === session.id ? 'opacity-40' : ''} ${
|
||||
className={`group relative flex cursor-default touch-none select-none items-center gap-1.5 px-3 py-1.5 pl-9 pr-2 text-sm transition-colors duration-150 ${dragId === session.id ? 'opacity-40' : ''} ${
|
||||
selected
|
||||
? 'border-l-2 border-accent-blue bg-accent-blue/10'
|
||||
: 'hover:bg-dark-700/40 border-l-2 border-transparent'
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
* TabContextMenu - 标签右键菜单
|
||||
*/
|
||||
|
||||
import { useRef, useEffect } from 'react'
|
||||
import { useRef, useEffect, useLayoutEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface MenuItem {
|
||||
label: string
|
||||
action: () => void
|
||||
danger?: boolean
|
||||
separator?: boolean
|
||||
}
|
||||
|
||||
interface TabContextMenuProps {
|
||||
@@ -23,6 +23,7 @@ interface TabContextMenuProps {
|
||||
onCloseAll: () => void
|
||||
onClone?: () => void
|
||||
onOpenSftp?: () => void
|
||||
onReconnect?: () => void
|
||||
}
|
||||
|
||||
export function TabContextMenu({
|
||||
@@ -36,6 +37,7 @@ export function TabContextMenu({
|
||||
onCloseAll,
|
||||
onClone,
|
||||
onOpenSftp,
|
||||
onReconnect,
|
||||
}: TabContextMenuProps) {
|
||||
const { t } = useTranslation('common')
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
@@ -60,6 +62,24 @@ export function TabContextMenu({
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = menuRef.current
|
||||
if (!el || !isOpen) return
|
||||
const margin = 8
|
||||
const width = el.offsetWidth
|
||||
const height = el.offsetHeight
|
||||
let left = position.x
|
||||
let top = position.y
|
||||
if (left + width + margin > window.innerWidth) {
|
||||
left = Math.max(margin, window.innerWidth - width - margin)
|
||||
}
|
||||
if (top + height + margin > window.innerHeight) {
|
||||
top = Math.max(margin, window.innerHeight - height - margin)
|
||||
}
|
||||
el.style.left = `${left}px`
|
||||
el.style.top = `${top}px`
|
||||
}, [isOpen, position.x, position.y])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const items: MenuItem[] = [
|
||||
@@ -70,6 +90,9 @@ export function TabContextMenu({
|
||||
{ label: t('tab.menu.closeAll'), action: onCloseAll, danger: true },
|
||||
]
|
||||
|
||||
if (onReconnect) {
|
||||
items.unshift({ label: t('tab.menu.reconnect'), action: onReconnect })
|
||||
}
|
||||
if (onClone) {
|
||||
items.unshift({ label: t('tab.menu.clone'), action: onClone })
|
||||
}
|
||||
@@ -77,11 +100,11 @@ export function TabContextMenu({
|
||||
items.unshift({ label: t('tab.menu.openSftp'), action: onOpenSftp })
|
||||
}
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{ left: position.x, top: position.y }}
|
||||
className="fixed z-50 bg-dark-800 border border-dark-700 rounded-lg shadow-xl py-1 min-w-[160px]"
|
||||
className="fixed z-[200] w-max min-w-[8.5rem] rounded-md border border-dark-700 bg-dark-800 py-0.5 shadow-xl"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
@@ -92,17 +115,14 @@ export function TabContextMenu({
|
||||
item.action()
|
||||
onClose()
|
||||
}}
|
||||
className={`
|
||||
w-full px-4 py-2 text-left text-sm transition-colors
|
||||
${item.danger
|
||||
? 'text-red-400 hover:bg-red-500/20'
|
||||
: 'text-content-secondary hover:bg-dark-700'
|
||||
}
|
||||
`}
|
||||
className={`block w-full whitespace-nowrap px-3 py-1.5 text-left text-[13px] transition-colors ${
|
||||
item.danger ? 'text-red-400 hover:bg-red-500/20' : 'text-content-secondary hover:bg-dark-700'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -4,11 +4,8 @@
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Crown, Sparkles, X } from 'lucide-react'
|
||||
import {
|
||||
proFeatureLabel,
|
||||
useEntitlementStore,
|
||||
type ProFeature,
|
||||
} from '../../stores/entitlementStore'
|
||||
import { proFeatureLabel, useEntitlementStore, type ProFeature } from '../../stores/entitlementStore'
|
||||
import AppOverlay from '../common/AppOverlay'
|
||||
|
||||
export default function UpgradeWall() {
|
||||
const { t } = useTranslation('common')
|
||||
@@ -23,16 +20,20 @@ export default function UpgradeWall() {
|
||||
const comingSoon = wall.comingSoon
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[120] flex items-center justify-center bg-black/65 p-4 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="upgrade-wall-title"
|
||||
onClick={closeWall}
|
||||
<AppOverlay
|
||||
open
|
||||
className="z-[120] flex items-center justify-center bg-black/65 p-4 backdrop-blur-sm"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) closeWall()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-md overflow-hidden rounded-2xl border border-dark-600/80 bg-dark-800 shadow-2xl"
|
||||
className="border-dark-600/80 relative w-full max-w-md overflow-hidden rounded-2xl border bg-dark-800 shadow-2xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="upgrade-wall-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -43,7 +44,7 @@ export default function UpgradeWall() {
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div className="border-b border-dark-700/50 bg-gradient-to-br from-accent-blue/15 to-transparent px-6 pb-5 pt-6">
|
||||
<div className="border-dark-700/50 border-b bg-gradient-to-br from-accent-blue/15 to-transparent px-6 pb-5 pt-6">
|
||||
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-xl bg-accent-blue/20 text-accent-blue">
|
||||
<Crown className="h-5 w-5" />
|
||||
</div>
|
||||
@@ -80,6 +81,6 @@ export default function UpgradeWall() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppOverlay>
|
||||
)
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import DesktopResolutionPicker from '../desktop/DesktopResolutionPicker'
|
||||
import { getDisplayResolution } from '../../utils/desktopResolution'
|
||||
import { useToast } from '../../hooks/useToast'
|
||||
import { translateErrorMessage } from '../../i18n/format'
|
||||
import AppOverlay from '../common/AppOverlay'
|
||||
|
||||
const logger = createLogger('SessionDialog')
|
||||
|
||||
@@ -45,6 +46,18 @@ type KeyInputMode = 'file' | 'text'
|
||||
|
||||
const DEFAULT_KEY_PATH = '~/.ssh/id_ed25519'
|
||||
|
||||
function defaultDisplayName(protocol: SessionProtocol, host: string, username: string): string {
|
||||
if (!host) return ''
|
||||
return protocol === 'vnc' ? host : `${username}@${host}`
|
||||
}
|
||||
|
||||
function isGeneratedDisplayName(name: string, host: string, previousHost = ''): boolean {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return true
|
||||
if (trimmed.includes('@')) return true
|
||||
return [host, previousHost].filter(Boolean).some((value) => trimmed === value || trimmed === `vnc:${value}`)
|
||||
}
|
||||
|
||||
function isPemPrivateKey(value: string | undefined | null): boolean {
|
||||
return Boolean(value && value.includes('-----BEGIN'))
|
||||
}
|
||||
@@ -220,10 +233,9 @@ export default function SessionDialog({
|
||||
host: parsed.host,
|
||||
port: targetIncludesPort(trimmed) ? parsed.port : prev.port,
|
||||
username: parsed.username || prev.username,
|
||||
name:
|
||||
prev.name.trim() && !prev.name.includes('@')
|
||||
? prev.name
|
||||
: `${parsed.username || prev.username}@${parsed.host}`,
|
||||
name: isGeneratedDisplayName(prev.name, parsed.host, prev.host)
|
||||
? `${parsed.username || prev.username}@${parsed.host}`
|
||||
: prev.name,
|
||||
}))
|
||||
}
|
||||
return
|
||||
@@ -235,12 +247,13 @@ export default function SessionDialog({
|
||||
...prev,
|
||||
host,
|
||||
port: portMatch ? Number(portMatch[1]) : prev.port,
|
||||
name:
|
||||
prev.name.trim() && !prev.name.includes('@') && !prev.name.startsWith('vnc:')
|
||||
? prev.name
|
||||
: formData.protocol === 'vnc'
|
||||
? `vnc:${host}`
|
||||
: `${prev.username || (formData.protocol === 'rdp' ? 'Administrator' : 'root')}@${host}`,
|
||||
name: isGeneratedDisplayName(prev.name, host, prev.host)
|
||||
? defaultDisplayName(
|
||||
formData.protocol,
|
||||
host,
|
||||
prev.username || (formData.protocol === 'rdp' ? 'Administrator' : 'root')
|
||||
)
|
||||
: prev.name,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -320,7 +333,7 @@ export default function SessionDialog({
|
||||
: undefined
|
||||
|
||||
const sessionData = {
|
||||
name: formData.name.trim() || (formData.protocol === 'vnc' ? `vnc:${host}` : `${username}@${host}`),
|
||||
name: formData.name.trim() || defaultDisplayName(formData.protocol, host, username),
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
@@ -436,8 +449,7 @@ export default function SessionDialog({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[100] flex justify-end">
|
||||
{/* Backdrop */}
|
||||
<AppOverlay open className="z-[100] flex justify-end">
|
||||
<div className="absolute inset-0 animate-fade-in bg-black/45 backdrop-blur-[1px]" onClick={onClose} />
|
||||
|
||||
{/* Drawer */}
|
||||
@@ -450,7 +462,7 @@ export default function SessionDialog({
|
||||
{/* Header */}
|
||||
<div className="border-dark-700/50 flex flex-shrink-0 items-center gap-3 border-b px-4 py-3.5">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl border border-accent-blue/20 bg-accent-blue/15 text-accent-blue">
|
||||
<Server className="w-4.5 h-4.5 h-4 w-4" />
|
||||
<Server className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 id="session-drawer-title" className="text-sm font-semibold text-content-primary">
|
||||
@@ -507,7 +519,11 @@ export default function SessionDialog({
|
||||
autoFocus={!isEditing}
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder={t('dialog.displayNamePlaceholder')}
|
||||
placeholder={
|
||||
formData.protocol === 'vnc'
|
||||
? t('dialog.displayNamePlaceholderVnc')
|
||||
: t('dialog.displayNamePlaceholder')
|
||||
}
|
||||
className="ui-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -576,12 +592,13 @@ export default function SessionDialog({
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
username,
|
||||
name:
|
||||
prev.name.trim() && !prev.name.includes('@')
|
||||
? prev.name
|
||||
: prev.host
|
||||
? `${username || (prev.protocol === 'rdp' ? 'Administrator' : 'root')}@${prev.host}`
|
||||
: prev.name,
|
||||
name: isGeneratedDisplayName(prev.name, prev.host)
|
||||
? defaultDisplayName(
|
||||
prev.protocol,
|
||||
prev.host,
|
||||
username || (prev.protocol === 'rdp' ? 'Administrator' : 'root')
|
||||
)
|
||||
: prev.name,
|
||||
}))
|
||||
}}
|
||||
placeholder={
|
||||
@@ -595,20 +612,34 @@ export default function SessionDialog({
|
||||
</div>
|
||||
)}
|
||||
{formData.protocol === 'rdp' && (
|
||||
<div>
|
||||
<label htmlFor="session-password" className="ui-label">
|
||||
{t('dialog.password')}
|
||||
</label>
|
||||
<input
|
||||
id="session-password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder="••••••••"
|
||||
className="ui-input"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<div>
|
||||
<label htmlFor="session-password" className="ui-label">
|
||||
{t('dialog.password')}
|
||||
</label>
|
||||
<input
|
||||
id="session-password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder="••••••••"
|
||||
className="ui-input"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ui-label" htmlFor="session-domain">
|
||||
{t('dialog.domain')}
|
||||
</label>
|
||||
<input
|
||||
id="session-domain"
|
||||
value={formData.domain}
|
||||
onChange={(e) => setFormData({ ...formData, domain: e.target.value })}
|
||||
className="ui-input font-mono"
|
||||
placeholder={t('dialog.domainPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{formData.protocol !== 'ssh' && (
|
||||
<DesktopResolutionPicker
|
||||
@@ -622,168 +653,155 @@ export default function SessionDialog({
|
||||
{formData.protocol === 'rdp' && groupField}
|
||||
</section>
|
||||
|
||||
{/* 凭据 */}
|
||||
<section className="border-dark-700/50 bg-dark-900/30 space-y-3 rounded-xl border p-3.5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
|
||||
{t('dialog.login')}
|
||||
</div>
|
||||
{isSsh && (
|
||||
<div className="bg-dark-900/60 border-dark-700/40 grid grid-cols-2 gap-1.5 rounded-xl border p-1">
|
||||
{authTypes.map((type) => {
|
||||
const Icon = type.icon
|
||||
const active = formData.authType === type.value
|
||||
return (
|
||||
<button
|
||||
key={type.value}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, authType: type.value })}
|
||||
className={`flex items-center justify-center gap-1 rounded-lg py-2 text-xs font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-dark-700 text-content-primary shadow-sm'
|
||||
: 'text-txt-muted hover:text-content-secondary'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{type.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{/* 凭据:SSH / VNC。RDP 的密码和域已在基本区,避免重复。 */}
|
||||
{(isSsh || formData.protocol === 'vnc') && (
|
||||
<section className="border-dark-700/50 bg-dark-900/30 space-y-3 rounded-xl border p-3.5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
|
||||
{t('dialog.login')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(formData.authType === 'password' || formData.protocol === 'vnc') && (
|
||||
<div>
|
||||
<label htmlFor="session-password" className="ui-label">
|
||||
{t('dialog.password')}
|
||||
</label>
|
||||
<input
|
||||
id="session-password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder="••••••••"
|
||||
className="ui-input"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.protocol === 'rdp' && (
|
||||
<div>
|
||||
<label className="ui-label" htmlFor="session-domain">
|
||||
{t('dialog.domain')}
|
||||
</label>
|
||||
<input
|
||||
id="session-domain"
|
||||
value={formData.domain}
|
||||
onChange={(e) => setFormData({ ...formData, domain: e.target.value })}
|
||||
className="ui-input font-mono"
|
||||
placeholder={t('dialog.domainPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSsh && formData.authType === 'key' && (
|
||||
<div className="space-y-2.5">
|
||||
<div>
|
||||
<div className="ui-label">{t('dialog.keySource')}</div>
|
||||
<div className="bg-dark-900/60 border-dark-700/40 grid grid-cols-2 gap-1.5 rounded-xl border p-1">
|
||||
{(
|
||||
[
|
||||
['file', t('dialog.keyFile')],
|
||||
['text', t('dialog.keyText')],
|
||||
] as const
|
||||
).map(([value, label]) => {
|
||||
const active = formData.keyInputMode === value
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, keyInputMode: value })}
|
||||
className={`flex items-center justify-center rounded-lg py-2 text-xs font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-dark-700 text-content-primary shadow-sm'
|
||||
: 'text-txt-muted hover:text-content-secondary'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.keyInputMode === 'file' ? (
|
||||
<div>
|
||||
<label htmlFor="session-private-key" className="ui-label">
|
||||
{t('dialog.privateKeyPath')}
|
||||
</label>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
id="session-private-key"
|
||||
type="text"
|
||||
value={formData.privateKeyPath}
|
||||
onChange={(e) => setFormData({ ...formData, privateKeyPath: e.target.value })}
|
||||
placeholder={DEFAULT_KEY_PATH}
|
||||
className="ui-input flex-1"
|
||||
/>
|
||||
{isSsh && (
|
||||
<div className="bg-dark-900/60 border-dark-700/40 grid grid-cols-2 gap-1.5 rounded-xl border p-1">
|
||||
{authTypes.map((type) => {
|
||||
const Icon = type.icon
|
||||
const active = formData.authType === type.value
|
||||
return (
|
||||
<button
|
||||
key={type.value}
|
||||
type="button"
|
||||
className="rounded-lg border border-dark-600 bg-dark-700 px-2.5 text-xs text-content-secondary hover:bg-dark-600"
|
||||
onClick={async () => {
|
||||
const result = await window.opsxApi?.showOpenDialog({
|
||||
title: t('dialog.pickKeyDialogTitle'),
|
||||
filters: [{ name: t('dialog.allFilesFilter'), extensions: ['*'] }],
|
||||
})
|
||||
if (!result?.canceled && result?.filePaths?.[0]) {
|
||||
setFormData({
|
||||
...formData,
|
||||
privateKeyPath: result.filePaths[0],
|
||||
keyInputMode: 'file',
|
||||
})
|
||||
}
|
||||
}}
|
||||
onClick={() => setFormData({ ...formData, authType: type.value })}
|
||||
className={`flex items-center justify-center gap-1 rounded-lg py-2 text-xs font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-dark-700 text-content-primary shadow-sm'
|
||||
: 'text-txt-muted hover:text-content-secondary'
|
||||
}`}
|
||||
>
|
||||
{t('dialog.pickKeyFile')}
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{type.label}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label htmlFor="session-private-key-text" className="ui-label">
|
||||
{t('dialog.privateKeyText')}
|
||||
</label>
|
||||
<textarea
|
||||
id="session-private-key-text"
|
||||
value={formData.privateKeyText}
|
||||
onChange={(e) => setFormData({ ...formData, privateKeyText: e.target.value })}
|
||||
placeholder={'-----BEGIN OPENSSH PRIVATE KEY-----\n...\n-----END OPENSSH PRIVATE KEY-----'}
|
||||
rows={6}
|
||||
spellCheck={false}
|
||||
className="ui-input min-h-[7.5rem] resize-y font-mono text-[11px] leading-relaxed"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-txt-faint">{t('dialog.privateKeyTextHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{((isSsh && formData.authType === 'password') || formData.protocol === 'vnc') && (
|
||||
<div>
|
||||
<label htmlFor="session-passphrase" className="ui-label">
|
||||
{t('dialog.passphrase')}
|
||||
<label htmlFor="session-password" className="ui-label">
|
||||
{t('dialog.password')}
|
||||
</label>
|
||||
<input
|
||||
id="session-passphrase"
|
||||
id="session-password"
|
||||
type="password"
|
||||
value={formData.passphrase}
|
||||
onChange={(e) => setFormData({ ...formData, passphrase: e.target.value })}
|
||||
placeholder={t('dialog.passphrasePlaceholder')}
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder="••••••••"
|
||||
className="ui-input"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{(isSsh || formData.protocol === 'vnc') && groupField}
|
||||
</section>
|
||||
{isSsh && formData.authType === 'key' && (
|
||||
<div className="space-y-2.5">
|
||||
<div>
|
||||
<div className="ui-label">{t('dialog.keySource')}</div>
|
||||
<div className="bg-dark-900/60 border-dark-700/40 grid grid-cols-2 gap-1.5 rounded-xl border p-1">
|
||||
{(
|
||||
[
|
||||
['file', t('dialog.keyFile')],
|
||||
['text', t('dialog.keyText')],
|
||||
] as const
|
||||
).map(([value, label]) => {
|
||||
const active = formData.keyInputMode === value
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, keyInputMode: value })}
|
||||
className={`flex items-center justify-center rounded-lg py-2 text-xs font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-dark-700 text-content-primary shadow-sm'
|
||||
: 'text-txt-muted hover:text-content-secondary'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.keyInputMode === 'file' ? (
|
||||
<div>
|
||||
<label htmlFor="session-private-key" className="ui-label">
|
||||
{t('dialog.privateKeyPath')}
|
||||
</label>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
id="session-private-key"
|
||||
type="text"
|
||||
value={formData.privateKeyPath}
|
||||
onChange={(e) => setFormData({ ...formData, privateKeyPath: e.target.value })}
|
||||
placeholder={DEFAULT_KEY_PATH}
|
||||
className="ui-input flex-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg border border-dark-600 bg-dark-700 px-2.5 text-xs text-content-secondary hover:bg-dark-600"
|
||||
onClick={async () => {
|
||||
const result = await window.opsxApi?.showOpenDialog({
|
||||
title: t('dialog.pickKeyDialogTitle'),
|
||||
filters: [{ name: t('dialog.allFilesFilter'), extensions: ['*'] }],
|
||||
})
|
||||
if (!result?.canceled && result?.filePaths?.[0]) {
|
||||
setFormData({
|
||||
...formData,
|
||||
privateKeyPath: result.filePaths[0],
|
||||
keyInputMode: 'file',
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('dialog.pickKeyFile')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label htmlFor="session-private-key-text" className="ui-label">
|
||||
{t('dialog.privateKeyText')}
|
||||
</label>
|
||||
<textarea
|
||||
id="session-private-key-text"
|
||||
value={formData.privateKeyText}
|
||||
onChange={(e) => setFormData({ ...formData, privateKeyText: e.target.value })}
|
||||
placeholder={'-----BEGIN OPENSSH PRIVATE KEY-----\n...\n-----END OPENSSH PRIVATE KEY-----'}
|
||||
rows={6}
|
||||
spellCheck={false}
|
||||
className="ui-input min-h-[7.5rem] resize-y font-mono text-[11px] leading-relaxed"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-txt-faint">{t('dialog.privateKeyTextHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="session-passphrase" className="ui-label">
|
||||
{t('dialog.passphrase')}
|
||||
</label>
|
||||
<input
|
||||
id="session-passphrase"
|
||||
type="password"
|
||||
value={formData.passphrase}
|
||||
onChange={(e) => setFormData({ ...formData, passphrase: e.target.value })}
|
||||
placeholder={t('dialog.passphrasePlaceholder')}
|
||||
className="ui-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupField}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 高级 */}
|
||||
<div className="border-dark-700/50 overflow-hidden rounded-xl border">
|
||||
@@ -794,7 +812,7 @@ export default function SessionDialog({
|
||||
>
|
||||
<span className="text-xs font-semibold text-content-secondary">{t('dialog.advanced')}</span>
|
||||
<span className="flex items-center gap-1.5 text-[11px] text-txt-faint">
|
||||
{showAdvanced ? t('dialog.collapse') : t('dialog.advancedCollapsed')}
|
||||
{showAdvanced ? t('dialog.collapse') : isSsh ? t('dialog.advancedCollapsed') : t('dialog.tags')}
|
||||
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${showAdvanced ? 'rotate-180' : ''}`} />
|
||||
</span>
|
||||
</button>
|
||||
@@ -817,146 +835,150 @@ export default function SessionDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="ui-label">{t('dialog.startupCommand')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.startupCommand}
|
||||
onChange={(e) => setFormData({ ...formData, startupCommand: e.target.value })}
|
||||
placeholder={t('dialog.startupCommandPlaceholder')}
|
||||
className="ui-input-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
{isSsh && (
|
||||
<>
|
||||
<div>
|
||||
<label className="ui-label">{t('dialog.startupCommand')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.startupCommand}
|
||||
onChange={(e) => setFormData({ ...formData, startupCommand: e.target.value })}
|
||||
placeholder={t('dialog.startupCommandPlaceholder')}
|
||||
className="ui-input-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="ui-label">{t('dialog.proxy')}</label>
|
||||
<div className="mb-2 flex gap-1">
|
||||
{(
|
||||
[
|
||||
{ value: 'none', label: t('dialog.proxyNone') },
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
{ value: 'socks5', label: 'SOCKS5' },
|
||||
] as const
|
||||
).map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (t.value !== 'none' && !useEntitlementStore.getState().requirePro('proxy')) {
|
||||
return
|
||||
}
|
||||
<div>
|
||||
<label className="ui-label">{t('dialog.proxy')}</label>
|
||||
<div className="mb-2 flex gap-1">
|
||||
{(
|
||||
[
|
||||
{ value: 'none', label: t('dialog.proxyNone') },
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
{ value: 'socks5', label: 'SOCKS5' },
|
||||
] as const
|
||||
).map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (option.value !== 'none' && !useEntitlementStore.getState().requirePro('proxy')) {
|
||||
return
|
||||
}
|
||||
setFormData({
|
||||
...formData,
|
||||
proxy: {
|
||||
...formData.proxy,
|
||||
type: option.value,
|
||||
port:
|
||||
formData.proxy.port ||
|
||||
(option.value === 'http' ? 8080 : option.value === 'socks5' ? 1080 : 0),
|
||||
},
|
||||
jumpSessionIds: option.value === 'none' ? formData.jumpSessionIds : [],
|
||||
})
|
||||
}}
|
||||
className={`flex-1 rounded-lg border py-1.5 text-[11px] transition-colors ${
|
||||
formData.proxy.type === option.value
|
||||
? 'border-accent-blue bg-accent-blue/15 text-accent-blue'
|
||||
: 'border-dark-600 text-txt-muted hover:border-dark-500'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{formData.proxy.type !== 'none' && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.proxy.host}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
proxy: { ...formData.proxy, host: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder={t('dialog.proxyHostPlaceholder')}
|
||||
className="ui-input-sm col-span-2"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.proxy.port || ''}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
proxy: {
|
||||
...formData.proxy,
|
||||
port: parseInt(e.target.value) || 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder={t('dialog.proxyPortPlaceholder')}
|
||||
className="ui-input-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.proxy.username}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
proxy: { ...formData.proxy, username: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder={t('dialog.proxyUserPlaceholder')}
|
||||
autoComplete="off"
|
||||
className="ui-input-sm"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.proxy.password}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
proxy: { ...formData.proxy, password: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder={t('dialog.proxyPasswordPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
className="ui-input-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="session-jump-host" className="ui-label">
|
||||
{t('dialog.jumpHost')}
|
||||
</label>
|
||||
<select
|
||||
id="session-jump-host"
|
||||
value={formData.jumpSessionIds[0] || ''}
|
||||
onChange={(event) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
proxy: {
|
||||
...formData.proxy,
|
||||
type: t.value,
|
||||
port:
|
||||
formData.proxy.port ||
|
||||
(t.value === 'http' ? 8080 : t.value === 'socks5' ? 1080 : 0),
|
||||
},
|
||||
jumpSessionIds: t.value === 'none' ? formData.jumpSessionIds : [],
|
||||
jumpSessionIds: event.target.value ? [event.target.value] : [],
|
||||
proxy: event.target.value
|
||||
? { type: 'none', host: '', port: 0, username: '', password: '' }
|
||||
: formData.proxy,
|
||||
})
|
||||
}}
|
||||
className={`flex-1 rounded-lg border py-1.5 text-[11px] transition-colors ${
|
||||
formData.proxy.type === t.value
|
||||
? 'border-accent-blue bg-accent-blue/15 text-accent-blue'
|
||||
: 'border-dark-600 text-txt-muted hover:border-dark-500'
|
||||
}`}
|
||||
}
|
||||
className="ui-input-sm"
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{formData.proxy.type !== 'none' && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.proxy.host}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
proxy: { ...formData.proxy, host: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder={t('dialog.proxyHostPlaceholder')}
|
||||
className="ui-input-sm col-span-2"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.proxy.port || ''}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
proxy: {
|
||||
...formData.proxy,
|
||||
port: parseInt(e.target.value) || 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder={t('dialog.proxyPortPlaceholder')}
|
||||
className="ui-input-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.proxy.username}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
proxy: { ...formData.proxy, username: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder={t('dialog.proxyUserPlaceholder')}
|
||||
autoComplete="off"
|
||||
className="ui-input-sm"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.proxy.password}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
proxy: { ...formData.proxy, password: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder={t('dialog.proxyPasswordPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
className="ui-input-sm"
|
||||
/>
|
||||
</div>
|
||||
<option value="">{t('dialog.jumpNoneAlt')}</option>
|
||||
{jumpCandidates.map((session) => (
|
||||
<option key={session.id} value={session.id}>
|
||||
{session.name} ({session.username}@{session.host})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-[10px] text-txt-faint">{t('dialog.jumpHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="session-jump-host" className="ui-label">
|
||||
{t('dialog.jumpHost')}
|
||||
</label>
|
||||
<select
|
||||
id="session-jump-host"
|
||||
value={formData.jumpSessionIds[0] || ''}
|
||||
onChange={(event) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
jumpSessionIds: event.target.value ? [event.target.value] : [],
|
||||
proxy: event.target.value
|
||||
? { type: 'none', host: '', port: 0, username: '', password: '' }
|
||||
: formData.proxy,
|
||||
})
|
||||
}
|
||||
className="ui-input-sm"
|
||||
>
|
||||
<option value="">{t('dialog.jumpNoneAlt')}</option>
|
||||
{jumpCandidates.map((session) => (
|
||||
<option key={session.id} value={session.id}>
|
||||
{session.name} ({session.username}@{session.host})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-[10px] text-txt-faint">{t('dialog.jumpHint')}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -985,7 +1007,7 @@ export default function SessionDialog({
|
||||
</div>
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
</AppOverlay>
|
||||
{PromptDialogComponent}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -200,12 +200,12 @@ export default function AiProviderSettings() {
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
aria-label={t('ai.presets')}
|
||||
value={presetId}
|
||||
onChange={(event) => setPresetId(event.target.value)}
|
||||
className="ui-input-sm min-w-52 flex-1"
|
||||
className="ui-select-pill"
|
||||
>
|
||||
{AI_PROVIDER_FAMILY_ORDER.map((familyId) => (
|
||||
<optgroup key={familyId} label={t(`ai.family.${familyId}`)}>
|
||||
@@ -228,11 +228,11 @@ export default function AiProviderSettings() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-[400px] grid-cols-[168px_minmax(0,1fr)] gap-6">
|
||||
<aside className="space-y-0.5">
|
||||
<div className="px-2 pb-2 text-[11px] font-medium text-txt-faint">{t('ai.configured')}</div>
|
||||
<div className="grid min-h-[22rem] grid-cols-[12rem_minmax(0,1fr)] gap-5">
|
||||
<aside className="border-dark-700/45 bg-dark-800/50 h-fit overflow-hidden rounded-2xl border py-2">
|
||||
<div className="px-3 pb-1.5 text-[11px] font-medium text-txt-faint">{t('ai.configured')}</div>
|
||||
{store.providers.length === 0 ? (
|
||||
<p className="px-2 py-6 text-xs leading-relaxed text-txt-faint">{t('ai.configuredHint')}</p>
|
||||
<p className="px-3 py-5 text-xs leading-relaxed text-txt-faint">{t('ai.configuredHint')}</p>
|
||||
) : (
|
||||
store.providers.map((provider) => {
|
||||
const model = provider.models.find((item) => item.id === provider.defaultModelId)
|
||||
@@ -241,7 +241,7 @@ export default function AiProviderSettings() {
|
||||
key={provider.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(provider.id)}
|
||||
className={`w-full rounded-lg px-2.5 py-2 text-left transition-colors ${
|
||||
className={`w-full px-3 py-2 text-left transition-colors ${
|
||||
selectedId === provider.id
|
||||
? 'bg-accent-blue/12 text-accent-blue'
|
||||
: 'hover:bg-dark-700/50 text-content-secondary'
|
||||
@@ -260,34 +260,40 @@ export default function AiProviderSettings() {
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<main className="min-w-0">
|
||||
<main className="min-w-0 max-w-lg">
|
||||
{!draft ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-txt-faint">{t('ai.emptyHint')}</div>
|
||||
<div className="border-dark-600/80 flex h-40 items-center justify-center rounded-2xl border border-dashed text-sm text-txt-faint">
|
||||
{t('ai.emptyHint')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-xl space-y-3.5">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label={t('ai.name')}>
|
||||
<input
|
||||
value={draft.name}
|
||||
onChange={(event) => updateDraft({ name: event.target.value })}
|
||||
className="ui-input-sm"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('ai.model')}>
|
||||
{manualModel || draft.models.length === 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="border-dark-700/45 bg-dark-800/50 divide-dark-700/35 divide-y overflow-hidden rounded-2xl border">
|
||||
<FormRow label={t('ai.name')}>
|
||||
<CompactField>
|
||||
<input
|
||||
aria-label={t('ai.model')}
|
||||
value={currentModel?.model || ''}
|
||||
onChange={(event) => updateManualModel(event.target.value)}
|
||||
placeholder="deepseek-v4-pro"
|
||||
className="ui-input-sm font-mono"
|
||||
value={draft.name}
|
||||
onChange={(event) => updateDraft({ name: event.target.value })}
|
||||
className="ui-input-sm"
|
||||
/>
|
||||
</CompactField>
|
||||
</FormRow>
|
||||
<FormRow label={t('ai.model')}>
|
||||
{manualModel || draft.models.length === 0 ? (
|
||||
<CompactField>
|
||||
<input
|
||||
aria-label={t('ai.model')}
|
||||
value={currentModel?.model || ''}
|
||||
onChange={(event) => updateManualModel(event.target.value)}
|
||||
placeholder="deepseek-v4-pro"
|
||||
className="ui-input-sm font-mono"
|
||||
/>
|
||||
</CompactField>
|
||||
) : (
|
||||
<select
|
||||
aria-label={t('ai.model')}
|
||||
value={draft.defaultModelId}
|
||||
onChange={(event) => chooseModel(event.target.value)}
|
||||
className="ui-input-sm"
|
||||
className="ui-select-pill"
|
||||
>
|
||||
{draft.models.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
@@ -297,100 +303,103 @@ export default function AiProviderSettings() {
|
||||
<option value="__manual">{t('ai.otherModels')}</option>
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
</FormRow>
|
||||
{manualModel && draft.models.length > 0 && (
|
||||
<div className="flex justify-end px-4 py-2">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-accent-blue hover:underline"
|
||||
onClick={() => setManualModel(false)}
|
||||
>
|
||||
{t('ai.backToList')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<FormRow label={draft.authStyle === 'none' ? t('ai.apiKeyOptional') : t('ai.apiKey')}>
|
||||
<CompactField wide>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={apiKey}
|
||||
disabled={draft.authStyle === 'none'}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
placeholder={
|
||||
draft.hasApiKey
|
||||
? t('ai.savedKeepBlank')
|
||||
: draft.authStyle === 'none'
|
||||
? t('ai.notRequired')
|
||||
: 'sk-...'
|
||||
}
|
||||
className="ui-input-sm"
|
||||
/>
|
||||
</CompactField>
|
||||
</FormRow>
|
||||
{(needsEndpoint || advancedOpen) && (
|
||||
<FormRow label={t('ai.endpoint')}>
|
||||
<CompactField wide>
|
||||
<input
|
||||
aria-label={t('ai.endpoint')}
|
||||
value={currentModel?.baseUrl || draft.baseUrl}
|
||||
onChange={(event) => updateDraft({ baseUrl: event.target.value })}
|
||||
placeholder="https://api.example.com/v1"
|
||||
className="ui-input-sm font-mono"
|
||||
disabled={!!currentModel?.baseUrl && !needsEndpoint}
|
||||
/>
|
||||
</CompactField>
|
||||
</FormRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{manualModel && draft.models.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-accent-blue hover:underline"
|
||||
onClick={() => setManualModel(false)}
|
||||
>
|
||||
{t('ai.backToList')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Field label={draft.authStyle === 'none' ? t('ai.apiKeyOptional') : 'API Key'}>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={apiKey}
|
||||
disabled={draft.authStyle === 'none'}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
placeholder={
|
||||
draft.hasApiKey
|
||||
? t('ai.savedKeepBlank')
|
||||
: draft.authStyle === 'none'
|
||||
? t('ai.notRequired')
|
||||
: 'sk-...'
|
||||
}
|
||||
className="ui-input-sm"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{(needsEndpoint || advancedOpen) && (
|
||||
<Field label="Endpoint">
|
||||
<input
|
||||
aria-label="Endpoint"
|
||||
value={currentModel?.baseUrl || draft.baseUrl}
|
||||
onChange={(event) => updateDraft({ baseUrl: event.target.value })}
|
||||
placeholder="https://api.example.com/v1"
|
||||
className="ui-input-sm font-mono"
|
||||
disabled={!!currentModel?.baseUrl && !needsEndpoint}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen((open) => !open)}
|
||||
className="flex items-center gap-1 text-xs text-txt-faint hover:text-txt-muted"
|
||||
className="flex items-center gap-1 px-0.5 text-xs text-txt-faint hover:text-txt-muted"
|
||||
>
|
||||
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${advancedOpen ? 'rotate-180' : ''}`} />
|
||||
{t('ai.advanced')}
|
||||
</button>
|
||||
|
||||
{advancedOpen && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label={t('ai.protocol')}>
|
||||
<div className="border-dark-700/45 bg-dark-800/50 divide-dark-700/35 divide-y overflow-hidden rounded-2xl border">
|
||||
<FormRow label={t('ai.protocol')}>
|
||||
<select
|
||||
value={draft.protocol}
|
||||
onChange={(event) =>
|
||||
updateDraft({ protocol: event.target.value as AiProviderConfig['protocol'] })
|
||||
}
|
||||
className="ui-input-sm"
|
||||
className="ui-select-pill"
|
||||
>
|
||||
<option value="openai-chat">OpenAI Chat</option>
|
||||
<option value="openai-responses">OpenAI Responses</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t('ai.auth')}>
|
||||
</FormRow>
|
||||
<FormRow label={t('ai.auth')}>
|
||||
<select
|
||||
value={draft.authStyle}
|
||||
onChange={(event) =>
|
||||
updateDraft({ authStyle: event.target.value as AiProviderConfig['authStyle'] })
|
||||
}
|
||||
className="ui-input-sm"
|
||||
className="ui-select-pill"
|
||||
>
|
||||
<option value="bearer">Bearer</option>
|
||||
<option value="x-api-key">x-api-key</option>
|
||||
<option value="both">Bearer + x-api-key</option>
|
||||
<option value="none">{t('ai.authNone')}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<label className="col-span-2 flex items-center gap-2 text-xs text-txt-muted">
|
||||
</FormRow>
|
||||
<FormRow label={t('ai.allowHttp')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.allowInsecureHttp}
|
||||
onChange={(event) => updateDraft({ allowInsecureHttp: event.target.checked })}
|
||||
className="h-3.5 w-3.5 accent-blue-500"
|
||||
/>
|
||||
{t('ai.allowHttp')}
|
||||
</label>
|
||||
</FormRow>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
<div className="flex flex-wrap items-center gap-2 pt-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@@ -450,11 +459,15 @@ export default function AiProviderSettings() {
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
function FormRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-[11px] text-txt-muted">{label}</span>
|
||||
{children}
|
||||
<label className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5 px-4 py-3">
|
||||
<span className="min-w-[6.5rem] text-sm text-content-primary">{label}</span>
|
||||
<div className="ml-auto flex-shrink-0">{children}</div>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function CompactField({ children, wide = false }: { children: React.ReactNode; wide?: boolean }) {
|
||||
return <div className={wide ? 'w-[17.5rem] max-w-full' : 'w-[12.5rem] max-w-full'}>{children}</div>
|
||||
}
|
||||
@@ -18,12 +18,21 @@ import {
|
||||
Check,
|
||||
Sparkles,
|
||||
UserRound,
|
||||
ExternalLink,
|
||||
} from 'lucide-react'
|
||||
import AiProviderSettings from './AiProviderSettings'
|
||||
import AccountSettings from './AccountSettings'
|
||||
import { themes, type TerminalThemeId } from '../../themes/terminal'
|
||||
import { getKeybindingCategories, getKeybindingsByCategory, type KeybindingActionId } from '../../constants/keybindings'
|
||||
import { filterSettingsTabsByQuery, type SettingsSearchTab } from '../../utils/settingsSearch'
|
||||
import {
|
||||
buildBugReportUrl,
|
||||
formatRuntimeSystem,
|
||||
GITHUB_DISCUSSIONS_URL,
|
||||
GITHUB_LICENSE_URL,
|
||||
GITHUB_RELEASES_URL,
|
||||
WECHAT_ID,
|
||||
} from '../../utils/bugReport'
|
||||
|
||||
// ─── Tab 类型 ───────────────────────────────────────────────
|
||||
type SettingsTab = 'general' | 'account' | 'terminal' | 'ai' | 'shortcuts' | 'ssh' | 'security' | 'about'
|
||||
@@ -148,6 +157,17 @@ export default function SettingsView() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const openBugReport = useCallback(() => {
|
||||
const url = buildBugReportUrl({
|
||||
appVersion: runtimeInfo?.appVersion,
|
||||
os: runtimeInfo?.os,
|
||||
arch: runtimeInfo?.arch,
|
||||
logPath: runtimeInfo?.logPath,
|
||||
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,
|
||||
})
|
||||
openHttpUrl(url)
|
||||
}, [runtimeInfo])
|
||||
|
||||
// 当前 tab 元信息
|
||||
const currentMeta = useMemo(() => TAB_LIST.find((t) => t.id === activeTab)!, [activeTab])
|
||||
|
||||
@@ -171,7 +191,7 @@ export default function SettingsView() {
|
||||
const diagnosticText = runtimeInfo
|
||||
? [
|
||||
`OpsXShell v${runtimeInfo.appVersion}`,
|
||||
`${runtimeInfo.os} ${runtimeInfo.arch}`,
|
||||
formatRuntimeSystem(runtimeInfo.os, runtimeInfo.arch),
|
||||
`Data: ${runtimeInfo.dataPath}`,
|
||||
`Log: ${runtimeInfo.logPath}`,
|
||||
].join('\n')
|
||||
@@ -249,7 +269,7 @@ export default function SettingsView() {
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white transition-transform ${
|
||||
className={`absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${
|
||||
settings.sidebarCollapsed ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
@@ -334,7 +354,7 @@ export default function SettingsView() {
|
||||
onClick={() => setSetting('terminalTheme', key as TerminalThemeId)}
|
||||
className={`rounded-xl border p-2.5 text-left text-xs transition-colors ${
|
||||
settings.terminalTheme === key
|
||||
? 'bg-accent-blue/10 ring-accent-blue/30 border-accent-blue ring-1'
|
||||
? 'border-accent-blue bg-accent-blue/10 ring-1 ring-accent-blue/30'
|
||||
: 'border-dark-600/60 hover:border-dark-500'
|
||||
}`}
|
||||
>
|
||||
@@ -389,7 +409,9 @@ export default function SettingsView() {
|
||||
const defs = getKeybindingsByCategory(category)
|
||||
return (
|
||||
<SettingCard key={category}>
|
||||
<h3 className="mb-3 text-sm font-semibold text-content-secondary">{t(`shortcuts.category.${category}`)}</h3>
|
||||
<h3 className="mb-3 text-sm font-semibold text-content-secondary">
|
||||
{t(`shortcuts.category.${category}`)}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{defs.map((def) => {
|
||||
const currentKeys = allKeys[def.id]
|
||||
@@ -400,13 +422,13 @@ export default function SettingsView() {
|
||||
<div
|
||||
key={def.id}
|
||||
className={`flex items-center justify-between rounded-lg px-3 py-2 transition-colors ${
|
||||
isRecordingThis ? 'bg-accent-blue/10 border-accent-blue/30 border' : 'hover:bg-dark-800/60'
|
||||
isRecordingThis ? 'border border-accent-blue/30 bg-accent-blue/10' : 'hover:bg-dark-800/60'
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm text-content-primary">{t(def.labelKey)}</span>
|
||||
{isCustom && (
|
||||
<span className="bg-accent-blue/10 ml-2 rounded px-1.5 py-0.5 text-[10px] text-accent-blue">
|
||||
<span className="ml-2 rounded bg-accent-blue/10 px-1.5 py-0.5 text-[10px] text-accent-blue">
|
||||
{t('shortcuts.custom')}
|
||||
</span>
|
||||
)}
|
||||
@@ -415,7 +437,7 @@ export default function SettingsView() {
|
||||
<div className="flex items-center gap-2">
|
||||
{isRecordingThis ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<kbd className="bg-accent-blue/20 border-accent-blue/40 inline-flex animate-pulse items-center rounded border px-3 py-1 font-mono text-xs text-accent-blue">
|
||||
<kbd className="inline-flex animate-pulse items-center rounded border border-accent-blue/40 bg-accent-blue/20 px-3 py-1 font-mono text-xs text-accent-blue">
|
||||
{recordedKeys || t('shortcuts.pressKeys')}
|
||||
</kbd>
|
||||
<button
|
||||
@@ -442,7 +464,7 @@ export default function SettingsView() {
|
||||
<kbd
|
||||
className={`inline-flex items-center rounded px-2.5 py-1 font-mono text-xs transition-colors ${
|
||||
isCustom
|
||||
? 'bg-accent-blue/10 border-accent-blue/30 border text-accent-blue'
|
||||
? 'border border-accent-blue/30 bg-accent-blue/10 text-accent-blue'
|
||||
: 'border border-dark-600 bg-dark-700 text-content-secondary'
|
||||
} group-hover:border-accent-blue/50`}
|
||||
>
|
||||
@@ -472,15 +494,13 @@ export default function SettingsView() {
|
||||
{/* 录入确认弹层 */}
|
||||
{recordingId && recordedKeys && (
|
||||
<div className="fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 rounded-xl border border-dark-600 bg-dark-800 px-5 py-3 shadow-2xl">
|
||||
<span className="text-sm text-content-secondary">
|
||||
{t('shortcuts.setTo', { keys: recordedKeys })}
|
||||
</span>
|
||||
<span className="text-sm text-content-secondary">{t('shortcuts.setTo', { keys: recordedKeys })}</span>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await keybindingState.setCustomKeybinding(recordingId, recordedKeys)
|
||||
stopRecording()
|
||||
}}
|
||||
className="hover:bg-accent-blue/80 rounded-lg bg-accent-blue px-3 py-1 text-xs text-content-primary transition-colors"
|
||||
className="rounded-lg bg-accent-blue px-3 py-1 text-xs text-content-primary transition-colors hover:bg-accent-blue/80"
|
||||
>
|
||||
{t('common:action.confirm')}
|
||||
</button>
|
||||
@@ -525,6 +545,7 @@ export default function SettingsView() {
|
||||
checked={settings.autoReconnect !== false}
|
||||
onChange={(v) => setSetting('autoReconnect', v)}
|
||||
/>
|
||||
<p className="px-3 py-2 text-[11px] leading-relaxed text-txt-faint">{t('ssh.sessionPolicyHint')}</p>
|
||||
<SettingRow label={t('ssh.timeout')} description={t('ssh.timeoutDesc')}>
|
||||
<input
|
||||
type="number"
|
||||
@@ -588,12 +609,12 @@ export default function SettingsView() {
|
||||
<div className="space-y-6">
|
||||
<SettingGroup title={t('security.credentials')}>
|
||||
<SettingRow label={t('security.sessionPassword')} description={t('security.sessionPasswordDesc')}>
|
||||
<span className="border-accent-green/25 bg-accent-green/10 rounded-md border px-2 py-1 text-xs font-medium text-accent-green">
|
||||
<span className="rounded-md border border-accent-green/25 bg-accent-green/10 px-2 py-1 text-xs font-medium text-accent-green">
|
||||
{t('security.enabled')}
|
||||
</span>
|
||||
</SettingRow>
|
||||
<SettingRow label={t('security.masterKey')} description={t('security.masterKeyDesc')}>
|
||||
<code className="text-xs text-txt-muted break-all">
|
||||
<code className="break-all text-xs text-txt-muted">
|
||||
{runtimeInfo?.credentialPath ?? '~/.opsxshell/master.key'}
|
||||
</code>
|
||||
</SettingRow>
|
||||
@@ -605,71 +626,101 @@ export default function SettingsView() {
|
||||
// ──────── 关于 ─────────
|
||||
case 'about':
|
||||
return (
|
||||
<div className="max-w-2xl space-y-5">
|
||||
<div className="max-w-xl space-y-6">
|
||||
<SettingCard>
|
||||
<div className="flex items-start gap-4 py-2">
|
||||
<div className="border-dark-600/50 flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-2xl border bg-dark-700">
|
||||
<Terminal className="h-8 w-8 text-content-primary" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4 py-1">
|
||||
<img
|
||||
src="/app-icon.png"
|
||||
alt=""
|
||||
width={64}
|
||||
height={64}
|
||||
className="h-16 w-16 flex-shrink-0 rounded-[18px] shadow-sm"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-lg font-semibold text-content-primary">OpsXShell</h3>
|
||||
<h3 className="text-lg font-semibold tracking-tight text-content-primary">OpsXShell</h3>
|
||||
<p className="mt-0.5 text-sm text-txt-muted">{t('about.tagline')}</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<span className="bg-accent-blue/15 border-accent-blue/20 inline-flex items-center rounded-md border px-2.5 py-1 font-mono text-xs font-medium text-accent-blue">
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-txt-faint">
|
||||
<span className="font-mono text-content-secondary">
|
||||
{t('about.appVersion', { version: appVersion })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText('version', diagnosticText)}
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
|
||||
title={t('about.copyVersion')}
|
||||
>
|
||||
{copiedField === 'version' ? (
|
||||
<Check className="h-3.5 w-3.5 text-accent-green" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{copiedField === 'version' ? t('common:action.copied') : t('common:action.copy')}
|
||||
</button>
|
||||
{runtimeInfo ? (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{formatRuntimeSystem(runtimeInfo.os, runtimeInfo.arch)}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
<SettingCard>
|
||||
<SettingSectionTitle>{t('about.runtime')}</SettingSectionTitle>
|
||||
<div className="divide-dark-700/30 mt-3 divide-y text-sm">
|
||||
<div className="flex items-center justify-between gap-4 py-2">
|
||||
<span className="text-txt-muted">{t('about.system')}</span>
|
||||
<code className="text-right text-xs text-content-secondary">
|
||||
{runtimeInfo ? `${runtimeInfo.os} ${runtimeInfo.arch}` : t('common:status.reading')}
|
||||
</code>
|
||||
<SettingGroup title={t('about.resources')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyText('wechat', WECHAT_ID)}
|
||||
aria-label={t('about.copyWechat')}
|
||||
className="hover:bg-dark-700/35 flex w-full items-center gap-3 px-4 py-3.5 text-left transition-colors"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-content-primary">{t('about.wechat')}</div>
|
||||
<div className="mt-0.5 font-mono text-xs leading-relaxed text-txt-muted">{WECHAT_ID}</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingCard>
|
||||
{copiedField === 'wechat' ? (
|
||||
<Check className="h-3.5 w-3.5 flex-shrink-0 text-accent-green" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5 flex-shrink-0 text-txt-faint" />
|
||||
)}
|
||||
</button>
|
||||
<AboutLink label={t('about.reportBug')} description={t('about.reportBugDesc')} onClick={openBugReport} />
|
||||
<AboutLink
|
||||
label={t('about.github')}
|
||||
description={t('about.githubDesc')}
|
||||
onClick={() => openHttpUrl(GITHUB_DISCUSSIONS_URL)}
|
||||
/>
|
||||
<AboutLink
|
||||
label={t('about.releases')}
|
||||
description={t('about.releasesDesc')}
|
||||
onClick={() => openHttpUrl(GITHUB_RELEASES_URL)}
|
||||
/>
|
||||
<AboutLink
|
||||
label={t('about.licenseLink')}
|
||||
description={t('about.licenseLinkDesc')}
|
||||
onClick={() => openHttpUrl(GITHUB_LICENSE_URL)}
|
||||
/>
|
||||
</SettingGroup>
|
||||
|
||||
<SettingCard>
|
||||
<SettingSectionTitle>{t('about.localData')}</SettingSectionTitle>
|
||||
<div className="mt-3 space-y-3 text-xs">
|
||||
<div>
|
||||
<div className="mb-1 text-txt-muted">{t('about.sessionsSettings')}</div>
|
||||
<code className="block break-all text-content-secondary">{runtimeInfo?.dataPath ?? t('common:status.reading')}</code>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-txt-muted">{t('about.masterKeyPath')}</div>
|
||||
<code className="block break-all text-content-secondary">
|
||||
{runtimeInfo?.credentialPath ?? '~/.opsxshell/master.key'}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-txt-muted">{t('about.logPath')}</div>
|
||||
<code className="block break-all text-content-secondary">
|
||||
{runtimeInfo?.logPath ?? t('common:status.reading')}
|
||||
</code>
|
||||
</div>
|
||||
<p className="leading-relaxed text-txt-faint">{t('about.privacyNote')}</p>
|
||||
</div>
|
||||
</SettingCard>
|
||||
<SettingGroup title={t('about.diagnostics')}>
|
||||
<SettingRow label={t('about.logPath')}>
|
||||
<CopyValueButton
|
||||
copied={copiedField === 'log'}
|
||||
value={runtimeInfo?.logPath}
|
||||
label={t('about.copyPath')}
|
||||
onCopy={() => runtimeInfo?.logPath && void copyText('log', runtimeInfo.logPath)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label={t('about.sessionsSettings')}>
|
||||
<CopyValueButton
|
||||
copied={copiedField === 'data'}
|
||||
value={runtimeInfo?.dataPath}
|
||||
label={t('about.copyPath')}
|
||||
onCopy={() => runtimeInfo?.dataPath && void copyText('data', runtimeInfo.dataPath)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label={t('about.copyDiagnostics')} description={t('about.copyVersion')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText('version', diagnosticText)}
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
|
||||
>
|
||||
{copiedField === 'version' ? (
|
||||
<Check className="h-3.5 w-3.5 text-accent-green" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{copiedField === 'version' ? t('common:action.copied') : t('common:action.copy')}
|
||||
</button>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
|
||||
<p className="px-1 text-[11px] text-txt-faint">{t('about.license')}</p>
|
||||
</div>
|
||||
@@ -833,6 +884,65 @@ function KnownHostsSettingsCard() {
|
||||
)
|
||||
}
|
||||
|
||||
function openHttpUrl(url: string) {
|
||||
const open = window.opsxApi?.openExternal
|
||||
if (open) {
|
||||
void open(url).catch(() => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
})
|
||||
return
|
||||
}
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
function AboutLink({ label, description, onClick }: { label: string; description: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="hover:bg-dark-700/35 flex w-full items-center gap-3 px-4 py-3.5 text-left transition-colors"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-content-primary">{label}</div>
|
||||
<div className="mt-0.5 text-xs leading-relaxed text-txt-muted">{description}</div>
|
||||
</div>
|
||||
<ExternalLink className="h-3.5 w-3.5 flex-shrink-0 text-txt-faint" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CopyValueButton({
|
||||
value,
|
||||
copied,
|
||||
label,
|
||||
onCopy,
|
||||
}: {
|
||||
value?: string
|
||||
copied: boolean
|
||||
label: string
|
||||
onCopy: () => void
|
||||
}) {
|
||||
if (!value) {
|
||||
return <span className="text-xs text-txt-faint">—</span>
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
title={value}
|
||||
aria-label={label}
|
||||
className="inline-flex max-w-[16rem] items-center gap-1.5 rounded-md px-1.5 py-1 text-left text-xs text-content-secondary transition-colors hover:bg-dark-700 hover:text-content-primary"
|
||||
>
|
||||
<code className="truncate">{value}</code>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5 flex-shrink-0 text-accent-green" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** 分组卡片:多行设置 */
|
||||
function SettingGroup({ title, children }: { title?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -850,7 +960,7 @@ function SettingCard({ children, highlight = false }: { children: React.ReactNod
|
||||
return (
|
||||
<div
|
||||
className={`rounded-2xl p-4 transition-colors ${
|
||||
highlight ? 'bg-accent-blue/5 border-accent-blue/25 border' : 'bg-dark-800/50 border-dark-700/45 border'
|
||||
highlight ? 'border border-accent-blue/25 bg-accent-blue/5' : 'bg-dark-800/50 border-dark-700/45 border'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
@@ -858,11 +968,6 @@ function SettingCard({ children, highlight = false }: { children: React.ReactNod
|
||||
)
|
||||
}
|
||||
|
||||
/** 区域小标题 */
|
||||
function SettingSectionTitle({ children, className = '' }: { children: React.ReactNode; className?: string }) {
|
||||
return <div className={`mb-1 text-sm font-semibold text-content-secondary ${className}`}>{children}</div>
|
||||
}
|
||||
|
||||
/** 左文右控行 */
|
||||
function SettingRow({
|
||||
label,
|
||||
|
||||
+468
-402
@@ -1,16 +1,12 @@
|
||||
/**
|
||||
* SFTPView - SFTP 文件传输主组件 (简化版)
|
||||
*
|
||||
*
|
||||
* 使用子组件拆分后的简化版本
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
FolderOpen, Upload, Download, Trash2,
|
||||
Edit3, FileText, ShieldCheck,
|
||||
Unplug, FileCode,
|
||||
} from 'lucide-react'
|
||||
import { FolderOpen, Upload, Download, Trash2, Edit3, FileText, ShieldCheck, Unplug, FileCode } from 'lucide-react'
|
||||
import { useSFTP } from '../../hooks'
|
||||
import { useToast } from '../../hooks/useToast'
|
||||
import { getConnectionStore } from '../../stores/connection'
|
||||
@@ -32,11 +28,8 @@ import type { SftpDragEntry } from './dragDrop'
|
||||
import { dndLog, shortPath } from './dragDrop'
|
||||
import { isEditableRemoteTextFile } from '../../utils/fileLanguage'
|
||||
import { isWithinRemoteTextEditLimit } from '../../utils/remoteTextLimits'
|
||||
import {
|
||||
conflictNamesFromListing,
|
||||
formatOverwriteMessage,
|
||||
transferDestinationPath,
|
||||
} from './transferConflicts'
|
||||
import { subscribeTabReconnect } from '../../utils/tabReconnect'
|
||||
import { conflictNamesFromListing, formatOverwriteMessage, transferDestinationPath } from './transferConflicts'
|
||||
|
||||
interface SFTPViewProps {
|
||||
tabId: string
|
||||
@@ -89,7 +82,7 @@ export default function SFTPView({ tabId, isActive = true, sessionId }: SFTPView
|
||||
connectionId,
|
||||
localLoading,
|
||||
remoteLoading,
|
||||
} = useSFTP({ tabId })
|
||||
} = useSFTP({ tabId, sessionId })
|
||||
|
||||
const { toast } = useToast()
|
||||
const { prompt, PromptDialogComponent } = usePromptDialog()
|
||||
@@ -147,6 +140,17 @@ export default function SFTPView({ tabId, isActive = true, sessionId }: SFTPView
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
subscribeTabReconnect(tabId, () => {
|
||||
if (!sessionId) return
|
||||
clearError()
|
||||
autoConnectAttempted.current = true
|
||||
void connectRef.current(sessionId)
|
||||
}),
|
||||
[tabId, sessionId, clearError]
|
||||
)
|
||||
|
||||
// Keep pool idle-suspend from arming while this SFTP tab is in front.
|
||||
useEffect(() => {
|
||||
if (!isActive || !connectionId || !isConnected) return
|
||||
@@ -154,285 +158,330 @@ export default function SFTPView({ tabId, isActive = true, sessionId }: SFTPView
|
||||
}, [isActive, connectionId, isConnected, tabId])
|
||||
|
||||
// Sort handler
|
||||
const handleSort = useCallback((field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortField(field)
|
||||
setSortOrder('asc')
|
||||
}
|
||||
}, [sortField, sortOrder])
|
||||
const handleSort = useCallback(
|
||||
(field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortField(field)
|
||||
setSortOrder('asc')
|
||||
}
|
||||
},
|
||||
[sortField, sortOrder]
|
||||
)
|
||||
|
||||
// Local selection
|
||||
const toggleLocalSelect = useCallback((name: string) => {
|
||||
setLocalSelected((prev: Set<string>) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(name)) next.delete(name)
|
||||
else next.add(name)
|
||||
return next
|
||||
})
|
||||
}, [setLocalSelected])
|
||||
const toggleLocalSelect = useCallback(
|
||||
(name: string) => {
|
||||
setLocalSelected((prev: Set<string>) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(name)) next.delete(name)
|
||||
else next.add(name)
|
||||
return next
|
||||
})
|
||||
},
|
||||
[setLocalSelected]
|
||||
)
|
||||
|
||||
// Remote selection
|
||||
const toggleRemoteSelect = useCallback((name: string) => {
|
||||
setRemoteSelected((prev: Set<string>) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(name)) next.delete(name)
|
||||
else next.add(name)
|
||||
return next
|
||||
})
|
||||
}, [setRemoteSelected])
|
||||
|
||||
const reportTransfer = useCallback((action: 'upload' | 'download', result: { completed: number; failed: number; errors: string[] }) => {
|
||||
const actionLabel = action === 'upload' ? t('toast.uploadAction') : t('toast.downloadAction')
|
||||
if (result.failed > 0) {
|
||||
const detail = result.errors[0] || actionLabel
|
||||
toast.error(t('toast.uploadPartial', {
|
||||
action: actionLabel,
|
||||
completed: result.completed,
|
||||
failed: result.failed,
|
||||
detail,
|
||||
}))
|
||||
} else if (result.completed > 0) {
|
||||
toast.success(t('toast.uploadSuccess', { action: actionLabel, completed: result.completed }))
|
||||
}
|
||||
}, [t, toast])
|
||||
|
||||
const performUpload = useCallback(async (sources: Array<{ path: string; name?: string; size?: number; isDirectory?: boolean }>, targetPath?: string) => {
|
||||
const dest = targetPath ?? remotePath
|
||||
let pending = sources
|
||||
const names = sources.map((source) => source.name || source.path.split(/[\\/]/).filter(Boolean).pop() || source.path)
|
||||
let conflicts =
|
||||
dest === remotePath
|
||||
? conflictNamesFromListing(names, remoteFiles)
|
||||
: []
|
||||
if (dest !== remotePath && sftpId && window.opsxApi?.sftpStat) {
|
||||
const hits: string[] = []
|
||||
for (const name of names) {
|
||||
const remoteTarget = transferDestinationPath('upload', dest, name)
|
||||
try {
|
||||
const result = await window.opsxApi.sftpStat(sftpId, remoteTarget)
|
||||
if (result?.success && result.stats) hits.push(name)
|
||||
} catch {
|
||||
// treat as missing
|
||||
}
|
||||
}
|
||||
conflicts = hits
|
||||
}
|
||||
if (conflicts.length > 0) {
|
||||
const overwrite = await confirm({
|
||||
title: t('dialog.overwriteTitle'),
|
||||
message: formatOverwriteMessage(conflicts, 'upload', t),
|
||||
confirmText: t('dialog.overwriteConfirm'),
|
||||
cancelText: t('dialog.overwriteSkip'),
|
||||
danger: true,
|
||||
const toggleRemoteSelect = useCallback(
|
||||
(name: string) => {
|
||||
setRemoteSelected((prev: Set<string>) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(name)) next.delete(name)
|
||||
else next.add(name)
|
||||
return next
|
||||
})
|
||||
if (!overwrite) {
|
||||
const skip = new Set(conflicts)
|
||||
pending = sources.filter((source) => {
|
||||
const name = source.name || source.path.split(/[\\/]/).filter(Boolean).pop() || source.path
|
||||
return !skip.has(name)
|
||||
})
|
||||
if (pending.length === 0) {
|
||||
toast.info(t('toast.allSkippedExisting'))
|
||||
return
|
||||
}
|
||||
toast.info(t('toast.skippedExisting', { count: conflicts.length }))
|
||||
}
|
||||
}
|
||||
setIsTransferPanelOpen(true)
|
||||
const result = await uploadFiles(pending, dest)
|
||||
reportTransfer('upload', result)
|
||||
}, [confirm, remoteFiles, remotePath, reportTransfer, sftpId, t, toast, uploadFiles])
|
||||
},
|
||||
[setRemoteSelected]
|
||||
)
|
||||
|
||||
const performDownload = useCallback(async (files?: FileItem[], targetPath?: string) => {
|
||||
const dest = targetPath ?? localPath
|
||||
const selected = files ?? remoteFiles.filter((file) => remoteSelected.has(file.name))
|
||||
let pending = selected
|
||||
const names = selected.map((file) => file.name)
|
||||
let conflicts =
|
||||
dest === localPath ? conflictNamesFromListing(names, localFiles) : []
|
||||
if (dest !== localPath && window.opsxApi?.localGetStats) {
|
||||
const hits: string[] = []
|
||||
for (const name of names) {
|
||||
const localTarget = transferDestinationPath('download', dest, name)
|
||||
try {
|
||||
const result = (await window.opsxApi.localGetStats(localTarget)) as {
|
||||
success?: boolean
|
||||
stats?: unknown
|
||||
const reportTransfer = useCallback(
|
||||
(action: 'upload' | 'download', result: { completed: number; failed: number; errors: string[] }) => {
|
||||
const actionLabel = action === 'upload' ? t('toast.uploadAction') : t('toast.downloadAction')
|
||||
if (result.failed > 0) {
|
||||
const detail = result.errors[0] || actionLabel
|
||||
toast.error(
|
||||
t('toast.uploadPartial', {
|
||||
action: actionLabel,
|
||||
completed: result.completed,
|
||||
failed: result.failed,
|
||||
detail,
|
||||
})
|
||||
)
|
||||
} else if (result.completed > 0) {
|
||||
toast.success(t('toast.uploadSuccess', { action: actionLabel, completed: result.completed }))
|
||||
}
|
||||
},
|
||||
[t, toast]
|
||||
)
|
||||
|
||||
const performUpload = useCallback(
|
||||
async (
|
||||
sources: Array<{ path: string; name?: string; size?: number; isDirectory?: boolean }>,
|
||||
targetPath?: string
|
||||
) => {
|
||||
const dest = targetPath ?? remotePath
|
||||
let pending = sources
|
||||
const names = sources.map(
|
||||
(source) => source.name || source.path.split(/[\\/]/).filter(Boolean).pop() || source.path
|
||||
)
|
||||
let conflicts = dest === remotePath ? conflictNamesFromListing(names, remoteFiles) : []
|
||||
if (dest !== remotePath && sftpId && window.opsxApi?.sftpStat) {
|
||||
const hits: string[] = []
|
||||
for (const name of names) {
|
||||
const remoteTarget = transferDestinationPath('upload', dest, name)
|
||||
try {
|
||||
const result = await window.opsxApi.sftpStat(sftpId, remoteTarget)
|
||||
if (result?.success && result.stats) hits.push(name)
|
||||
} catch {
|
||||
// treat as missing
|
||||
}
|
||||
if (result?.success && result.stats) hits.push(name)
|
||||
} catch {
|
||||
// treat as missing
|
||||
}
|
||||
conflicts = hits
|
||||
}
|
||||
if (conflicts.length > 0) {
|
||||
const overwrite = await confirm({
|
||||
title: t('dialog.overwriteTitle'),
|
||||
message: formatOverwriteMessage(conflicts, 'upload', t),
|
||||
confirmText: t('dialog.overwriteConfirm'),
|
||||
cancelText: t('dialog.overwriteSkip'),
|
||||
danger: true,
|
||||
})
|
||||
if (!overwrite) {
|
||||
const skip = new Set(conflicts)
|
||||
pending = sources.filter((source) => {
|
||||
const name = source.name || source.path.split(/[\\/]/).filter(Boolean).pop() || source.path
|
||||
return !skip.has(name)
|
||||
})
|
||||
if (pending.length === 0) {
|
||||
toast.info(t('toast.allSkippedExisting'))
|
||||
return
|
||||
}
|
||||
toast.info(t('toast.skippedExisting', { count: conflicts.length }))
|
||||
}
|
||||
}
|
||||
conflicts = hits
|
||||
}
|
||||
if (conflicts.length > 0) {
|
||||
const overwrite = await confirm({
|
||||
title: t('dialog.overwriteTitle'),
|
||||
message: formatOverwriteMessage(conflicts, 'download', t),
|
||||
confirmText: t('dialog.overwriteConfirm'),
|
||||
cancelText: t('dialog.overwriteSkip'),
|
||||
danger: true,
|
||||
})
|
||||
if (!overwrite) {
|
||||
const skip = new Set(conflicts)
|
||||
pending = selected.filter((file) => !skip.has(file.name))
|
||||
if (pending.length === 0) {
|
||||
toast.info(t('toast.allSkippedExisting'))
|
||||
return
|
||||
setIsTransferPanelOpen(true)
|
||||
const result = await uploadFiles(pending, dest)
|
||||
reportTransfer('upload', result)
|
||||
},
|
||||
[confirm, remoteFiles, remotePath, reportTransfer, sftpId, t, toast, uploadFiles]
|
||||
)
|
||||
|
||||
const performDownload = useCallback(
|
||||
async (files?: FileItem[], targetPath?: string) => {
|
||||
const dest = targetPath ?? localPath
|
||||
const selected = files ?? remoteFiles.filter((file) => remoteSelected.has(file.name))
|
||||
let pending = selected
|
||||
const names = selected.map((file) => file.name)
|
||||
let conflicts = dest === localPath ? conflictNamesFromListing(names, localFiles) : []
|
||||
if (dest !== localPath && window.opsxApi?.localGetStats) {
|
||||
const hits: string[] = []
|
||||
for (const name of names) {
|
||||
const localTarget = transferDestinationPath('download', dest, name)
|
||||
try {
|
||||
const result = (await window.opsxApi.localGetStats(localTarget)) as {
|
||||
success?: boolean
|
||||
stats?: unknown
|
||||
}
|
||||
if (result?.success && result.stats) hits.push(name)
|
||||
} catch {
|
||||
// treat as missing
|
||||
}
|
||||
}
|
||||
toast.info(t('toast.skippedExisting', { count: conflicts.length }))
|
||||
conflicts = hits
|
||||
}
|
||||
}
|
||||
setIsTransferPanelOpen(true)
|
||||
const result = await downloadFiles(pending, dest)
|
||||
reportTransfer('download', result)
|
||||
}, [
|
||||
confirm,
|
||||
downloadFiles,
|
||||
localFiles,
|
||||
localPath,
|
||||
remoteFiles,
|
||||
remoteSelected,
|
||||
reportTransfer,
|
||||
t,
|
||||
toast,
|
||||
])
|
||||
if (conflicts.length > 0) {
|
||||
const overwrite = await confirm({
|
||||
title: t('dialog.overwriteTitle'),
|
||||
message: formatOverwriteMessage(conflicts, 'download', t),
|
||||
confirmText: t('dialog.overwriteConfirm'),
|
||||
cancelText: t('dialog.overwriteSkip'),
|
||||
danger: true,
|
||||
})
|
||||
if (!overwrite) {
|
||||
const skip = new Set(conflicts)
|
||||
pending = selected.filter((file) => !skip.has(file.name))
|
||||
if (pending.length === 0) {
|
||||
toast.info(t('toast.allSkippedExisting'))
|
||||
return
|
||||
}
|
||||
toast.info(t('toast.skippedExisting', { count: conflicts.length }))
|
||||
}
|
||||
}
|
||||
setIsTransferPanelOpen(true)
|
||||
const result = await downloadFiles(pending, dest)
|
||||
reportTransfer('download', result)
|
||||
},
|
||||
[confirm, downloadFiles, localFiles, localPath, remoteFiles, remoteSelected, reportTransfer, t, toast]
|
||||
)
|
||||
|
||||
// ===== Context menu handlers =====
|
||||
|
||||
// Rename handler
|
||||
const handleRename = useCallback(async (file: FileItem) => {
|
||||
const newName = await prompt({
|
||||
title: t('dialog.renameTitle'),
|
||||
message: t('dialog.renameMessage'),
|
||||
placeholder: t('dialog.renamePlaceholder'),
|
||||
defaultValue: file.name,
|
||||
})
|
||||
if (newName && newName !== file.name) {
|
||||
const ok = await renameRemote(file, newName)
|
||||
if (ok) {
|
||||
toast.success(t('toast.renamed', { name: newName }))
|
||||
} else {
|
||||
toast.error(t('toast.renameFailed'))
|
||||
const handleRename = useCallback(
|
||||
async (file: FileItem) => {
|
||||
const newName = await prompt({
|
||||
title: t('dialog.renameTitle'),
|
||||
message: t('dialog.renameMessage'),
|
||||
placeholder: t('dialog.renamePlaceholder'),
|
||||
defaultValue: file.name,
|
||||
})
|
||||
if (newName && newName !== file.name) {
|
||||
const ok = await renameRemote(file, newName)
|
||||
if (ok) {
|
||||
toast.success(t('toast.renamed', { name: newName }))
|
||||
} else {
|
||||
toast.error(t('toast.renameFailed'))
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [prompt, renameRemote, t, toast])
|
||||
},
|
||||
[prompt, renameRemote, t, toast]
|
||||
)
|
||||
|
||||
// Properties handler
|
||||
const handleShowProperties = useCallback((file: FileItem) => {
|
||||
setPropertiesFile(file)
|
||||
}, [])
|
||||
|
||||
const handleDeleteRemote = useCallback(async (files: FileItem[]) => {
|
||||
if (files.length === 0) return
|
||||
const hasDir = files.some((file) => file.type === 'directory')
|
||||
const names = files.slice(0, 3).map((file) => file.name).join(', ')
|
||||
const ok = await confirm({
|
||||
title: t('dialog.deleteTitle'),
|
||||
message:
|
||||
t('dialog.deleteMessage', { count: files.length, names }) +
|
||||
(hasDir ? `\n${t('dialog.deleteRecursiveWarn')}` : ''),
|
||||
danger: true,
|
||||
confirmText: t('contextMenu.delete'),
|
||||
})
|
||||
if (!ok) return
|
||||
const success = await deleteRemote(files)
|
||||
if (!success) toast.error(t('toast.deleteFailed'))
|
||||
}, [confirm, deleteRemote, t, toast])
|
||||
const handleDeleteRemote = useCallback(
|
||||
async (files: FileItem[]) => {
|
||||
if (files.length === 0) return
|
||||
const hasDir = files.some((file) => file.type === 'directory')
|
||||
const names = files
|
||||
.slice(0, 3)
|
||||
.map((file) => file.name)
|
||||
.join(', ')
|
||||
const ok = await confirm({
|
||||
title: t('dialog.deleteTitle'),
|
||||
message:
|
||||
t('dialog.deleteMessage', { count: files.length, names }) +
|
||||
(hasDir ? `\n${t('dialog.deleteRecursiveWarn')}` : ''),
|
||||
danger: true,
|
||||
confirmText: t('contextMenu.delete'),
|
||||
})
|
||||
if (!ok) return
|
||||
const success = await deleteRemote(files)
|
||||
if (!success) toast.error(t('toast.deleteFailed'))
|
||||
},
|
||||
[confirm, deleteRemote, t, toast]
|
||||
)
|
||||
|
||||
// Permissions (chmod) handler — fetch stat first, then show dialog
|
||||
const handleChmod = useCallback(async (file: FileItem) => {
|
||||
const stats = await getRemoteStat(file.path)
|
||||
const mode = stats?.mode ?? 0o644
|
||||
setChmodFile(file)
|
||||
setChmodInitialMode(mode & 0o7777)
|
||||
}, [getRemoteStat])
|
||||
const handleChmod = useCallback(
|
||||
async (file: FileItem) => {
|
||||
const stats = await getRemoteStat(file.path)
|
||||
const mode = stats?.mode ?? 0o644
|
||||
setChmodFile(file)
|
||||
setChmodInitialMode(mode & 0o7777)
|
||||
},
|
||||
[getRemoteStat]
|
||||
)
|
||||
|
||||
// Chmod confirm callback
|
||||
const handleChmodConfirm = useCallback(async (filePath: string, mode: number) => {
|
||||
const ok = await chmodRemote(filePath, mode)
|
||||
if (ok) {
|
||||
toast.success(t('toast.chmodUpdated', { mode: mode.toString(8) }))
|
||||
return true
|
||||
} else {
|
||||
toast.error(t('toast.chmodFailed'))
|
||||
return false
|
||||
}
|
||||
}, [chmodRemote, toast, t])
|
||||
const handleChmodConfirm = useCallback(
|
||||
async (filePath: string, mode: number) => {
|
||||
const ok = await chmodRemote(filePath, mode)
|
||||
if (ok) {
|
||||
toast.success(t('toast.chmodUpdated', { mode: mode.toString(8) }))
|
||||
return true
|
||||
} else {
|
||||
toast.error(t('toast.chmodFailed'))
|
||||
return false
|
||||
}
|
||||
},
|
||||
[chmodRemote, toast, t]
|
||||
)
|
||||
|
||||
// Local context menu
|
||||
const showLocalContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => {
|
||||
e.preventDefault()
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
icon: <FolderOpen className="w-4 h-4" />,
|
||||
label: t('contextMenu.open'),
|
||||
action: () => file.type === 'directory' && navigateLocal(file.path),
|
||||
},
|
||||
...(file.type !== 'directory'
|
||||
? [{
|
||||
icon: <Upload className="w-4 h-4" />,
|
||||
label: t('contextMenu.upload'),
|
||||
action: () => void performUpload([{ path: file.path, name: file.name, size: file.size }]),
|
||||
} as MenuItem]
|
||||
: []),
|
||||
{
|
||||
icon: <FileText className="w-4 h-4" />,
|
||||
label: t('contextMenu.properties'),
|
||||
separator: true,
|
||||
action: () => handleShowProperties(file),
|
||||
},
|
||||
]
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, items })
|
||||
}, [navigateLocal, handleShowProperties, performUpload, t])
|
||||
const showLocalContextMenu = useCallback(
|
||||
(e: React.MouseEvent, file: FileItem) => {
|
||||
e.preventDefault()
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
icon: <FolderOpen className="h-4 w-4" />,
|
||||
label: t('contextMenu.open'),
|
||||
action: () => file.type === 'directory' && navigateLocal(file.path),
|
||||
},
|
||||
...(file.type !== 'directory'
|
||||
? [
|
||||
{
|
||||
icon: <Upload className="h-4 w-4" />,
|
||||
label: t('contextMenu.upload'),
|
||||
action: () => void performUpload([{ path: file.path, name: file.name, size: file.size }]),
|
||||
} as MenuItem,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
label: t('contextMenu.properties'),
|
||||
separator: true,
|
||||
action: () => handleShowProperties(file),
|
||||
},
|
||||
]
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, items })
|
||||
},
|
||||
[navigateLocal, handleShowProperties, performUpload, t]
|
||||
)
|
||||
|
||||
// Remote context menu
|
||||
const showRemoteContextMenu = useCallback((e: React.MouseEvent, file: FileItem) => {
|
||||
e.preventDefault()
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
icon: <FolderOpen className="w-4 h-4" />,
|
||||
label: t('contextMenu.open'),
|
||||
action: () => file.type === 'directory' && navigateRemote(file.path),
|
||||
},
|
||||
{
|
||||
icon: <Download className="w-4 h-4" />,
|
||||
label: t('contextMenu.download'),
|
||||
action: () => void performDownload([file]),
|
||||
},
|
||||
...(file.type === 'file' && isEditableRemoteTextFile(file.path, file.type)
|
||||
? [
|
||||
{
|
||||
icon: <FileCode className="w-4 h-4" />,
|
||||
label: t('contextMenu.edit'),
|
||||
action: () => {
|
||||
if (!isWithinRemoteTextEditLimit(file.size)) {
|
||||
toast.warning(t('errors:sftp.editTooLarge'))
|
||||
return
|
||||
}
|
||||
setEditFile(file)
|
||||
},
|
||||
} as MenuItem,
|
||||
]
|
||||
: []),
|
||||
{ icon: <Edit3 className="w-4 h-4" />, label: t('contextMenu.rename'), separator: true, action: () => handleRename(file) },
|
||||
{
|
||||
icon: <FileText className="w-4 h-4" />,
|
||||
label: t('contextMenu.properties'),
|
||||
action: () => handleShowProperties(file),
|
||||
},
|
||||
{
|
||||
icon: <ShieldCheck className="w-4 h-4" />,
|
||||
label: t('contextMenu.permissions'),
|
||||
action: () => handleChmod(file),
|
||||
},
|
||||
{ icon: <Trash2 className="w-4 h-4" />, label: t('contextMenu.delete'), separator: true, danger: true, action: () => void handleDeleteRemote([file]) },
|
||||
]
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, items })
|
||||
}, [navigateRemote, performDownload, handleDeleteRemote, handleRename, handleShowProperties, handleChmod, toast, t])
|
||||
const showRemoteContextMenu = useCallback(
|
||||
(e: React.MouseEvent, file: FileItem) => {
|
||||
e.preventDefault()
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
icon: <FolderOpen className="h-4 w-4" />,
|
||||
label: t('contextMenu.open'),
|
||||
action: () => file.type === 'directory' && navigateRemote(file.path),
|
||||
},
|
||||
{
|
||||
icon: <Download className="h-4 w-4" />,
|
||||
label: t('contextMenu.download'),
|
||||
action: () => void performDownload([file]),
|
||||
},
|
||||
...(file.type === 'file' && isEditableRemoteTextFile(file.path, file.type)
|
||||
? [
|
||||
{
|
||||
icon: <FileCode className="h-4 w-4" />,
|
||||
label: t('contextMenu.edit'),
|
||||
action: () => {
|
||||
if (!isWithinRemoteTextEditLimit(file.size)) {
|
||||
toast.warning(t('errors:sftp.editTooLarge'))
|
||||
return
|
||||
}
|
||||
setEditFile(file)
|
||||
},
|
||||
} as MenuItem,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: <Edit3 className="h-4 w-4" />,
|
||||
label: t('contextMenu.rename'),
|
||||
separator: true,
|
||||
action: () => handleRename(file),
|
||||
},
|
||||
{
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
label: t('contextMenu.properties'),
|
||||
action: () => handleShowProperties(file),
|
||||
},
|
||||
{
|
||||
icon: <ShieldCheck className="h-4 w-4" />,
|
||||
label: t('contextMenu.permissions'),
|
||||
action: () => handleChmod(file),
|
||||
},
|
||||
{
|
||||
icon: <Trash2 className="h-4 w-4" />,
|
||||
label: t('contextMenu.delete'),
|
||||
separator: true,
|
||||
danger: true,
|
||||
action: () => void handleDeleteRemote([file]),
|
||||
},
|
||||
]
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, items })
|
||||
},
|
||||
[navigateRemote, performDownload, handleDeleteRemote, handleRename, handleShowProperties, handleChmod, toast, t]
|
||||
)
|
||||
|
||||
// Navigate up
|
||||
const navigateLocalUp = useCallback(() => {
|
||||
@@ -465,10 +514,13 @@ export default function SFTPView({ tabId, isActive = true, sessionId }: SFTPView
|
||||
const focusLocalPane = useCallback(() => setActivePane('local'), [])
|
||||
const focusRemotePane = useCallback(() => setActivePane('remote'), [])
|
||||
|
||||
const navigateRemoteClear = useCallback((p: string) => {
|
||||
clearError()
|
||||
navigateRemote(p)
|
||||
}, [clearError, navigateRemote])
|
||||
const navigateRemoteClear = useCallback(
|
||||
(p: string) => {
|
||||
clearError()
|
||||
navigateRemote(p)
|
||||
},
|
||||
[clearError, navigateRemote]
|
||||
)
|
||||
|
||||
const navigateRemoteBackClear = useCallback(() => {
|
||||
clearError()
|
||||
@@ -486,29 +538,37 @@ export default function SFTPView({ tabId, isActive = true, sessionId }: SFTPView
|
||||
}, [clearError, navigateRemoteUp])
|
||||
|
||||
// Handle file open
|
||||
const handleLocalOpen = useCallback((file: FileItem) => {
|
||||
if (file.type === 'directory') {
|
||||
navigateLocal(file.path)
|
||||
}
|
||||
}, [navigateLocal])
|
||||
const handleLocalOpen = useCallback(
|
||||
(file: FileItem) => {
|
||||
if (file.type === 'directory') {
|
||||
navigateLocal(file.path)
|
||||
}
|
||||
},
|
||||
[navigateLocal]
|
||||
)
|
||||
|
||||
const handleRemoteOpen = useCallback((file: FileItem) => {
|
||||
if (file.type === 'directory') {
|
||||
navigateRemote(file.path)
|
||||
}
|
||||
}, [navigateRemote])
|
||||
const handleRemoteOpen = useCallback(
|
||||
(file: FileItem) => {
|
||||
if (file.type === 'directory') {
|
||||
navigateRemote(file.path)
|
||||
}
|
||||
},
|
||||
[navigateRemote]
|
||||
)
|
||||
|
||||
// Upload selected local rows first; otherwise use the native picker so Rust
|
||||
// receives real absolute paths instead of browser-only File objects.
|
||||
const handleUpload = useCallback(async () => {
|
||||
const selectedFiles = localFiles.filter((file) => localSelected.has(file.name))
|
||||
if (selectedFiles.length > 0) {
|
||||
await performUpload(selectedFiles.map((file) => ({
|
||||
path: file.path,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
isDirectory: file.type === 'directory',
|
||||
})))
|
||||
await performUpload(
|
||||
selectedFiles.map((file) => ({
|
||||
path: file.path,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
isDirectory: file.type === 'directory',
|
||||
}))
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -533,61 +593,67 @@ export default function SFTPView({ tabId, isActive = true, sessionId }: SFTPView
|
||||
}, [performDownload])
|
||||
|
||||
// Handle drag-and-drop upload on remote panel
|
||||
const handleDropFiles = useCallback((files: FileList, targetPath: string) => {
|
||||
if (!isConnected) return
|
||||
const sources = Array.from(files).flatMap((file) => {
|
||||
const path = (file as File & { path?: string }).path
|
||||
return path ? [{ path, name: file.name, size: file.size }] : []
|
||||
})
|
||||
dndLog('dispatch-os-upload', {
|
||||
target: shortPath(targetPath),
|
||||
fileCount: files.length,
|
||||
withPath: sources.length,
|
||||
})
|
||||
if (sources.length === 0) {
|
||||
toast.warning(t('toast.noDragPath'))
|
||||
return
|
||||
}
|
||||
void performUpload(sources, targetPath)
|
||||
}, [isConnected, performUpload, toast, t])
|
||||
const handleDropFiles = useCallback(
|
||||
(files: FileList, targetPath: string) => {
|
||||
if (!isConnected) return
|
||||
const sources = Array.from(files).flatMap((file) => {
|
||||
const path = (file as File & { path?: string }).path
|
||||
return path ? [{ path, name: file.name, size: file.size }] : []
|
||||
})
|
||||
dndLog('dispatch-os-upload', {
|
||||
target: shortPath(targetPath),
|
||||
fileCount: files.length,
|
||||
withPath: sources.length,
|
||||
})
|
||||
if (sources.length === 0) {
|
||||
toast.warning(t('toast.noDragPath'))
|
||||
return
|
||||
}
|
||||
void performUpload(sources, targetPath)
|
||||
},
|
||||
[isConnected, performUpload, toast, t]
|
||||
)
|
||||
|
||||
const handleDropEntry = useCallback((entries: SftpDragEntry[], targetPath: string) => {
|
||||
if (!isConnected || entries.length === 0) return
|
||||
const localEntries = entries.filter((entry) => entry.source === 'local')
|
||||
const remoteEntries = entries.filter((entry) => entry.source === 'remote')
|
||||
dndLog('dispatch', {
|
||||
target: shortPath(targetPath),
|
||||
upload: localEntries.length,
|
||||
download: remoteEntries.length,
|
||||
uploadNames: localEntries.slice(0, 8).map((e) => e.name),
|
||||
downloadNames: remoteEntries.slice(0, 8).map((e) => e.name),
|
||||
via: 'pointer',
|
||||
})
|
||||
if (localEntries.length > 0) {
|
||||
void performUpload(
|
||||
localEntries.map((entry) => ({
|
||||
path: entry.path,
|
||||
name: entry.name,
|
||||
size: entry.size,
|
||||
isDirectory: entry.type === 'directory',
|
||||
})),
|
||||
targetPath
|
||||
)
|
||||
}
|
||||
if (remoteEntries.length > 0) {
|
||||
void performDownload(
|
||||
remoteEntries.map((entry) => ({
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
type: entry.type,
|
||||
size: entry.size,
|
||||
modified: 0,
|
||||
permissions: '',
|
||||
})),
|
||||
targetPath
|
||||
)
|
||||
}
|
||||
}, [isConnected, performDownload, performUpload])
|
||||
const handleDropEntry = useCallback(
|
||||
(entries: SftpDragEntry[], targetPath: string) => {
|
||||
if (!isConnected || entries.length === 0) return
|
||||
const localEntries = entries.filter((entry) => entry.source === 'local')
|
||||
const remoteEntries = entries.filter((entry) => entry.source === 'remote')
|
||||
dndLog('dispatch', {
|
||||
target: shortPath(targetPath),
|
||||
upload: localEntries.length,
|
||||
download: remoteEntries.length,
|
||||
uploadNames: localEntries.slice(0, 8).map((e) => e.name),
|
||||
downloadNames: remoteEntries.slice(0, 8).map((e) => e.name),
|
||||
via: 'pointer',
|
||||
})
|
||||
if (localEntries.length > 0) {
|
||||
void performUpload(
|
||||
localEntries.map((entry) => ({
|
||||
path: entry.path,
|
||||
name: entry.name,
|
||||
size: entry.size,
|
||||
isDirectory: entry.type === 'directory',
|
||||
})),
|
||||
targetPath
|
||||
)
|
||||
}
|
||||
if (remoteEntries.length > 0) {
|
||||
void performDownload(
|
||||
remoteEntries.map((entry) => ({
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
type: entry.type,
|
||||
size: entry.size,
|
||||
modified: 0,
|
||||
permissions: '',
|
||||
})),
|
||||
targetPath
|
||||
)
|
||||
}
|
||||
},
|
||||
[isConnected, performDownload, performUpload]
|
||||
)
|
||||
|
||||
const handleDropFailed = useCallback(() => {
|
||||
dndLog('toast-drop-failed')
|
||||
@@ -653,7 +719,7 @@ export default function SFTPView({ tabId, isActive = true, sessionId }: SFTPView
|
||||
|
||||
{/* File Panels */}
|
||||
{!isConnected ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<EmptyState
|
||||
icon={Unplug}
|
||||
title={t('panel.notConnectedTitle')}
|
||||
@@ -665,74 +731,74 @@ export default function SFTPView({ tabId, isActive = true, sessionId }: SFTPView
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex gap-2 p-4 min-h-0">
|
||||
{/* Local Panel */}
|
||||
<FileListPanel
|
||||
title={t('panel.local')}
|
||||
isLocal={true}
|
||||
path={localPath}
|
||||
homePath={localHistory[0] || localPath}
|
||||
files={sortedLocalFiles}
|
||||
selected={localSelected}
|
||||
history={localHistory}
|
||||
historyIndex={localHistoryIndex}
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
onNavigate={navigateLocal}
|
||||
onNavigateBack={navigateLocalBack}
|
||||
onNavigateForward={navigateLocalForward}
|
||||
onNavigateUp={navigateLocalUp}
|
||||
onSelect={toggleLocalSelect}
|
||||
onOpen={handleLocalOpen}
|
||||
onContextMenu={showLocalContextMenu}
|
||||
onSort={handleSort}
|
||||
onDropEntry={handleDropEntry}
|
||||
onDropFailed={handleDropFailed}
|
||||
onRefresh={refreshLocal}
|
||||
onNewFolder={handleNewLocalFolder}
|
||||
onFocusPane={focusLocalPane}
|
||||
refreshing={localLoading}
|
||||
loading={localLoading && localFiles.length === 0}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1 gap-2 p-4">
|
||||
{/* Local Panel */}
|
||||
<FileListPanel
|
||||
title={t('panel.local')}
|
||||
isLocal={true}
|
||||
path={localPath}
|
||||
homePath={localHistory[0] || localPath}
|
||||
files={sortedLocalFiles}
|
||||
selected={localSelected}
|
||||
history={localHistory}
|
||||
historyIndex={localHistoryIndex}
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
onNavigate={navigateLocal}
|
||||
onNavigateBack={navigateLocalBack}
|
||||
onNavigateForward={navigateLocalForward}
|
||||
onNavigateUp={navigateLocalUp}
|
||||
onSelect={toggleLocalSelect}
|
||||
onOpen={handleLocalOpen}
|
||||
onContextMenu={showLocalContextMenu}
|
||||
onSort={handleSort}
|
||||
onDropEntry={handleDropEntry}
|
||||
onDropFailed={handleDropFailed}
|
||||
onRefresh={refreshLocal}
|
||||
onNewFolder={handleNewLocalFolder}
|
||||
onFocusPane={focusLocalPane}
|
||||
refreshing={localLoading}
|
||||
loading={localLoading && localFiles.length === 0}
|
||||
/>
|
||||
|
||||
<TransferRail
|
||||
canUpload={isConnected}
|
||||
canDownload={isConnected && remoteSelected.size > 0}
|
||||
onUpload={railUpload}
|
||||
onDownload={railDownload}
|
||||
/>
|
||||
<TransferRail
|
||||
canUpload={isConnected}
|
||||
canDownload={isConnected && remoteSelected.size > 0}
|
||||
onUpload={railUpload}
|
||||
onDownload={railDownload}
|
||||
/>
|
||||
|
||||
{/* Remote Panel */}
|
||||
<FileListPanel
|
||||
title={t('panel.remote')}
|
||||
isLocal={false}
|
||||
path={remotePath}
|
||||
homePath={remoteHomePath}
|
||||
files={sortedRemoteFiles}
|
||||
selected={remoteSelected}
|
||||
history={remoteHistory}
|
||||
historyIndex={remoteHistoryIndex}
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
onNavigate={navigateRemoteClear}
|
||||
onNavigateBack={navigateRemoteBackClear}
|
||||
onNavigateForward={navigateRemoteForwardClear}
|
||||
onNavigateUp={navigateRemoteUpClear}
|
||||
onSelect={toggleRemoteSelect}
|
||||
onOpen={handleRemoteOpen}
|
||||
onContextMenu={showRemoteContextMenu}
|
||||
onSort={handleSort}
|
||||
onDropFiles={isConnected ? handleDropFiles : undefined}
|
||||
onDropEntry={handleDropEntry}
|
||||
onDropFailed={handleDropFailed}
|
||||
onRefresh={isConnected ? refreshRemote : undefined}
|
||||
onNewFolder={handleNewRemoteFolder}
|
||||
onFocusPane={focusRemotePane}
|
||||
loading={isConnecting || (remoteLoading && remoteFiles.length === 0)}
|
||||
refreshing={remoteLoading}
|
||||
listError={remoteListError}
|
||||
/>
|
||||
</div>
|
||||
{/* Remote Panel */}
|
||||
<FileListPanel
|
||||
title={t('panel.remote')}
|
||||
isLocal={false}
|
||||
path={remotePath}
|
||||
homePath={remoteHomePath}
|
||||
files={sortedRemoteFiles}
|
||||
selected={remoteSelected}
|
||||
history={remoteHistory}
|
||||
historyIndex={remoteHistoryIndex}
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
onNavigate={navigateRemoteClear}
|
||||
onNavigateBack={navigateRemoteBackClear}
|
||||
onNavigateForward={navigateRemoteForwardClear}
|
||||
onNavigateUp={navigateRemoteUpClear}
|
||||
onSelect={toggleRemoteSelect}
|
||||
onOpen={handleRemoteOpen}
|
||||
onContextMenu={showRemoteContextMenu}
|
||||
onSort={handleSort}
|
||||
onDropFiles={isConnected ? handleDropFiles : undefined}
|
||||
onDropEntry={handleDropEntry}
|
||||
onDropFailed={handleDropFailed}
|
||||
onRefresh={isConnected ? refreshRemote : undefined}
|
||||
onNewFolder={handleNewRemoteFolder}
|
||||
onFocusPane={focusRemotePane}
|
||||
loading={isConnecting || (remoteLoading && remoteFiles.length === 0)}
|
||||
refreshing={remoteLoading}
|
||||
listError={remoteListError}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transfer Panel */}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Terminal as TerminalIcon, WifiOff, RefreshCw, AlertTriangle, Clock3 } from 'lucide-react'
|
||||
import { translateErrorMessage } from '../../i18n/format'
|
||||
import type { ConnectionState } from '../../stores/connection'
|
||||
import type { DisconnectKind } from '../../utils/disconnectReason'
|
||||
|
||||
export interface ShellClosedOverlayProps {
|
||||
reason: string
|
||||
@@ -33,7 +35,7 @@ export function ShellClosedOverlay({
|
||||
<div className="mb-1.5 text-sm font-medium text-slate-100">
|
||||
{idleTimeout ? t('overlay.shellClosedIdle') : t('overlay.shellClosed')}
|
||||
</div>
|
||||
<div className="mb-2 text-xs leading-relaxed text-slate-400">{reason}</div>
|
||||
<div className="mb-2 text-xs leading-relaxed text-slate-400">{translateErrorMessage(reason)}</div>
|
||||
<div className="mb-4 text-[11px] text-accent-green">
|
||||
{t(isLocal ? 'overlay.localShellClosedHint' : 'overlay.shellClosedHint')}
|
||||
</div>
|
||||
@@ -57,6 +59,7 @@ export interface TerminalOverlayProps {
|
||||
showSuspendedOverlay: boolean
|
||||
needsReconnect: boolean
|
||||
reconnectAttempt?: number
|
||||
disconnectKind?: DisconnectKind
|
||||
isLocal?: boolean
|
||||
onReconnect: () => void
|
||||
onResume: () => void
|
||||
@@ -68,6 +71,7 @@ function TerminalOverlay({
|
||||
showSuspendedOverlay,
|
||||
needsReconnect,
|
||||
reconnectAttempt = 0,
|
||||
disconnectKind = 'unexpected',
|
||||
isLocal = false,
|
||||
onReconnect,
|
||||
onResume,
|
||||
@@ -78,18 +82,26 @@ function TerminalOverlay({
|
||||
return (
|
||||
<>
|
||||
{showDisconnectBanner && (
|
||||
<div className="terminal-overlay-scrim absolute inset-0 z-10 flex items-center justify-center backdrop-blur-[2px]">
|
||||
<div className="terminal-overlay-scrim absolute inset-0 z-30 flex items-center justify-center backdrop-blur-[2px]">
|
||||
<div className="max-w-sm px-6 text-center">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-accent-blue/25 bg-accent-blue/15">
|
||||
<WifiOff className="h-7 w-7 text-accent-blue" />
|
||||
</div>
|
||||
<div className="mb-1.5 text-base font-medium text-slate-100">
|
||||
{t(isLocal ? 'overlay.localDisconnected' : 'overlay.disconnected')}
|
||||
{isLocal
|
||||
? t('overlay.localDisconnected')
|
||||
: disconnectKind === 'keepalive'
|
||||
? t('overlay.disconnectedKeepalive')
|
||||
: t('overlay.disconnected')}
|
||||
</div>
|
||||
<div className="mb-6 text-sm leading-relaxed text-slate-400">
|
||||
{statusMessage?.trim()
|
||||
? statusMessage
|
||||
: t(isLocal ? 'overlay.localDisconnectedHint' : 'overlay.disconnectedHint')}
|
||||
: isLocal
|
||||
? t('overlay.localDisconnectedHint')
|
||||
: disconnectKind === 'keepalive'
|
||||
? t('overlay.disconnectedKeepaliveHint')
|
||||
: t('overlay.disconnectedHint')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -104,7 +116,7 @@ function TerminalOverlay({
|
||||
)}
|
||||
|
||||
{showSuspendedOverlay && (
|
||||
<div className="terminal-overlay-scrim absolute inset-0 z-10 flex items-center justify-center">
|
||||
<div className="terminal-overlay-scrim absolute inset-0 z-30 flex items-center justify-center">
|
||||
<div className="max-w-sm px-6 text-center">
|
||||
<div className="mb-2 text-base font-medium text-feedback-warning">{t('overlay.suspended')}</div>
|
||||
<div className="mb-5 text-sm text-slate-400">{t('overlay.suspendedHint')}</div>
|
||||
@@ -129,7 +141,7 @@ function TerminalOverlay({
|
||||
)}
|
||||
|
||||
{connectionState === 'error' && (
|
||||
<div className="terminal-overlay-scrim absolute inset-0 z-10 flex items-center justify-center">
|
||||
<div className="terminal-overlay-scrim absolute inset-0 z-30 flex items-center justify-center">
|
||||
<div className="max-w-md px-6 text-center">
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border border-red-500/25 bg-red-500/15">
|
||||
<AlertTriangle className="h-6 w-6 text-red-400" />
|
||||
@@ -151,7 +163,7 @@ function TerminalOverlay({
|
||||
)}
|
||||
|
||||
{(connectionState === 'connecting' || connectionState === 'reconnecting') && (
|
||||
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center bg-[color-mix(in_srgb,var(--color-terminal-bg)_45%,transparent)]">
|
||||
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center bg-[color-mix(in_srgb,var(--color-terminal-bg)_45%,transparent)]">
|
||||
<div className="px-4 text-center">
|
||||
<div className="inline-flex animate-pulse items-center gap-2 text-sm font-medium text-accent-blue">
|
||||
<TerminalIcon className="h-4 w-4" />
|
||||
|
||||
@@ -56,6 +56,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
statusMessage,
|
||||
showSuspendedOverlay,
|
||||
reconnectAttempt,
|
||||
disconnectKind,
|
||||
connectionIdRef,
|
||||
shellIdRef,
|
||||
hasConnectedRef,
|
||||
@@ -334,6 +335,7 @@ export default function TerminalView({ tabId, isActive = true }: TerminalViewPro
|
||||
!!currentTab?.needsReconnect || (connectionState === 'disconnected' && !!hasConnectedRef.current)
|
||||
}
|
||||
reconnectAttempt={reconnectAttempt}
|
||||
disconnectKind={disconnectKind}
|
||||
isLocal={!!currentTab?.isLocal}
|
||||
onReconnect={handleReconnect}
|
||||
onResume={resumeConnection}
|
||||
|
||||
@@ -46,7 +46,12 @@ import { createLogger } from '../../utils/logger'
|
||||
import type { TerminalViewProps } from './types'
|
||||
import { readClipboardText, writeClipboardText } from '../../platform/clipboard'
|
||||
import { isTerminalCopyShortcut, isTerminalPasteShortcut, isTerminalSplitShortcut } from '../../utils/terminalShortcuts'
|
||||
import { resolveTerminalInputTargets, shouldBroadcastTerminalInput } from '../../utils/terminalPanes'
|
||||
import {
|
||||
panesAfterConnectionStateChange,
|
||||
resolveTerminalInputTargets,
|
||||
shouldBroadcastTerminalInput,
|
||||
shouldCollapseTerminalSplitForOverlay,
|
||||
} from '../../utils/terminalPanes'
|
||||
import { TerminalPipeline } from '../../utils/terminalPipeline'
|
||||
import { ZmodemSftpBridge, type ZmodemBridgeStatus } from '../../utils/zmodemSftpBridge'
|
||||
import AiTerminalPanel, { type TerminalAiContext } from '../ai/AiTerminalPanel'
|
||||
@@ -274,6 +279,8 @@ function PaneTerminal({
|
||||
void shellResize(sid, term.cols, term.rows)
|
||||
}
|
||||
}
|
||||
const resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => onResize()) : null
|
||||
resizeObserver?.observe(container)
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
// Fit before advertising size so the primary shell is not born at 80×24.
|
||||
@@ -315,6 +322,7 @@ function PaneTerminal({
|
||||
return () => {
|
||||
disposed = true
|
||||
setXtermReady(false)
|
||||
resizeObserver?.disconnect()
|
||||
window.removeEventListener('resize', onResize)
|
||||
container.removeEventListener('contextmenu', handleContextMenu)
|
||||
unregisterShellHandler(paneId)
|
||||
@@ -341,9 +349,13 @@ function PaneTerminal({
|
||||
safeTerminalFit(fitAddonRef.current, term)
|
||||
}, [currentTheme, settings.fontFamily, settings.fontSize, settings.scrollback])
|
||||
|
||||
// Resize when shell id attaches
|
||||
// Resize when shell id attaches; drop the stale id when the session is gone.
|
||||
useEffect(() => {
|
||||
if (!shellId || !xtermRef.current) return
|
||||
if (!shellId) {
|
||||
shellIdRef.current = null
|
||||
return
|
||||
}
|
||||
if (!xtermRef.current) return
|
||||
shellIdRef.current = shellId
|
||||
safeTerminalFit(fitAddonRef.current, xtermRef.current)
|
||||
void shellResize(shellId, xtermRef.current.cols, xtermRef.current.rows)
|
||||
@@ -470,6 +482,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
const canEnableBroadcast = effectiveBroadcastCount >= 2
|
||||
const requirePro = useEntitlementStore((state) => state.requirePro)
|
||||
const [aiPanelOpen, setAiPanelOpen] = useState(false)
|
||||
const [shellIoReady, setShellIoReady] = useState(false)
|
||||
const isLocalTab = !!currentTab?.isLocal
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
paneId: string
|
||||
@@ -517,6 +530,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
statusMessage,
|
||||
showSuspendedOverlay,
|
||||
reconnectAttempt,
|
||||
disconnectKind,
|
||||
connectionIdRef,
|
||||
shellIdRef,
|
||||
hasConnectedRef,
|
||||
@@ -544,17 +558,14 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
}, [tabId, direction, panes.length, ratio, updateTab])
|
||||
|
||||
// Sync primary shell from connection hook → first pane.
|
||||
// Also adopt the pending id while connecting so bash/sh PS1 is not dropped.
|
||||
// Drop every pane's dead shell when the pooled session is gone so Cmd+D
|
||||
// splits recreate after auto-reconnect instead of looking frozen.
|
||||
useEffect(() => {
|
||||
const sid = shellIdRef.current || currentTab?.connectionInstance?.shellId
|
||||
if (!sid) return
|
||||
if (connectionState !== 'connected' && connectionState !== 'connecting') return
|
||||
setPanes((prev) => {
|
||||
if (prev.length === 0) return prev
|
||||
if (prev[0].shellId === sid) return prev
|
||||
if (prev[0].shellId) return prev
|
||||
return prev.map((p, i) => (i === 0 ? { ...p, shellId: sid } : p))
|
||||
})
|
||||
const primaryShellId =
|
||||
connectionState === 'connected' || connectionState === 'connecting'
|
||||
? shellIdRef.current || currentTab?.connectionInstance?.shellId || null
|
||||
: null
|
||||
setPanes((prev) => panesAfterConnectionStateChange(prev, connectionState, primaryShellId))
|
||||
}, [connectionState, currentTab?.connectionInstance?.shellId, shellIdRef])
|
||||
|
||||
// Register ready shells (SSH + local) so broadcast can cover this tab's panes.
|
||||
@@ -596,8 +607,10 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
if (broadcastInput && effectiveBroadcastCount < 2) setBroadcastInput(false)
|
||||
}, [broadcastInput, effectiveBroadcastCount, setBroadcastInput])
|
||||
|
||||
// Fan-out shell data to panes (SSH + local)
|
||||
// Fan-out shell data to panes (SSH + local). Do not connect until listen() resolves —
|
||||
// otherwise MOTD from bootstrap shows up and the prompt (later events) never arrives.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const handler = (_e: unknown, _connId: string, shellId: string, data: unknown) => {
|
||||
for (const h of shellHandlersRef.current.values()) {
|
||||
h(shellId, data)
|
||||
@@ -608,11 +621,19 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
if (conn) conn.stats.bytesTransferred += shellPayloadByteLength(data)
|
||||
}
|
||||
}
|
||||
window.opsxApi?.onShellData(handler)
|
||||
window.opsxApi?.onLocalData?.(handler)
|
||||
const api = window.opsxApi
|
||||
const pending = [
|
||||
api?.onShellData ? Promise.resolve(api.onShellData(handler)) : Promise.resolve(),
|
||||
api?.onLocalData ? Promise.resolve(api.onLocalData(handler)) : Promise.resolve(),
|
||||
]
|
||||
void Promise.all(pending).then(() => {
|
||||
if (!cancelled) setShellIoReady(true)
|
||||
})
|
||||
return () => {
|
||||
window.opsxApi?.removeListener('ssh:shell-data', handler as (...args: unknown[]) => void)
|
||||
window.opsxApi?.removeListener('local:data', handler as (...args: unknown[]) => void)
|
||||
cancelled = true
|
||||
setShellIoReady(false)
|
||||
api?.removeListener('ssh:shell-data', handler as (...args: unknown[]) => void)
|
||||
api?.removeListener('local:data', handler as (...args: unknown[]) => void)
|
||||
}
|
||||
}, [connectionIdRef])
|
||||
|
||||
@@ -712,6 +733,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
|
||||
const armInitialConnect = useCallback(() => {
|
||||
if (connectArmedRef.current || hasConnectedRef.current) return
|
||||
if (!shellIoReady) return
|
||||
const current = getTabById(tabId)
|
||||
if (!current) return
|
||||
if (!(isActive || current.connectOnMount)) return
|
||||
@@ -728,7 +750,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
requestAnimationFrame(() => {
|
||||
void connect()
|
||||
})
|
||||
}, [connect, getTabById, isActive, restoreSnapshot, snapshotStore, tabId, updateTab, hasConnectedRef])
|
||||
}, [connect, getTabById, isActive, restoreSnapshot, shellIoReady, snapshotStore, tabId, updateTab, hasConnectedRef])
|
||||
|
||||
const registerShellHandler = useCallback((paneId: string, handler: (shellId: string, data: unknown) => void) => {
|
||||
shellHandlersRef.current.set(paneId, handler)
|
||||
@@ -777,7 +799,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
useEffect(() => {
|
||||
if (!isActive || hasConnectedRef.current || connectArmedRef.current) return
|
||||
if (primaryXtermRef.current) armInitialConnect()
|
||||
}, [isActive, armInitialConnect, hasConnectedRef])
|
||||
}, [isActive, armInitialConnect, hasConnectedRef, shellIoReady])
|
||||
|
||||
const onShellReady = useCallback(
|
||||
(paneId: string, shellId: string) => {
|
||||
@@ -1073,12 +1095,21 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
|
||||
const multi = panes.length > 1
|
||||
const isLocal = !!currentTab?.isLocal
|
||||
const needsReconnect =
|
||||
!!currentTab?.needsReconnect || (connectionState === 'disconnected' && !!hasConnectedRef.current)
|
||||
const layoutSplit =
|
||||
multi &&
|
||||
!shouldCollapseTerminalSplitForOverlay({
|
||||
connectionState,
|
||||
needsReconnect,
|
||||
showSuspendedOverlay,
|
||||
})
|
||||
|
||||
const paneStyle = (index: number, paneId: string): CSSProperties => {
|
||||
if (maximizedPaneId === paneId) {
|
||||
return { flex: 1, width: '100%', height: '100%', minWidth: 0, minHeight: 0 }
|
||||
}
|
||||
if (!multi) return { flex: 1 }
|
||||
if (!layoutSplit) return { flex: 1 }
|
||||
if (panes.length === 2) {
|
||||
const share = index === 0 ? ratio : 1 - ratio
|
||||
return direction === 'horizontal'
|
||||
@@ -1090,7 +1121,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-dark-900">
|
||||
<div className="app-workspace">
|
||||
<div className="border-dark-700/35 bg-dark-800/40 flex items-center gap-0.5 border-b px-2 py-1">
|
||||
<button
|
||||
type="button"
|
||||
@@ -1230,8 +1261,8 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
<div className="relative min-h-0 min-w-0 flex-1 overflow-hidden p-1">
|
||||
<div className="app-workspace-body">
|
||||
<div className="terminal-canvas">
|
||||
{showSearch && (
|
||||
<TerminalSearchBar
|
||||
searchQuery={searchQuery}
|
||||
@@ -1246,13 +1277,15 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
)}
|
||||
<div
|
||||
className={`h-full min-h-0 w-full ${
|
||||
multi ? (direction === 'horizontal' ? 'flex flex-row' : 'flex flex-col') : 'flex'
|
||||
layoutSplit ? (direction === 'horizontal' ? 'flex flex-row' : 'flex flex-col') : 'flex'
|
||||
}`}
|
||||
>
|
||||
{panes.map((pane, index) => (
|
||||
<div
|
||||
key={pane.id}
|
||||
className={`relative min-h-0 min-w-0 ${maximizedPaneId && maximizedPaneId !== pane.id ? 'hidden' : ''}`}
|
||||
className={`relative min-h-0 min-w-0 ${
|
||||
maximizedPaneId && maximizedPaneId !== pane.id ? 'hidden' : ''
|
||||
} ${!layoutSplit && index > 0 ? 'hidden' : ''}`}
|
||||
style={paneStyle(index, pane.id)}
|
||||
>
|
||||
<PaneTerminal
|
||||
@@ -1291,7 +1324,7 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
onReopen={() => void reopenPaneShell(pane.id)}
|
||||
/>
|
||||
)}
|
||||
{multi && panes.length === 2 && index === 0 && (
|
||||
{layoutSplit && panes.length === 2 && index === 0 && (
|
||||
<div
|
||||
onMouseDown={onDividerMouseDown}
|
||||
className={
|
||||
@@ -1309,10 +1342,9 @@ export default function TerminalWorkspace({ tabId, isActive = true }: TerminalVi
|
||||
connectionState={connectionState}
|
||||
statusMessage={statusMessage}
|
||||
showSuspendedOverlay={showSuspendedOverlay}
|
||||
needsReconnect={
|
||||
!!currentTab?.needsReconnect || (connectionState === 'disconnected' && !!hasConnectedRef.current)
|
||||
}
|
||||
needsReconnect={needsReconnect}
|
||||
reconnectAttempt={reconnectAttempt}
|
||||
disconnectKind={disconnectKind}
|
||||
isLocal={isLocal}
|
||||
onReconnect={() => {
|
||||
const count = Math.min(MAX_PANES, Math.max(1, panes.length))
|
||||
|
||||
Vendored
+3
-3
@@ -18,7 +18,7 @@ export interface DesktopApiContract {
|
||||
) => Promise<import('../shared/types/ipc.types').SSHConnectResponse>
|
||||
sshDisconnect: (
|
||||
connectionId: string,
|
||||
options?: { retainForwards?: boolean }
|
||||
options?: { retainForwards?: boolean; reason?: string }
|
||||
) => Promise<{ success: boolean; error?: string }>
|
||||
sshExec: (
|
||||
connectionId: string,
|
||||
@@ -27,7 +27,7 @@ export interface DesktopApiContract {
|
||||
) => Promise<{ success: boolean; result?: string; exitCode?: number; error?: string }>
|
||||
sshExecCancel: (connectionId: string) => Promise<{ success: boolean; error?: string }>
|
||||
sshHostMetrics: (connectionId: string) => Promise<import('../shared/types/host-metrics.types').HostMetricsResponse>
|
||||
sshKeepalive: (connectionId: string) => Promise<{ success: boolean; error?: string }>
|
||||
sshKeepalive: (connectionId: string) => Promise<{ success: boolean; error?: string; errorCode?: string }>
|
||||
sshCreateShell: (
|
||||
connectionId: string,
|
||||
options?: import('../shared/types/ipc.types').SSHCreateShellRequest,
|
||||
@@ -81,7 +81,7 @@ export interface DesktopApiContract {
|
||||
localPort: number,
|
||||
forwardId?: string
|
||||
) => Promise<import('../shared/types/ipc.types').SSHForwardResponse>
|
||||
sshForwardStop: (forwardId: string) => Promise<{ success: boolean; error?: string }>
|
||||
sshForwardStop: (forwardId: string) => Promise<import('../shared/types/ipc.types').SSHForwardResponse>
|
||||
sshForwardList: (connectionId?: string) => Promise<import('../shared/types/ipc.types').SSHForwardListResponse>
|
||||
|
||||
// Sessions
|
||||
|
||||
+101
-47
@@ -22,6 +22,8 @@ import {
|
||||
import { joinLocalPath } from '../utils/localPath'
|
||||
import i18n from '../i18n'
|
||||
import { translateErrorMessage } from '../i18n/format'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { classifyDisconnectReason, reconnectDelayMs, shouldAutoReconnect } from '../utils/disconnectReason'
|
||||
|
||||
const logger = createLogger('useSFTP')
|
||||
|
||||
@@ -83,6 +85,7 @@ function localEntryType(entry: LocalDirEntry): FileItem['type'] {
|
||||
|
||||
export interface UseSFTPOptions {
|
||||
tabId: string
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
export interface UploadSource {
|
||||
@@ -191,7 +194,7 @@ interface QueuedTransferJob {
|
||||
total: number
|
||||
}
|
||||
|
||||
export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
export function useSFTP({ tabId, sessionId }: UseSFTPOptions): UseSFTPReturn {
|
||||
const { sessions } = useSessionStore()
|
||||
const { updateTabConnectionInstance, updateTab } = useTabStore()
|
||||
const { createConnection } = useConnectionPool()
|
||||
@@ -224,9 +227,16 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
/** Last successfully listed remote directory — used to resume after reconnect. */
|
||||
const lastBrowsePathRef = useRef<string | null>(null)
|
||||
const remoteHistoryRef = useRef<string[]>(['/'])
|
||||
const sessionIdRef = useRef<string | undefined>(sessionId)
|
||||
const connectRef = useRef<(id: string) => Promise<void>>(async () => {})
|
||||
const intentionalDisconnectRef = useRef(false)
|
||||
const reconnectAttemptRef = useRef(0)
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const connectingGuardRef = useRef(false)
|
||||
connectionIdRef.current = connectionId
|
||||
sftpIdRef.current = sftpId
|
||||
remoteHistoryRef.current = remoteHistory
|
||||
sessionIdRef.current = sessionId
|
||||
|
||||
// Transfer state — serial pump avoids concurrent upload+download locking the same SFTP Mutex.
|
||||
const [transferQueue, setTransferQueue] = useState<TransferTask[]>([])
|
||||
@@ -487,7 +497,9 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
// Connect to SFTP
|
||||
const connect = useCallback(
|
||||
async (sessionId: string) => {
|
||||
logger.info('SFTP CONNECT', { sessionId })
|
||||
if (connectingGuardRef.current) return
|
||||
connectingGuardRef.current = true
|
||||
intentionalDisconnectRef.current = false
|
||||
|
||||
if (!sessionId) {
|
||||
logger.error('No sessionId provided')
|
||||
@@ -535,6 +547,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
|
||||
setSftpId(sftpResult.sftpId)
|
||||
setIsConnected(true)
|
||||
reconnectAttemptRef.current = 0
|
||||
logger.info(`SFTP connected: ${sftpResult.sftpId}`)
|
||||
|
||||
getConnectionStore().addSFTP(connId, sftpResult.sftpId)
|
||||
@@ -551,11 +564,9 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
// Resume last browse path after reconnect; first connect prefers home then /.
|
||||
const homePreferred = config.username === 'root' ? '/root' : `/home/${config.username}`
|
||||
const resumePath = lastBrowsePathRef.current
|
||||
const tryPaths = [
|
||||
...(resumePath ? [resumePath] : []),
|
||||
homePreferred,
|
||||
'/',
|
||||
].filter((path, index, all) => path && all.indexOf(path) === index)
|
||||
const tryPaths = [...(resumePath ? [resumePath] : []), homePreferred, '/'].filter(
|
||||
(path, index, all) => path && all.indexOf(path) === index
|
||||
)
|
||||
|
||||
let startPath = '/'
|
||||
for (const path of tryPaths) {
|
||||
@@ -589,14 +600,21 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
logger.error('SFTP connection failed', err)
|
||||
setError(i18n.t('errors:connection.failed'))
|
||||
} finally {
|
||||
connectingGuardRef.current = false
|
||||
setIsConnecting(false)
|
||||
}
|
||||
},
|
||||
[tabId, createConnection, loadRemoteDirectory, updateTabConnectionInstance, updateTab]
|
||||
)
|
||||
connectRef.current = connect
|
||||
|
||||
// Disconnect
|
||||
const disconnect = useCallback(async () => {
|
||||
intentionalDisconnectRef.current = true
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current)
|
||||
reconnectTimerRef.current = null
|
||||
}
|
||||
if (sftpId && connectionId) {
|
||||
getConnectionStore().removeSFTP(connectionId, sftpId)
|
||||
}
|
||||
@@ -822,9 +840,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
} else {
|
||||
const message =
|
||||
translateErrorMessage(result.error) ||
|
||||
(job.type === 'upload'
|
||||
? i18n.t('sftp:errors.uploadFailed')
|
||||
: i18n.t('sftp:errors.downloadFailed'))
|
||||
(job.type === 'upload' ? i18n.t('sftp:errors.uploadFailed') : i18n.t('sftp:errors.downloadFailed'))
|
||||
patchTransfer(job.id, { status: 'failed', error: message })
|
||||
transferResultsRef.current.set(job.id, {
|
||||
status: 'failed',
|
||||
@@ -841,9 +857,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
} else {
|
||||
const message =
|
||||
translateErrorMessage(raw) ||
|
||||
(job.type === 'upload'
|
||||
? i18n.t('sftp:errors.uploadFailed')
|
||||
: i18n.t('sftp:errors.downloadFailed'))
|
||||
(job.type === 'upload' ? i18n.t('sftp:errors.uploadFailed') : i18n.t('sftp:errors.downloadFailed'))
|
||||
patchTransfer(job.id, { status: 'failed', error: message })
|
||||
transferResultsRef.current.set(job.id, {
|
||||
status: 'failed',
|
||||
@@ -889,33 +903,30 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
[runTransferPump]
|
||||
)
|
||||
|
||||
const cancelTransfer = useCallback(
|
||||
(taskId: string) => {
|
||||
const isActive = activeTransferIdRef.current === taskId
|
||||
cancelledTransferIdsRef.current.add(taskId)
|
||||
// Drop pending jobs immediately so the pump never starts them.
|
||||
transferJobsRef.current = transferJobsRef.current.filter((job) => job.id !== taskId)
|
||||
setTransferQueue((prev) => {
|
||||
const next = prev.map((task) =>
|
||||
task.id === taskId && (task.status === 'pending' || task.status === 'transferring')
|
||||
? { ...task, status: 'canceled' as const }
|
||||
: task
|
||||
)
|
||||
transferQueueRef.current = next
|
||||
return next
|
||||
const cancelTransfer = useCallback((taskId: string) => {
|
||||
const isActive = activeTransferIdRef.current === taskId
|
||||
cancelledTransferIdsRef.current.add(taskId)
|
||||
// Drop pending jobs immediately so the pump never starts them.
|
||||
transferJobsRef.current = transferJobsRef.current.filter((job) => job.id !== taskId)
|
||||
setTransferQueue((prev) => {
|
||||
const next = prev.map((task) =>
|
||||
task.id === taskId && (task.status === 'pending' || task.status === 'transferring')
|
||||
? { ...task, status: 'canceled' as const }
|
||||
: task
|
||||
)
|
||||
transferQueueRef.current = next
|
||||
return next
|
||||
})
|
||||
if (!isActive) {
|
||||
transferResultsRef.current.set(taskId, { status: 'canceled' })
|
||||
cancelledTransferIdsRef.current.delete(taskId)
|
||||
}
|
||||
if (isActive && sftpIdRef.current) {
|
||||
void cancelSftpTransfer(sftpIdRef.current).catch((err) => {
|
||||
logger.warn('cancelSftpTransfer failed', err)
|
||||
})
|
||||
if (!isActive) {
|
||||
transferResultsRef.current.set(taskId, { status: 'canceled' })
|
||||
cancelledTransferIdsRef.current.delete(taskId)
|
||||
}
|
||||
if (isActive && sftpIdRef.current) {
|
||||
void cancelSftpTransfer(sftpIdRef.current).catch((err) => {
|
||||
logger.warn('cancelSftpTransfer failed', err)
|
||||
})
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const retryTransfer = useCallback(
|
||||
(taskId: string) => {
|
||||
@@ -975,9 +986,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
return {
|
||||
completed,
|
||||
failed: failedTasks.length,
|
||||
errors: failedTasks.map((result) =>
|
||||
result.error || i18n.t('sftp:errors.uploadFailed')
|
||||
),
|
||||
errors: failedTasks.map((result) => result.error || i18n.t('sftp:errors.uploadFailed')),
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 40))
|
||||
@@ -1006,8 +1015,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
stats?: { type?: string; isDirectory?: boolean; size?: number }
|
||||
}
|
||||
if (statsResult.success && statsResult.stats) {
|
||||
isDirectory =
|
||||
statsResult.stats.type === 'directory' || Boolean(statsResult.stats.isDirectory)
|
||||
isDirectory = statsResult.stats.type === 'directory' || Boolean(statsResult.stats.isDirectory)
|
||||
total = isDirectory ? 0 : statsResult.stats.size || 0
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
@@ -1237,9 +1245,9 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
// Do NOT release/disconnect on React unmount — StrictMode remounts would
|
||||
// kill a healthy SFTP session; tab close goes through disposeTabResources.
|
||||
useEffect(() => {
|
||||
const onClosed = (_event: unknown, closedId: string) => {
|
||||
const onClosed = (_event: unknown, closedId: string, reason?: string) => {
|
||||
if (!closedId || closedId !== connectionIdRef.current) return
|
||||
logger.warn('SFTP underlying SSH connection closed', { connectionId: closedId })
|
||||
logger.warn('SFTP underlying SSH connection closed', { connectionId: closedId, reason })
|
||||
const sid = sftpIdRef.current
|
||||
if (sid) {
|
||||
getConnectionStore().removeSFTP(closedId, sid)
|
||||
@@ -1248,7 +1256,7 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
setSftpId(null)
|
||||
setConnectionId(null)
|
||||
setRemoteFiles([])
|
||||
const closedMsg = i18n.t('errors:connection.closed')
|
||||
const closedMsg = translateErrorMessage(reason) || i18n.t('errors:connection.unexpectedDropped')
|
||||
setError(closedMsg)
|
||||
// Keep lastBrowsePathRef so reconnect can resume the directory.
|
||||
// Fail in-flight queue items so the user can retry after reconnect.
|
||||
@@ -1268,10 +1276,56 @@ export function useSFTP({ tabId }: UseSFTPOptions): UseSFTPReturn {
|
||||
return next
|
||||
})
|
||||
updateTab(tabId, { connectionInstance: undefined, connectionStatus: 'disconnected' })
|
||||
|
||||
const resumeSessionId = sessionIdRef.current
|
||||
const { settings } = useSettingsStore.getState()
|
||||
const kind = classifyDisconnectReason(reason)
|
||||
if (
|
||||
!resumeSessionId ||
|
||||
intentionalDisconnectRef.current ||
|
||||
!settings.autoReconnect ||
|
||||
!shouldAutoReconnect(kind)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const maxAttempts = settings.maxReconnectAttempts ?? 5
|
||||
const baseDelay = settings.reconnectBaseDelayMs ?? 1500
|
||||
if (reconnectAttemptRef.current >= maxAttempts) {
|
||||
setError(
|
||||
i18n.t('terminal:connection.autoReconnectLimit', {
|
||||
max: maxAttempts,
|
||||
reasonSuffix: i18n.t('terminal:connection.autoReconnectLimitReason', {
|
||||
reason: closedMsg,
|
||||
}),
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
reconnectAttemptRef.current += 1
|
||||
const attempt = reconnectAttemptRef.current
|
||||
const delay = reconnectDelayMs(attempt, baseDelay)
|
||||
setIsConnecting(true)
|
||||
setError(
|
||||
i18n.t('sftp:connection.autoReconnecting', {
|
||||
attempt,
|
||||
max: maxAttempts,
|
||||
})
|
||||
)
|
||||
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current)
|
||||
reconnectTimerRef.current = setTimeout(() => {
|
||||
reconnectTimerRef.current = null
|
||||
void connectRef.current?.(resumeSessionId)
|
||||
}, delay)
|
||||
}
|
||||
window.opsxApi?.onConnectionClosed?.(onClosed)
|
||||
return () => {
|
||||
window.opsxApi?.removeListener?.('ssh:connection-closed', onClosed as (...args: unknown[]) => void)
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current)
|
||||
reconnectTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [tabId, updateTab])
|
||||
|
||||
|
||||
@@ -12,7 +12,14 @@ import { createLogger } from '../utils/logger'
|
||||
import { detectAndPersistSessionDistro } from '../utils/detectSessionDistro'
|
||||
import i18n from '../i18n'
|
||||
import { translateErrorMessage } from '../i18n/format'
|
||||
import {
|
||||
classifyDisconnectReason,
|
||||
reconnectDelayMs,
|
||||
shouldAutoReconnect,
|
||||
type DisconnectKind,
|
||||
} from '../utils/disconnectReason'
|
||||
import { isDefaultTerminalTitle } from '../utils/tabTitle'
|
||||
import { subscribeTabReconnect } from '../utils/tabReconnect'
|
||||
import type { SSHConnectionConfig } from '../../shared/types/session.types'
|
||||
|
||||
const logger = createLogger('useTerminalConnection')
|
||||
@@ -37,6 +44,7 @@ export interface UseTerminalConnectionReturn {
|
||||
statusMessage: string
|
||||
showSuspendedOverlay: boolean
|
||||
reconnectAttempt: number
|
||||
disconnectKind: DisconnectKind
|
||||
connectionIdRef: React.MutableRefObject<string | null>
|
||||
shellIdRef: React.MutableRefObject<string | null>
|
||||
hasConnectedRef: React.MutableRefObject<boolean>
|
||||
@@ -63,6 +71,7 @@ export function useTerminalConnection({ tabId, xtermRef }: UseTerminalConnection
|
||||
const [statusMessage, setStatusMessage] = useState<string>('')
|
||||
const [showSuspendedOverlay, setShowSuspendedOverlay] = useState(false)
|
||||
const [reconnectAttempt, setReconnectAttempt] = useState(0)
|
||||
const [disconnectKind, setDisconnectKind] = useState<DisconnectKind>('unexpected')
|
||||
|
||||
const { updateTab, updateTabConnectionInstance, updateTabStatus, getTabById } = useTabStore()
|
||||
|
||||
@@ -370,12 +379,19 @@ export function useTerminalConnection({ tabId, xtermRef }: UseTerminalConnection
|
||||
|
||||
const maxAttempts = settings.maxReconnectAttempts ?? 5
|
||||
const baseDelay = settings.reconnectBaseDelayMs ?? 1500
|
||||
const kind = classifyDisconnectReason(reason)
|
||||
setDisconnectKind(kind)
|
||||
if (!shouldAutoReconnect(kind)) return
|
||||
|
||||
if (reconnectAttemptRef.current >= maxAttempts) {
|
||||
setStatusMessage(
|
||||
i18n.t('terminal:connection.autoReconnectLimit', {
|
||||
max: maxAttempts,
|
||||
reasonSuffix: reason ? i18n.t('terminal:connection.autoReconnectLimitReason', { reason }) : '',
|
||||
reasonSuffix: reason
|
||||
? i18n.t('terminal:connection.autoReconnectLimitReason', {
|
||||
reason: translateErrorMessage(reason),
|
||||
})
|
||||
: '',
|
||||
})
|
||||
)
|
||||
setConnectionState('error')
|
||||
@@ -384,7 +400,7 @@ export function useTerminalConnection({ tabId, xtermRef }: UseTerminalConnection
|
||||
|
||||
reconnectAttemptRef.current += 1
|
||||
setReconnectAttempt(reconnectAttemptRef.current)
|
||||
const delay = Math.min(baseDelay * Math.pow(1.6, reconnectAttemptRef.current - 1), 30000)
|
||||
const delay = reconnectDelayMs(reconnectAttemptRef.current, baseDelay)
|
||||
const seconds = Math.round(delay / 1000)
|
||||
const attempt = reconnectAttemptRef.current
|
||||
|
||||
@@ -430,12 +446,24 @@ export function useTerminalConnection({ tabId, xtermRef }: UseTerminalConnection
|
||||
if (!connectionIdRef.current || closedId !== connectionIdRef.current) return
|
||||
if (intentionalDisconnectRef.current) return
|
||||
|
||||
const kind = classifyDisconnectReason(reason)
|
||||
if (kind === 'idle-suspend') {
|
||||
setDisconnectKind(kind)
|
||||
setShowSuspendedOverlay(true)
|
||||
setConnectionState('suspended')
|
||||
updateTabStatus(tabId, 'disconnected')
|
||||
return
|
||||
}
|
||||
|
||||
logger.info('[Terminal] Connection closed unexpectedly', closedId, reason)
|
||||
connectionIdRef.current = null
|
||||
shellIdRef.current = null
|
||||
setDisconnectKind(kind)
|
||||
setConnectionState('disconnected')
|
||||
updateTabStatus(tabId, 'disconnected')
|
||||
xtermRef.current?.writeln(`\r\n\x1b[33m${i18n.t('terminal:connection.disconnectedBanner', { reason })}\x1b[0m`)
|
||||
xtermRef.current?.writeln(
|
||||
`\r\n\x1b[33m${i18n.t('terminal:connection.disconnectedBanner', { reason: translateErrorMessage(reason) })}\x1b[0m`
|
||||
)
|
||||
scheduleAutoReconnectRef.current?.(reason)
|
||||
}
|
||||
|
||||
@@ -501,6 +529,7 @@ export function useTerminalConnection({ tabId, xtermRef }: UseTerminalConnection
|
||||
clearReconnectTimer()
|
||||
reconnectAttemptRef.current = 0
|
||||
setReconnectAttempt(0)
|
||||
setDisconnectKind('unexpected')
|
||||
intentionalDisconnectRef.current = false
|
||||
|
||||
const tab = getTabById(tabId)
|
||||
@@ -525,11 +554,14 @@ export function useTerminalConnection({ tabId, xtermRef }: UseTerminalConnection
|
||||
connect()
|
||||
}, [connect, updateTab, tabId, xtermRef, clearReconnectTimer, getTabById])
|
||||
|
||||
useEffect(() => subscribeTabReconnect(tabId, () => handleReconnect()), [tabId, handleReconnect])
|
||||
|
||||
return {
|
||||
connectionState,
|
||||
statusMessage,
|
||||
showSuspendedOverlay,
|
||||
reconnectAttempt,
|
||||
disconnectKind,
|
||||
connectionIdRef,
|
||||
shellIdRef,
|
||||
hasConnectedRef,
|
||||
|
||||
+51
-20
@@ -4,21 +4,37 @@ import { intlLocale } from './detectLocale'
|
||||
/** Map stable backend / FE error phrases to i18n keys; fall back to raw text. */
|
||||
const ERROR_PHRASE_MAP: Array<{ match: RegExp | string; key: string }> = [
|
||||
{ match: /连接失败,请检查网络|connection failed/i, key: 'errors:connection.failed' },
|
||||
{ match: /timed out waiting for input|TMOUT|auto-logout/i, key: 'errors:connection.idleKicked' },
|
||||
{ match: /^shell\.idleTimeout$|timed out waiting for input|TMOUT|auto-logout/i, key: 'errors:connection.idleKicked' },
|
||||
{ match: /^shell\.exited$/i, key: 'errors:connection.shellExited' },
|
||||
{ match: /^shell\.readFailed|远端 Shell 读取失败/i, key: 'errors:connection.shellReadFailed' },
|
||||
{ match: /连接超时|timed?\s*out/i, key: 'errors:connection.timeout' },
|
||||
{ match: /认证失败|auth(entication)? failed/i, key: 'errors:auth.failed' },
|
||||
{ match: /主机不可达|unreachable/i, key: 'errors:connection.hostUnreachable' },
|
||||
{ match: /保活失败|keepalive failed/i, key: 'errors:connection.keepaliveFailed' },
|
||||
{ match: /连接已关闭|connection closed/i, key: 'errors:connection.closed' },
|
||||
{ match: /closed by user|connection closed/i, key: 'errors:connection.unexpectedDropped' },
|
||||
{ match: /连接已关闭/i, key: 'errors:connection.closed' },
|
||||
{ match: /终端创建失败/i, key: 'errors:shell.createFailed' },
|
||||
{ match: /命令执行失败/i, key: 'errors:shell.execFailed' },
|
||||
{ match: /目标地址不能是 0\.0\.0\.0|目标不能是 0\.0\.0\.0/i, key: 'errors:forward.invalidTargetHost' },
|
||||
{ match: /建立远程端口转发失败: 远端拒绝监听特权端口|建立远程端口转发失败: 远端拒绝特权端口/i, key: 'errors:forward.remoteDeniedPrivileged' },
|
||||
{ match: /建立远程端口转发失败: 远端拒绝|Unable to complete request for forward-listen|REQUEST_DENIED|Session\(-32\)/i, key: 'errors:forward.remoteDenied' },
|
||||
{
|
||||
match: /建立远程端口转发失败: 远端拒绝监听特权端口|建立远程端口转发失败: 远端拒绝特权端口/i,
|
||||
key: 'errors:forward.remoteDeniedPrivileged',
|
||||
},
|
||||
{
|
||||
match:
|
||||
/建立远程端口转发失败: 远端拒绝|Unable to complete request for forward-listen|REQUEST_DENIED|Session\(-32\)/i,
|
||||
key: 'errors:forward.remoteDenied',
|
||||
},
|
||||
{ match: /建立远程端口转发失败|端口转发设置失败/i, key: 'errors:forward.failed' },
|
||||
{ match: /Address already in use|os error 48|EADDRINUSE|监听本地端口失败|本地端口已被占用/i, key: 'errors:forward.alreadyInUse' },
|
||||
{
|
||||
match: /Address already in use|os error 48|EADDRINUSE|监听本地端口失败|本地端口已被占用/i,
|
||||
key: 'errors:forward.alreadyInUse',
|
||||
},
|
||||
{ match: /SFTP 正在传输文件|transfer is in progress/i, key: 'errors:sftp.busy' },
|
||||
{ match: /Unable to startup channel|创建 SFTP 通道失败|MaxSessions|channel failure/i, key: 'errors:sftp.channelFailed' },
|
||||
{
|
||||
match: /Unable to startup channel|创建 SFTP 通道失败|MaxSessions|channel failure/i,
|
||||
key: 'errors:sftp.channelFailed',
|
||||
},
|
||||
{ match: /SFTP 会话不存在|SSH 连接不存在/i, key: 'errors:sftp.sessionGone' },
|
||||
{ match: /文件过大|编辑上限约 2MB|too large|edit limit/i, key: 'errors:sftp.editTooLarge' },
|
||||
{ match: /像是二进制|binary content/i, key: 'errors:sftp.editBinary' },
|
||||
@@ -36,38 +52,53 @@ const ERROR_PHRASE_MAP: Array<{ match: RegExp | string; key: string }> = [
|
||||
{ match: /主机地址格式/i, key: 'errors:validation.invalidHost' },
|
||||
{ match: /端口号必须/i, key: 'errors:validation.invalidPort' },
|
||||
{ match: /凭据格式/i, key: 'errors:validation.invalidCredentials' },
|
||||
{ match: /命令已终止|command (was )?(cancelled|canceled)|command terminated/i, key: 'ai:step.terminated' },
|
||||
{ match: /剪贴板/i, key: 'errors:clipboard.readFailed' },
|
||||
]
|
||||
|
||||
function lookupErrorCode(code: string): string | null {
|
||||
const coded = i18n.t(`errors:${code}`)
|
||||
if (coded && coded !== `errors:${code}` && coded !== code) return coded
|
||||
return null
|
||||
}
|
||||
|
||||
export function translateErrorMessage(raw: string | undefined | null): string {
|
||||
const text = (raw || '').trim()
|
||||
if (!text) return i18n.t('errors:unknown.error')
|
||||
if (text.startsWith('native.')) {
|
||||
const coded = lookupErrorCode(text)
|
||||
if (coded) return coded
|
||||
}
|
||||
for (const entry of ERROR_PHRASE_MAP) {
|
||||
const hit =
|
||||
typeof entry.match === 'string' ? text.includes(entry.match) : entry.match.test(text)
|
||||
const hit = typeof entry.match === 'string' ? text.includes(entry.match) : entry.match.test(text)
|
||||
if (hit) return i18n.t(entry.key)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
export function formatLocaleDate(
|
||||
date: Date,
|
||||
options?: Intl.DateTimeFormatOptions
|
||||
): string {
|
||||
/** Prefer a stable `errorCode` from a command envelope, then fall back to phrase matching. */
|
||||
export function translateIpcError(error?: string | null, errorCode?: string | null): string {
|
||||
if (errorCode) {
|
||||
const coded = lookupErrorCode(errorCode)
|
||||
if (coded) return coded
|
||||
}
|
||||
return translateErrorMessage(error)
|
||||
}
|
||||
|
||||
/** Native `ssh_exec` cancel from Agent Stop. Backend still returns a Chinese phrase. */
|
||||
export function isSshExecCancelled(error?: string | null): boolean {
|
||||
return /已终止|command (was )?(cancelled|canceled)|command terminated/i.test(error || '')
|
||||
}
|
||||
|
||||
export function formatLocaleDate(date: Date, options?: Intl.DateTimeFormatOptions): string {
|
||||
return date.toLocaleDateString(intlLocale(i18n.language), options)
|
||||
}
|
||||
|
||||
export function formatLocaleTime(
|
||||
date: Date,
|
||||
options?: Intl.DateTimeFormatOptions
|
||||
): string {
|
||||
export function formatLocaleTime(date: Date, options?: Intl.DateTimeFormatOptions): string {
|
||||
return date.toLocaleTimeString(intlLocale(i18n.language), options)
|
||||
}
|
||||
|
||||
export function formatLocaleDateTime(
|
||||
date: Date,
|
||||
options?: Intl.DateTimeFormatOptions
|
||||
): string {
|
||||
export function formatLocaleDateTime(date: Date, options?: Intl.DateTimeFormatOptions): string {
|
||||
return date.toLocaleString(intlLocale(i18n.language), options)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,10 +16,10 @@
|
||||
"stop": "Stop",
|
||||
"send": "Send",
|
||||
"placeholder": "Task or follow-up… (Enter to send, Shift+Enter for newline)",
|
||||
"hintFollowUp": "Follow-ups include this thread's context",
|
||||
"hintFollowUp": "Follow-ups include a truncated summary of this thread",
|
||||
"hintSelection": "Will include {{lines}} selected lines",
|
||||
"hintSteps": "Up to {{max}} steps · nothing persisted",
|
||||
"footerNote": "High risk requires confirmation · critical is blocked · session not saved",
|
||||
"hintSteps": "Up to {{max}} steps",
|
||||
"footerNote": "High-risk commands need confirmation · destructive ones are blocked · chat is not saved",
|
||||
"summarize": "Summarize",
|
||||
"summarizeTitle": "Summarize the visible terminal output with AI (selection first); never runs commands",
|
||||
"summarizeRequest": "Summarize the current terminal output"
|
||||
@@ -77,7 +77,7 @@
|
||||
"invalidCommand": "Model did not return a single one-line command; execution refused",
|
||||
"repeatLoop": "Repeated command loop detected; stopped: {{command}}",
|
||||
"criticalBlocked": "Command hit a destructive hard block; no override: {{command}}",
|
||||
"execFailed": "ssh-exec failed: {{error}}",
|
||||
"execFailed": "Remote execution failed: {{error}}",
|
||||
"execUnknown": "Unknown error",
|
||||
"execNoOutput": "Command finished with no output",
|
||||
"maxSteps": "Reached {{max}}-step limit; narrow the scope and retry",
|
||||
|
||||
@@ -33,8 +33,10 @@
|
||||
"error": {
|
||||
"title": "Something went wrong",
|
||||
"body": "The app hit an unexpected problem",
|
||||
"bodyPane": "This tab hit an unexpected problem. Other tabs still work.",
|
||||
"recoverable": "This error is recoverable. Try refreshing the page.",
|
||||
"refreshPage": "Refresh page",
|
||||
"retryPane": "Retry this tab",
|
||||
"restartApp": "Restart app",
|
||||
"supportHint": "If the problem persists, check logs or contact support"
|
||||
},
|
||||
@@ -104,11 +106,12 @@
|
||||
},
|
||||
"menu": {
|
||||
"closeOthers": "Close others",
|
||||
"closeRight": "Close tabs to the right",
|
||||
"closeLeft": "Close tabs to the left",
|
||||
"closeRight": "Close right tabs",
|
||||
"closeLeft": "Close left tabs",
|
||||
"closeAll": "Close all",
|
||||
"clone": "Duplicate tab",
|
||||
"openSftp": "Open SFTP"
|
||||
"clone": "Clone tab",
|
||||
"openSftp": "Open SFTP",
|
||||
"reconnect": "Reconnect"
|
||||
},
|
||||
"renameAria": "Rename tab {{title}}",
|
||||
"renameHint": "Double-click to rename"
|
||||
|
||||
@@ -35,13 +35,17 @@
|
||||
"fullscreenAria": "Enter fullscreen",
|
||||
"exitFullscreen": "Exit fullscreen",
|
||||
"exitFullscreenAria": "Exit fullscreen",
|
||||
"exitFullscreenHint": "Esc / F11 to exit"
|
||||
"exitFullscreenHint": "Esc / F11 to exit",
|
||||
"reconnect": "Reconnect",
|
||||
"reconnectAria": "Reconnect remote desktop"
|
||||
},
|
||||
"status": {
|
||||
"connecting": "Connecting…",
|
||||
"connected": "Connected",
|
||||
"disconnected": "Disconnected",
|
||||
"connectFailed": "Connection failed",
|
||||
"reconnecting": "Auto-reconnecting ({{attempt}}/{{max}})…",
|
||||
"reconnectLimit": "Auto-reconnect limit reached ({{max}})",
|
||||
"clipboardSynced": "Remote clipboard synced",
|
||||
"savedToDownloads": "Saved to local Downloads: {{name}}",
|
||||
"pastedToRemote": "Pasted to remote",
|
||||
|
||||
@@ -41,7 +41,10 @@
|
||||
"refused": "Connection refused",
|
||||
"disconnected": "Disconnected",
|
||||
"keepaliveFailed": "SSH keep-alive failed repeatedly; connection closed",
|
||||
"idleKicked": "Remote shell logged out due to idle timeout (TMOUT)"
|
||||
"idleKicked": "Remote shell logged out due to idle timeout (TMOUT)",
|
||||
"unexpectedDropped": "Disconnected (network drop or remote close)",
|
||||
"shellExited": "Remote shell exited (exit, Ctrl+D, or server policy)",
|
||||
"shellReadFailed": "Failed to read from the remote shell"
|
||||
},
|
||||
"auth": {
|
||||
"failed": "Authentication failed. Check the username and password.",
|
||||
@@ -160,7 +163,7 @@
|
||||
"invalidCommand": "Model did not return a single one-line command; execution refused",
|
||||
"repeatLoop": "Repeated command loop detected; stopped: {{command}}",
|
||||
"criticalBlocked": "Command hit a destructive hard block; no override: {{command}}",
|
||||
"execFailed": "ssh-exec failed: {{error}}",
|
||||
"execFailed": "Remote execution failed: {{error}}",
|
||||
"maxSteps": "Reached {{max}}-step limit; narrow the scope and retry",
|
||||
"sshRequired": "A connected remote SSH session is required"
|
||||
},
|
||||
@@ -168,6 +171,12 @@
|
||||
"readFailed": "Read failed: {{name}}",
|
||||
"writeFailed": "Write failed: {{name}}"
|
||||
},
|
||||
"native": {
|
||||
"taskJoin": "A background task failed. Retry the operation.",
|
||||
"invalidBase64": "File data is not valid Base64.",
|
||||
"knownHostsPath": "Could not locate ~/.ssh/known_hosts.",
|
||||
"invalidHostKeyDecision": "Invalid host key decision."
|
||||
},
|
||||
"unknown": {
|
||||
"error": "An unknown error occurred"
|
||||
}
|
||||
|
||||
@@ -132,7 +132,8 @@
|
||||
"collapse": "Collapse",
|
||||
"protocol": "Protocol",
|
||||
"displayName": "Display name (optional)",
|
||||
"displayNamePlaceholder": "Defaults to user@host or vnc:host",
|
||||
"displayNamePlaceholder": "Defaults to user@host",
|
||||
"displayNamePlaceholderVnc": "Defaults to the host name",
|
||||
"host": "Host",
|
||||
"hostPlaceholder": "192.168.1.10",
|
||||
"hostHint": "IP or hostname; host:port is also accepted",
|
||||
@@ -208,7 +209,8 @@
|
||||
},
|
||||
"importExport": {
|
||||
"exportTitle": "Export sessions",
|
||||
"exportDefaultPath": "opsxshell-sessions.bak",
|
||||
"exportDefaultPath": "opsxshell-hosts.oxs",
|
||||
"exportFilterOxs": "OpsXShell host backup",
|
||||
"exportFilterBak": "OpsXShell backup",
|
||||
"exportFilterJson": "JSON",
|
||||
"exportSuccess": "Exported {{count}} sessions to:\n{{path}}",
|
||||
@@ -224,6 +226,7 @@
|
||||
"exportPasswordMismatch": "Passwords do not match",
|
||||
"exportPasswordRequired": "A backup password is required to export",
|
||||
"importTitle": "Import sessions",
|
||||
"importFilterOxs": "OpsXShell host backup",
|
||||
"importFilterBak": "OpsXShell backup",
|
||||
"importFilterJson": "JSON",
|
||||
"importFilterSshConfig": "SSH Config",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"ai": "AI models",
|
||||
"aiDesc": "AI providers",
|
||||
"about": "About",
|
||||
"aboutDesc": "Version and data",
|
||||
"aboutDesc": "Version, contact, and diagnostics",
|
||||
"account": "Account",
|
||||
"accountDesc": "Email, license, and device"
|
||||
},
|
||||
@@ -89,22 +89,23 @@
|
||||
"ssh": {
|
||||
"connection": "Connection",
|
||||
"keepAlive": "Keep-alive",
|
||||
"keepAliveDesc": "Send SSH keep-alive while idle; disconnect only after several missed replies",
|
||||
"keepAliveDesc": "Send SSH protocol keep-alive while idle (not typed into the shell). Beats NAT drops; does not bypass remote TMOUT",
|
||||
"autoReconnect": "Auto reconnect",
|
||||
"autoReconnectDesc": "Automatically retry after unexpected disconnects",
|
||||
"autoReconnectDesc": "Retry after unexpected disconnects while the tab stays open (new shell). Closing the tab, idle suspend, or remote TMOUT are not forced alive",
|
||||
"timeout": "Connection timeout",
|
||||
"timeoutDesc": "Maximum wait time when establishing SSH (seconds)",
|
||||
"maxReconnect": "Reconnect attempts",
|
||||
"maxReconnectDesc": "Maximum retries after unexpected disconnect",
|
||||
"timeoutDesc": "Maximum wait when establishing an SSH connection (seconds)",
|
||||
"maxReconnect": "Auto-reconnect attempts",
|
||||
"maxReconnectDesc": "Maximum retries after an unexpected disconnect",
|
||||
"pool": "Connection pool",
|
||||
"maxConnections": "Max connections",
|
||||
"maxConnectionsDesc": "Shared cap for SSH, SFTP, and batch connections",
|
||||
"maxConnections": "Maximum connections",
|
||||
"maxConnectionsDesc": "Shared by SSH, SFTP, and batch connections",
|
||||
"maxBackground": "Background connection limit",
|
||||
"maxBackgroundDesc": "Evict least recently used connections when over limit",
|
||||
"maxBackgroundDesc": "When exceeded, the least recently used connection is reclaimed first",
|
||||
"suspend": "Background suspend",
|
||||
"suspendDesc": "Minutes before idle background tabs suspend (active Shell/SFTP stay connected)",
|
||||
"suspendDesc": "Minutes before a background tab is suspended (kept alive while a shell or SFTP is active)",
|
||||
"keepAliveInterval": "Keep-alive interval (sec)",
|
||||
"keepAliveIntervalDesc": "How often to ping the server (like ClientAliveInterval). Use 15s or less on flaky Wi‑Fi / NAT"
|
||||
"keepAliveIntervalDesc": "How often to ping the server (like ClientAliveInterval). Use 15s or less on flaky Wi‑Fi / NAT",
|
||||
"sessionPolicyHint": "Keep-alive protects the TCP link, not remote session policy. Bash TMOUT, sshd ClientAlive, and Windows idle GPO still apply. Use tmux/screen on the server for long jobs."
|
||||
},
|
||||
"security": {
|
||||
"credentials": "Credential protection",
|
||||
@@ -122,16 +123,25 @@
|
||||
},
|
||||
"about": {
|
||||
"tagline": "Simple, practical native ops terminal",
|
||||
"appVersion": "App v{{version}}",
|
||||
"copyVersion": "Copy version info",
|
||||
"runtime": "Runtime",
|
||||
"system": "System",
|
||||
"localData": "Local data",
|
||||
"appVersion": "v{{version}}",
|
||||
"copyVersion": "Copy version, system, and paths for a GitHub issue",
|
||||
"copyDiagnostics": "Copy diagnostics",
|
||||
"copyPath": "Copy path",
|
||||
"resources": "Resources",
|
||||
"wechat": "WeChat",
|
||||
"copyWechat": "Copy WeChat ID",
|
||||
"github": "Discussions",
|
||||
"githubDesc": "https://github.com/L00J/opsxshell/discussions",
|
||||
"releases": "Releases",
|
||||
"releasesDesc": "Installers and release notes",
|
||||
"licenseLink": "License",
|
||||
"licenseLinkDesc": "MIT License",
|
||||
"diagnostics": "Diagnostics",
|
||||
"sessionsSettings": "Sessions and settings",
|
||||
"masterKeyPath": "Master key",
|
||||
"privacyNote": "Passwords and key passphrases are AES-256-GCM encrypted in Rust. No terminal history, no bastion audit trail.",
|
||||
"license": "MIT License · Rust · XTerm.js",
|
||||
"logPath": "Application log"
|
||||
"logPath": "Application log",
|
||||
"reportBug": "Report a bug",
|
||||
"reportBugDesc": "Opens GitHub with this version and platform filled in"
|
||||
},
|
||||
"ai": {
|
||||
"presets": "Provider presets",
|
||||
@@ -157,7 +167,9 @@
|
||||
"otherModels": "Other models…",
|
||||
"customModel": "Custom model",
|
||||
"backToList": "Back to list",
|
||||
"apiKey": "API Key",
|
||||
"apiKeyOptional": "API Key (not required)",
|
||||
"endpoint": "Endpoint",
|
||||
"advanced": "Advanced",
|
||||
"allowHttp": "Allow HTTP (localhost/LAN)",
|
||||
"setActive": "Set as active",
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"notConnected": "Not connected",
|
||||
"connecting": "Connecting...",
|
||||
"resumeHint": "Reconnect will resume at {{path}}",
|
||||
"reconnectingResume": "Reconnecting and resuming {{path}}…"
|
||||
"reconnectingResume": "Reconnecting and resuming {{path}}…",
|
||||
"autoReconnecting": "Auto-reconnecting ({{attempt}}/{{max}})…"
|
||||
},
|
||||
"errors": {
|
||||
"notConnected": "SFTP is not connected",
|
||||
|
||||
@@ -34,7 +34,9 @@
|
||||
"reopenShell": "Reopen shell",
|
||||
"reopening": "Opening…",
|
||||
"disconnected": "Disconnected",
|
||||
"disconnectedHint": "The SSH session ended or the network dropped. One click below reconnects so you can keep working.",
|
||||
"disconnectedHint": "The network dropped or the server closed SSH. The tab will auto-reconnect while it stays open, or reconnect now with the button below.",
|
||||
"disconnectedKeepalive": "Keep-alive failed; disconnected",
|
||||
"disconnectedKeepaliveHint": "SSH keep-alive got no reply — usually a NAT or idle network drop. No keystrokes are sent into the shell. The tab will auto-reconnect while it stays open.",
|
||||
"reconnect": "Reconnect",
|
||||
"suspended": "Connection suspended",
|
||||
"suspendedHint": "Inactive for a while; connection was suspended to save resources",
|
||||
|
||||
@@ -16,10 +16,10 @@
|
||||
"stop": "停止",
|
||||
"send": "送信",
|
||||
"placeholder": "タスクまたはフォローアップ… (Enter で送信 · Shift+Enter で改行)",
|
||||
"hintFollowUp": "フォローアップにはこのスレッドのコンテキストが含まれます",
|
||||
"hintFollowUp": "フォローアップにはこのスレッドの要約が含まれます(長い出力は切り詰めます)",
|
||||
"hintSelection": "選択した {{lines}} 行を含めます",
|
||||
"hintSteps": "最大 {{max}} ステップ · 何も保存しません",
|
||||
"footerNote": "高リスクは確認が必要 · クリティカルはブロック · セッションは保存されません",
|
||||
"hintSteps": "最大 {{max}} ステップ",
|
||||
"footerNote": "高リスクは先に確認 · 破壊的なコマンドは拒否 · 会話は保存しません",
|
||||
"summarize": "出力を要約",
|
||||
"summarizeTitle": "表示中の端末出力を AI が要約します(選択範囲を優先)。コマンドは実行しません",
|
||||
"summarizeRequest": "現在の端末出力を要約"
|
||||
@@ -77,7 +77,7 @@
|
||||
"invalidCommand": "モデルが単一行のコマンドを返しませんでした · 実行を拒否",
|
||||
"repeatLoop": "繰り返しのコマンドループを検出 · 停止: {{command}}",
|
||||
"criticalBlocked": "コマンドが破壊的なハードブロックに該当 · 上書き不可: {{command}}",
|
||||
"execFailed": "ssh-exec 失敗: {{error}}",
|
||||
"execFailed": "リモート実行に失敗しました: {{error}}",
|
||||
"execUnknown": "不明なエラー",
|
||||
"execNoOutput": "コマンドは出力なしで完了",
|
||||
"maxSteps": "{{max}} ステップの上限に達しました · 範囲を絞って再試行してください",
|
||||
|
||||
@@ -33,8 +33,10 @@
|
||||
"error": {
|
||||
"title": "問題が発生しました",
|
||||
"body": "アプリで予期しない問題が発生しました",
|
||||
"bodyPane": "このタブで問題が発生しました。他のタブは引き続き使えます。",
|
||||
"recoverable": "このエラーは復旧可能です。ページを更新してみてください。",
|
||||
"refreshPage": "ページを更新",
|
||||
"retryPane": "このタブを再試行",
|
||||
"restartApp": "アプリを再起動",
|
||||
"supportHint": "問題が解決しない場合は、ログを確認するかサポートにお問い合わせください"
|
||||
},
|
||||
@@ -108,7 +110,8 @@
|
||||
"closeLeft": "左側のタブを閉じる",
|
||||
"closeAll": "すべて閉じる",
|
||||
"clone": "タブを複製",
|
||||
"openSftp": "SFTP を開く"
|
||||
"openSftp": "SFTP を開く",
|
||||
"reconnect": "再接続"
|
||||
},
|
||||
"renameAria": "タブ {{title}} の名前を変更",
|
||||
"renameHint": "ダブルクリックで名前変更"
|
||||
|
||||
@@ -35,13 +35,17 @@
|
||||
"fullscreenAria": "全画面表示にする",
|
||||
"exitFullscreen": "全画面を終了",
|
||||
"exitFullscreenAria": "全画面を終了",
|
||||
"exitFullscreenHint": "Esc / F11 で終了"
|
||||
"exitFullscreenHint": "Esc / F11 で終了",
|
||||
"reconnect": "再接続",
|
||||
"reconnectAria": "リモートデスクトップを再接続"
|
||||
},
|
||||
"status": {
|
||||
"connecting": "接続中…",
|
||||
"connected": "接続済み",
|
||||
"disconnected": "切断済み",
|
||||
"connectFailed": "接続失敗",
|
||||
"reconnecting": "自動再接続中({{attempt}}/{{max}})…",
|
||||
"reconnectLimit": "自動再接続の上限に達しました({{max}})",
|
||||
"clipboardSynced": "リモートのクリップボードを同期しました",
|
||||
"savedToDownloads": "ローカルの Downloads に保存: {{name}}",
|
||||
"pastedToRemote": "リモートに貼り付けました",
|
||||
|
||||
@@ -41,7 +41,10 @@
|
||||
"refused": "接続が拒否されました",
|
||||
"disconnected": "切断されました",
|
||||
"keepaliveFailed": "SSH キープアライブが連続失敗し、切断されました",
|
||||
"idleKicked": "リモート Shell がアイドルタイムアウトで終了しました(TMOUT)"
|
||||
"idleKicked": "リモート Shell がアイドルタイムアウトで終了しました(TMOUT)",
|
||||
"unexpectedDropped": "切断されました(ネットワーク切断またはサーバー側クローズ)",
|
||||
"shellExited": "リモート Shell が終了しました(exit、Ctrl+D、またはサーバーポリシー)",
|
||||
"shellReadFailed": "リモート Shell の読み取りに失敗しました"
|
||||
},
|
||||
"auth": {
|
||||
"failed": "認証に失敗しました。ユーザー名とパスワードを確認してください。",
|
||||
@@ -160,7 +163,7 @@
|
||||
"invalidCommand": "モデルが単一行のコマンドを返しませんでした · 実行を拒否",
|
||||
"repeatLoop": "繰り返しのコマンドループを検出 · 停止: {{command}}",
|
||||
"criticalBlocked": "コマンドが破壊的なハードブロックに該当 · 上書き不可: {{command}}",
|
||||
"execFailed": "ssh-exec 失敗: {{error}}",
|
||||
"execFailed": "リモート実行に失敗しました: {{error}}",
|
||||
"maxSteps": "{{max}} ステップの上限に達しました · 範囲を絞って再試行してください",
|
||||
"sshRequired": "接続済みのリモート SSH セッションが必要です"
|
||||
},
|
||||
@@ -168,6 +171,12 @@
|
||||
"readFailed": "読み取り失敗: {{name}}",
|
||||
"writeFailed": "書き込み失敗: {{name}}"
|
||||
},
|
||||
"native": {
|
||||
"taskJoin": "バックグラウンド処理に失敗しました。再試行してください。",
|
||||
"invalidBase64": "ファイルデータが有効な Base64 ではありません。",
|
||||
"knownHostsPath": "~/.ssh/known_hosts を特定できません。",
|
||||
"invalidHostKeyDecision": "ホスト鍵の決定が無効です。"
|
||||
},
|
||||
"unknown": {
|
||||
"error": "不明なエラーが発生しました"
|
||||
}
|
||||
|
||||
@@ -132,7 +132,8 @@
|
||||
"collapse": "折りたたむ",
|
||||
"protocol": "プロトコル",
|
||||
"displayName": "表示名 (任意)",
|
||||
"displayNamePlaceholder": "デフォルトは user@host または vnc:host",
|
||||
"displayNamePlaceholder": "デフォルトは user@host",
|
||||
"displayNamePlaceholderVnc": "デフォルトはホスト名",
|
||||
"host": "ホスト",
|
||||
"hostPlaceholder": "192.168.1.10",
|
||||
"hostHint": "IP またはホスト名 · host:port も可",
|
||||
@@ -208,7 +209,8 @@
|
||||
},
|
||||
"importExport": {
|
||||
"exportTitle": "セッションをエクスポート",
|
||||
"exportDefaultPath": "opsxshell-sessions.bak",
|
||||
"exportDefaultPath": "opsxshell-hosts.oxs",
|
||||
"exportFilterOxs": "OpsXShell ホストバックアップ",
|
||||
"exportFilterBak": "OpsXShell バックアップ",
|
||||
"exportFilterJson": "JSON",
|
||||
"exportSuccess": "{{count}} 件のセッションをエクスポート:\n{{path}}",
|
||||
@@ -224,6 +226,7 @@
|
||||
"exportPasswordMismatch": "パスワードが一致しません",
|
||||
"exportPasswordRequired": "エクスポートにはバックアップパスワードが必要です",
|
||||
"importTitle": "セッションをインポート",
|
||||
"importFilterOxs": "OpsXShell ホストバックアップ",
|
||||
"importFilterBak": "OpsXShell バックアップ",
|
||||
"importFilterJson": "JSON",
|
||||
"importFilterSshConfig": "SSH Config",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"ai": "AI モデル",
|
||||
"aiDesc": "AI プロバイダー",
|
||||
"about": "情報",
|
||||
"aboutDesc": "バージョンとデータ",
|
||||
"aboutDesc": "バージョン、連絡先、診断",
|
||||
"account": "アカウント",
|
||||
"accountDesc": "メール、ライセンス、デバイス"
|
||||
},
|
||||
@@ -89,9 +89,9 @@
|
||||
"ssh": {
|
||||
"connection": "接続",
|
||||
"keepAlive": "キープアライブ",
|
||||
"keepAliveDesc": "アイドル時に SSH キープアライブを送信。数回連続失敗してから切断",
|
||||
"keepAliveDesc": "アイドル時に SSH プロトコルのキープアライブを送信(シェルへは入力しません)。NAT 切断対策であり、遠隔 TMOUT は迂回しません",
|
||||
"autoReconnect": "自動再接続",
|
||||
"autoReconnectDesc": "予期しない切断後に自動で再試行します",
|
||||
"autoReconnectDesc": "タブを開いたままの予期しない切断後に自動再接続(新しいシェル)。タブを閉じた場合、アイドル休止、遠隔 TMOUT は強制維持しません",
|
||||
"timeout": "接続タイムアウト",
|
||||
"timeoutDesc": "SSH 接続確立の最大待機時間(秒)",
|
||||
"maxReconnect": "再接続回数",
|
||||
@@ -104,7 +104,8 @@
|
||||
"suspend": "バックグラウンド休止",
|
||||
"suspendDesc": "アイドルのバックグラウンドタブを何分後に休止するか(稼働中の Shell/SFTP は維持)",
|
||||
"keepAliveInterval": "キープアライブ間隔(秒)",
|
||||
"keepAliveIntervalDesc": "サーバーへの ping 間隔(ClientAliveInterval 相当)。不安定な回線では 15 秒以下を推奨"
|
||||
"keepAliveIntervalDesc": "サーバーへの ping 間隔(ClientAliveInterval 相当)。不安定な回線では 15 秒以下を推奨",
|
||||
"sessionPolicyHint": "キープアライブは TCP 経路を守るもので、遠隔のセッション方針は変えません。Bash TMOUT、sshd ClientAlive、Windows のアイドル GPO は有効です。長時間ジョブはサーバー側で tmux/screen を使ってください。"
|
||||
},
|
||||
"security": {
|
||||
"credentials": "資格情報の保護",
|
||||
@@ -122,16 +123,25 @@
|
||||
},
|
||||
"about": {
|
||||
"tagline": "シンプルで使いやすいネイティブ運用端末",
|
||||
"appVersion": "アプリ v{{version}}",
|
||||
"copyVersion": "バージョン情報をコピー",
|
||||
"runtime": "ランタイム",
|
||||
"system": "システム",
|
||||
"localData": "ローカルデータ",
|
||||
"appVersion": "v{{version}}",
|
||||
"copyVersion": "バージョン・環境・パスをコピーして GitHub に貼り付け",
|
||||
"copyDiagnostics": "診断情報をコピー",
|
||||
"copyPath": "パスをコピー",
|
||||
"resources": "リソース",
|
||||
"wechat": "WeChat",
|
||||
"copyWechat": "WeChat ID をコピー",
|
||||
"github": "ディスカッション",
|
||||
"githubDesc": "https://github.com/L00J/opsxshell/discussions",
|
||||
"releases": "リリース",
|
||||
"releasesDesc": "インストーラーと更新内容",
|
||||
"licenseLink": "ライセンス",
|
||||
"licenseLinkDesc": "MIT License",
|
||||
"diagnostics": "診断",
|
||||
"sessionsSettings": "セッションと設定",
|
||||
"masterKeyPath": "マスターキー",
|
||||
"privacyNote": "パスワードと鍵パスフレーズは Rust 側で AES-256-GCM 暗号化。コマンド履歴も監査ログも保存しません。",
|
||||
"license": "MIT License · Rust · XTerm.js",
|
||||
"logPath": "アプリログ"
|
||||
"logPath": "アプリログ",
|
||||
"reportBug": "不具合を報告",
|
||||
"reportBugDesc": "GitHub issue を開き、現在のバージョンと環境を記入します"
|
||||
},
|
||||
"ai": {
|
||||
"presets": "プロバイダープリセット",
|
||||
@@ -151,18 +161,20 @@
|
||||
"saved": "保存済み",
|
||||
"retest": "再テストしてください",
|
||||
"testFailed": "接続テストに失敗しました",
|
||||
"available": "OK · {{latency}} ms",
|
||||
"available": "利用可 · {{latency}} ms",
|
||||
"savedKeepBlank": "保存済み — 空欄で維持",
|
||||
"notRequired": "不要",
|
||||
"otherModels": "その他のモデル…",
|
||||
"customModel": "カスタムモデル",
|
||||
"backToList": "一覧に戻る",
|
||||
"apiKeyOptional": "API Key(不要)",
|
||||
"apiKey": "API キー",
|
||||
"apiKeyOptional": "API キー(不要)",
|
||||
"endpoint": "エンドポイント",
|
||||
"advanced": "詳細",
|
||||
"allowHttp": "HTTP を許可(localhost / LAN)",
|
||||
"setActive": "現在のプロバイダーに設定",
|
||||
"deleteTitle": "削除",
|
||||
"apiKeySaveFailed": "API Key の保存に失敗しました",
|
||||
"apiKeySaveFailed": "API キーの保存に失敗しました",
|
||||
"providerNotFound": "プロバイダーが見つかりません",
|
||||
"modelCatalogReadFailed": "モデルカタログの読み取りに失敗しました",
|
||||
"catalogUnavailable": "models.dev を利用できません",
|
||||
@@ -175,26 +187,26 @@
|
||||
}
|
||||
},
|
||||
"account": {
|
||||
"title": "Account",
|
||||
"email": "Email",
|
||||
"emailDesc": "Used for checkout and license claims",
|
||||
"plan": "Current plan",
|
||||
"planFree": "Free",
|
||||
"title": "アカウント",
|
||||
"email": "メール",
|
||||
"emailDesc": "決済とライセンス受け取りに使う連絡先メール",
|
||||
"plan": "現在のプラン",
|
||||
"planFree": "無料",
|
||||
"planYearly": "Pro 年額",
|
||||
"planPerpetual": "Pro 永久",
|
||||
"planTrial": "Pro 体験",
|
||||
"expires": "Expires {{date}}",
|
||||
"lifetime": "Lifetime",
|
||||
"devices": "Seats: up to {{count}} devices",
|
||||
"deviceId": "This device ID",
|
||||
"saveEmail": "Save email",
|
||||
"activate": "Activate license",
|
||||
"pasteLicense": "Paste LIC- license key",
|
||||
"clear": "Clear local license",
|
||||
"deactivate": "Deactivate this device",
|
||||
"upgrade": "Upgrade",
|
||||
"saved": "Saved",
|
||||
"activateOk": "Activated",
|
||||
"expires": "{{date}} まで有効",
|
||||
"lifetime": "無期限",
|
||||
"devices": "デバイス席:最大 {{count}} 台",
|
||||
"deviceId": "このデバイスの ID",
|
||||
"saveEmail": "メールを保存",
|
||||
"activate": "ライセンスを有効化",
|
||||
"pasteLicense": "LIC- ライセンスを貼り付け",
|
||||
"clear": "この端末のライセンスを消去",
|
||||
"deactivate": "この端末の有効化を解除",
|
||||
"upgrade": "Pro にアップグレード",
|
||||
"saved": "保存しました",
|
||||
"activateOk": "有効化しました",
|
||||
"deviceList": "紐付けデバイス",
|
||||
"loadingDevices": "デバイスを読み込み中…",
|
||||
"noDevices": "紐付けデバイスはありません",
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"notConnected": "未接続",
|
||||
"connecting": "接続中...",
|
||||
"resumeHint": "再接続後は {{path}} に戻ります",
|
||||
"reconnectingResume": "{{path}} を復元して再接続中…"
|
||||
"reconnectingResume": "{{path}} を復元して再接続中…",
|
||||
"autoReconnecting": "自動再接続中({{attempt}}/{{max}})…"
|
||||
},
|
||||
"errors": {
|
||||
"notConnected": "SFTP に接続されていません",
|
||||
|
||||
@@ -34,7 +34,9 @@
|
||||
"reopenShell": "シェルを再開",
|
||||
"reopening": "開いています…",
|
||||
"disconnected": "切断されました",
|
||||
"disconnectedHint": "SSH セッション終了またはネットワーク切断。下のボタンで再接続して作業を続けられます。",
|
||||
"disconnectedHint": "ネットワーク切断、またはサーバーが SSH を閉じました。タブを開いたままなら自動再接続します。下のボタンですぐに再接続もできます。",
|
||||
"disconnectedKeepalive": "キープアライブ失敗で切断",
|
||||
"disconnectedKeepaliveHint": "SSH キープアライブに応答がありません。NAT やアイドル切断が典型です。シェルへキー入力は送りません。タブを開いたままなら自動再接続します。",
|
||||
"reconnect": "再接続",
|
||||
"suspended": "接続が一時停止",
|
||||
"suspendedHint": "しばらく操作がなかったため、リソース節約のため接続を一時停止しました",
|
||||
|
||||
@@ -16,10 +16,10 @@
|
||||
"stop": "중지",
|
||||
"send": "전송",
|
||||
"placeholder": "작업 또는 후속 질문… (Enter로 전송, Shift+Enter로 줄 바꿈)",
|
||||
"hintFollowUp": "후속 질문은 이 스레드의 컨텍스트를 포함합니다",
|
||||
"hintFollowUp": "후속 질문에는 이 스레드 요약이 포함됩니다(너무 길면 잘림)",
|
||||
"hintSelection": "선택한 {{lines}}줄을 포함합니다",
|
||||
"hintSteps": "최대 {{max}}단계 · 저장되지 않음",
|
||||
"footerNote": "고위험은 확인 필요 · 치명적 작업은 차단됨 · 세션 저장 안 함",
|
||||
"hintSteps": "최대 {{max}}단계",
|
||||
"footerNote": "고위험은 먼저 확인 · 파괴적 명령은 거부 · 대화는 저장되지 않음",
|
||||
"summarize": "출력 요약",
|
||||
"summarizeTitle": "보이는 터미널 출력을 AI로 요약합니다(선택 텍스트 우선). 명령은 실행하지 않습니다",
|
||||
"summarizeRequest": "현재 터미널 출력 요약"
|
||||
@@ -77,7 +77,7 @@
|
||||
"invalidCommand": "모델이 단일 한 줄 명령을 반환하지 않았습니다; 실행이 거부되었습니다",
|
||||
"repeatLoop": "반복된 명령 루프가 감지되었습니다; 중지됨: {{command}}",
|
||||
"criticalBlocked": "명령이 파괴적인 하드 차단에 걸렸습니다; 재정의 불가: {{command}}",
|
||||
"execFailed": "ssh-exec 실패: {{error}}",
|
||||
"execFailed": "원격 실행 실패: {{error}}",
|
||||
"execUnknown": "알 수 없는 오류",
|
||||
"execNoOutput": "명령이 출력 없이 완료됨",
|
||||
"maxSteps": "{{max}}단계 한도에 도달했습니다; 범위를 좁히고 재시도하세요",
|
||||
|
||||
@@ -33,8 +33,10 @@
|
||||
"error": {
|
||||
"title": "문제가 발생했습니다",
|
||||
"body": "앱에서 예기치 않은 문제가 발생했습니다",
|
||||
"bodyPane": "이 탭에서 문제가 발생했습니다. 다른 탭은 계속 사용할 수 있습니다.",
|
||||
"recoverable": "이 오류는 복구 가능합니다. 페이지를 새로고침해 보세요.",
|
||||
"refreshPage": "페이지 새로고침",
|
||||
"retryPane": "이 탭 다시 시도",
|
||||
"restartApp": "앱 재시작",
|
||||
"supportHint": "문제가 지속되면 로그를 확인하거나 지원팀에 문의하세요"
|
||||
},
|
||||
@@ -108,7 +110,8 @@
|
||||
"closeLeft": "왼쪽 탭 닫기",
|
||||
"closeAll": "모두 닫기",
|
||||
"clone": "탭 복제",
|
||||
"openSftp": "SFTP 열기"
|
||||
"openSftp": "SFTP 열기",
|
||||
"reconnect": "다시 연결"
|
||||
},
|
||||
"renameAria": "탭 {{title}} 이름 바꾸기",
|
||||
"renameHint": "더블 클릭해 이름 변경"
|
||||
|
||||
@@ -35,13 +35,17 @@
|
||||
"fullscreenAria": "전체 화면으로 전환",
|
||||
"exitFullscreen": "전체 화면 종료",
|
||||
"exitFullscreenAria": "전체 화면 종료",
|
||||
"exitFullscreenHint": "Esc / F11로 종료"
|
||||
"exitFullscreenHint": "Esc / F11로 종료",
|
||||
"reconnect": "재연결",
|
||||
"reconnectAria": "원격 데스크톱 다시 연결"
|
||||
},
|
||||
"status": {
|
||||
"connecting": "연결 중…",
|
||||
"connected": "연결됨",
|
||||
"disconnected": "연결 끊김",
|
||||
"connectFailed": "연결 실패",
|
||||
"reconnecting": "자동 재연결 중 ({{attempt}}/{{max}})…",
|
||||
"reconnectLimit": "자동 재연결 한도에 도달했습니다 ({{max}})",
|
||||
"clipboardSynced": "원격 클립보드 동기화됨",
|
||||
"savedToDownloads": "로컬 다운로드에 저장됨: {{name}}",
|
||||
"pastedToRemote": "원격에 붙여넣음",
|
||||
|
||||
@@ -41,7 +41,10 @@
|
||||
"refused": "연결이 거부되었습니다",
|
||||
"disconnected": "연결 끊김",
|
||||
"keepaliveFailed": "SSH keep-alive가 연속 실패하여 연결이 닫혔습니다",
|
||||
"idleKicked": "원격 Shell이 유휴 시간 초과로 종료되었습니다(TMOUT)"
|
||||
"idleKicked": "원격 Shell이 유휴 시간 초과로 종료되었습니다(TMOUT)",
|
||||
"unexpectedDropped": "연결이 끊겼습니다(네트워크 중단 또는 서버 종료)",
|
||||
"shellExited": "원격 Shell이 종료되었습니다(exit, Ctrl+D 또는 서버 정책)",
|
||||
"shellReadFailed": "원격 Shell 읽기에 실패했습니다"
|
||||
},
|
||||
"auth": {
|
||||
"failed": "인증 실패. 사용자 이름과 비밀번호를 확인하세요.",
|
||||
@@ -160,7 +163,7 @@
|
||||
"invalidCommand": "모델이 단일 한 줄 명령을 반환하지 않았습니다; 실행이 거부되었습니다",
|
||||
"repeatLoop": "반복된 명령 루프가 감지되었습니다; 중지됨: {{command}}",
|
||||
"criticalBlocked": "명령이 파괴적인 하드 차단에 걸렸습니다; 재정의 불가: {{command}}",
|
||||
"execFailed": "ssh-exec 실패: {{error}}",
|
||||
"execFailed": "원격 실행 실패: {{error}}",
|
||||
"maxSteps": "{{max}}단계 한도에 도달했습니다; 범위를 좁히고 재시도하세요",
|
||||
"sshRequired": "연결된 원격 SSH 세션이 필요합니다"
|
||||
},
|
||||
@@ -168,6 +171,12 @@
|
||||
"readFailed": "읽기 실패: {{name}}",
|
||||
"writeFailed": "쓰기 실패: {{name}}"
|
||||
},
|
||||
"native": {
|
||||
"taskJoin": "백그라운드 작업에 실패했습니다. 다시 시도하세요.",
|
||||
"invalidBase64": "파일 데이터가 올바른 Base64가 아닙니다.",
|
||||
"knownHostsPath": "~/.ssh/known_hosts를 찾을 수 없습니다.",
|
||||
"invalidHostKeyDecision": "호스트 키 결정이 올바르지 않습니다."
|
||||
},
|
||||
"unknown": {
|
||||
"error": "알 수 없는 오류가 발생했습니다"
|
||||
}
|
||||
|
||||
@@ -132,7 +132,8 @@
|
||||
"collapse": "접기",
|
||||
"protocol": "프로토콜",
|
||||
"displayName": "표시 이름 (선택 사항)",
|
||||
"displayNamePlaceholder": "기본값은 user@host 또는 vnc:host",
|
||||
"displayNamePlaceholder": "기본값은 user@host",
|
||||
"displayNamePlaceholderVnc": "기본값은 호스트 이름",
|
||||
"host": "호스트",
|
||||
"hostPlaceholder": "192.168.1.10",
|
||||
"hostHint": "IP 또는 호스트명; host:port도 허용됨",
|
||||
@@ -208,7 +209,8 @@
|
||||
},
|
||||
"importExport": {
|
||||
"exportTitle": "세션 내보내기",
|
||||
"exportDefaultPath": "opsxshell-sessions.bak",
|
||||
"exportDefaultPath": "opsxshell-hosts.oxs",
|
||||
"exportFilterOxs": "OpsXShell 호스트 백업",
|
||||
"exportFilterBak": "OpsXShell 백업",
|
||||
"exportFilterJson": "JSON",
|
||||
"exportSuccess": "{{count}}개 세션을 다음으로 내보냄:\n{{path}}",
|
||||
@@ -224,6 +226,7 @@
|
||||
"exportPasswordMismatch": "비밀번호가 일치하지 않습니다",
|
||||
"exportPasswordRequired": "내보내기에는 백업 비밀번호가 필요합니다",
|
||||
"importTitle": "세션 가져오기",
|
||||
"importFilterOxs": "OpsXShell 호스트 백업",
|
||||
"importFilterBak": "OpsXShell 백업",
|
||||
"importFilterJson": "JSON",
|
||||
"importFilterSshConfig": "SSH Config",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"ai": "AI 모델",
|
||||
"aiDesc": "AI 제공자",
|
||||
"about": "정보",
|
||||
"aboutDesc": "버전 및 데이터",
|
||||
"aboutDesc": "버전, 연락처, 진단",
|
||||
"account": "계정",
|
||||
"accountDesc": "이메일, 라이선스, 기기"
|
||||
},
|
||||
@@ -89,9 +89,9 @@
|
||||
"ssh": {
|
||||
"connection": "연결",
|
||||
"keepAlive": "연결 유지",
|
||||
"keepAliveDesc": "유휴 시 SSH keep-alive를 보냅니다. 연속 실패 후에만 연결을 끊습니다",
|
||||
"keepAliveDesc": "유휴 시 SSH 프로토콜 keep-alive를 보냅니다(셸에 키를 넣지 않음). NAT 끊김만 막으며 원격 TMOUT을 우회하지 않습니다",
|
||||
"autoReconnect": "자동 재연결",
|
||||
"autoReconnectDesc": "예기치 않은 연결 끊김 후 자동으로 재시도",
|
||||
"autoReconnectDesc": "탭이 열린 채로 예기치 않게 끊기면 자동 재연결(새 셸). 탭 닫기, 유휴 일시 중지, 원격 TMOUT은 강제로 유지하지 않습니다",
|
||||
"timeout": "연결 시간 초과",
|
||||
"timeoutDesc": "SSH 설정 시 최대 대기 시간(초)",
|
||||
"maxReconnect": "재연결 시도",
|
||||
@@ -104,7 +104,8 @@
|
||||
"suspend": "백그라운드 일시 중지",
|
||||
"suspendDesc": "유휴 백그라운드 탭을 몇 분 후 일시 중지할지(활성 Shell/SFTP는 유지)",
|
||||
"keepAliveInterval": "유지 간격(초)",
|
||||
"keepAliveIntervalDesc": "서버 ping 주기(ClientAliveInterval과 유사). 불안정한 네트워크에서는 15초 이하 권장"
|
||||
"keepAliveIntervalDesc": "서버 ping 주기(ClientAliveInterval과 유사). 불안정한 네트워크에서는 15초 이하 권장",
|
||||
"sessionPolicyHint": "Keep-alive는 TCP 링크를 지킬 뿐 원격 세션 정책을 바꾸지 않습니다. Bash TMOUT, sshd ClientAlive, Windows 유휴 GPO는 그대로입니다. 긴 작업은 서버에서 tmux/screen을 쓰세요."
|
||||
},
|
||||
"security": {
|
||||
"credentials": "자격 증명 보호",
|
||||
@@ -122,16 +123,25 @@
|
||||
},
|
||||
"about": {
|
||||
"tagline": "간단하고 실용적인 네이티브 운영 터미널",
|
||||
"appVersion": "앱 v{{version}}",
|
||||
"copyVersion": "버전 정보 복사",
|
||||
"runtime": "런타임",
|
||||
"system": "시스템",
|
||||
"localData": "로컬 데이터",
|
||||
"appVersion": "v{{version}}",
|
||||
"copyVersion": "버전·시스템·경로를 복사해 GitHub에 붙여넣기",
|
||||
"copyDiagnostics": "진단 정보 복사",
|
||||
"copyPath": "경로 복사",
|
||||
"resources": "리소스",
|
||||
"wechat": "WeChat",
|
||||
"copyWechat": "WeChat ID 복사",
|
||||
"github": "토론",
|
||||
"githubDesc": "https://github.com/L00J/opsxshell/discussions",
|
||||
"releases": "릴리스",
|
||||
"releasesDesc": "설치 패키지와 변경 사항",
|
||||
"licenseLink": "라이선스",
|
||||
"licenseLinkDesc": "MIT License",
|
||||
"diagnostics": "진단",
|
||||
"sessionsSettings": "세션 및 설정",
|
||||
"masterKeyPath": "마스터 키",
|
||||
"privacyNote": "비밀번호·키 암호는 Rust에서 AES-256-GCM으로 암호화합니다. 명령 기록·감사 로그는 저장하지 않습니다.",
|
||||
"license": "MIT 라이선스 · Rust · XTerm.js",
|
||||
"logPath": "앱 로그"
|
||||
"license": "MIT License · Rust · XTerm.js",
|
||||
"logPath": "앱 로그",
|
||||
"reportBug": "버그 신고",
|
||||
"reportBugDesc": "GitHub 이슈를 열고 현재 버전과 시스템을 채웁니다"
|
||||
},
|
||||
"ai": {
|
||||
"presets": "제공자 프리셋",
|
||||
@@ -157,7 +167,9 @@
|
||||
"otherModels": "다른 모델…",
|
||||
"customModel": "사용자 지정 모델",
|
||||
"backToList": "목록으로",
|
||||
"apiKey": "API 키",
|
||||
"apiKeyOptional": "API 키 (필요하지 않음)",
|
||||
"endpoint": "엔드포인트",
|
||||
"advanced": "고급",
|
||||
"allowHttp": "HTTP 허용 (localhost/LAN)",
|
||||
"setActive": "활성으로 설정",
|
||||
@@ -175,26 +187,26 @@
|
||||
}
|
||||
},
|
||||
"account": {
|
||||
"title": "Account",
|
||||
"email": "Email",
|
||||
"emailDesc": "Used for checkout and license claims",
|
||||
"plan": "Current plan",
|
||||
"planFree": "Free",
|
||||
"title": "계정",
|
||||
"email": "이메일",
|
||||
"emailDesc": "결제 및 라이선스 수령에 쓰는 연락 이메일",
|
||||
"plan": "현재 요금제",
|
||||
"planFree": "무료",
|
||||
"planYearly": "프로 연간",
|
||||
"planPerpetual": "프로 영구",
|
||||
"planTrial": "프로 체험",
|
||||
"expires": "Expires {{date}}",
|
||||
"lifetime": "Lifetime",
|
||||
"devices": "Seats: up to {{count}} devices",
|
||||
"deviceId": "This device ID",
|
||||
"saveEmail": "Save email",
|
||||
"activate": "Activate license",
|
||||
"pasteLicense": "Paste LIC- license key",
|
||||
"clear": "Clear local license",
|
||||
"deactivate": "Deactivate this device",
|
||||
"upgrade": "Upgrade",
|
||||
"saved": "Saved",
|
||||
"activateOk": "Activated",
|
||||
"expires": "{{date}}까지",
|
||||
"lifetime": "평생",
|
||||
"devices": "기기 좌석: 최대 {{count}}대",
|
||||
"deviceId": "이 기기 ID",
|
||||
"saveEmail": "이메일 저장",
|
||||
"activate": "라이선스 활성화",
|
||||
"pasteLicense": "LIC- 라이선스 붙여넣기",
|
||||
"clear": "이 기기의 라이선스 지우기",
|
||||
"deactivate": "이 기기 활성화 해제",
|
||||
"upgrade": "프로로 업그레이드",
|
||||
"saved": "저장됨",
|
||||
"activateOk": "활성화됨",
|
||||
"deviceList": "바인딩된 기기",
|
||||
"loadingDevices": "기기 목록 불러오는 중…",
|
||||
"noDevices": "바인딩된 기기 없음",
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"notConnected": "연결되지 않음",
|
||||
"connecting": "연결 중...",
|
||||
"resumeHint": "다시 연결하면 {{path}}(으)로 복귀합니다",
|
||||
"reconnectingResume": "{{path}} 복원하며 다시 연결 중…"
|
||||
"reconnectingResume": "{{path}} 복원하며 다시 연결 중…",
|
||||
"autoReconnecting": "자동 재연결 중 ({{attempt}}/{{max}})…"
|
||||
},
|
||||
"errors": {
|
||||
"notConnected": "SFTP가 연결되지 않았습니다",
|
||||
|
||||
@@ -34,7 +34,9 @@
|
||||
"reopenShell": "셸 다시 열기",
|
||||
"reopening": "여는 중…",
|
||||
"disconnected": "연결 끊김",
|
||||
"disconnectedHint": "SSH 세션이 종료되었거나 네트워크가 끊겼습니다. 아래를 누르면 다시 연결해 작업을 이어갑니다.",
|
||||
"disconnectedHint": "네트워크가 끊겼거나 서버가 SSH를 닫았습니다. 탭이 열려 있으면 자동 재연결되며, 아래 버튼으로 바로 재연결할 수도 있습니다.",
|
||||
"disconnectedKeepalive": "Keep-alive 실패로 연결 끊김",
|
||||
"disconnectedKeepaliveHint": "SSH keep-alive에 응답이 없습니다. 대개 NAT나 유휴 네트워크 끊김입니다. 셸에 키 입력을 보내지는 않습니다. 탭이 열려 있으면 자동 재연결됩니다.",
|
||||
"reconnect": "재연결",
|
||||
"suspended": "연결 일시 중단됨",
|
||||
"suspendedHint": "오랫동안 활동이 없어 리소스 절약을 위해 연결이 일시 중단되었습니다",
|
||||
|
||||
@@ -16,10 +16,10 @@
|
||||
"stop": "Остановить",
|
||||
"send": "Отправить",
|
||||
"placeholder": "Задача или уточнение… (Enter — отправить, Shift+Enter — новая строка)",
|
||||
"hintFollowUp": "Уточнения включают контекст этой ветки",
|
||||
"hintFollowUp": "Уточнения включают сжатое резюме этой ветки",
|
||||
"hintSelection": "Будет включено {{lines}} выбранных строк",
|
||||
"hintSteps": "До {{max}} шагов · ничего не сохраняется",
|
||||
"footerNote": "Высокий риск требует подтверждения · критический блокируется · сеанс не сохраняется",
|
||||
"hintSteps": "До {{max}} шагов",
|
||||
"footerNote": "Высокий риск — сначала подтверждение · разрушительные команды отклоняются · чат не сохраняется",
|
||||
"summarize": "Сводка",
|
||||
"summarizeTitle": "AI суммирует видимый вывод терминала (сначала выделение); команды не выполняются",
|
||||
"summarizeRequest": "Суммируйте текущий вывод терминала"
|
||||
@@ -77,7 +77,7 @@
|
||||
"invalidCommand": "Модель не вернула единственную однострочную команду; выполнение отклонено",
|
||||
"repeatLoop": "Обнаружен повторяющийся цикл команд; остановлено: {{command}}",
|
||||
"criticalBlocked": "Команда попала под деструктивную жёсткую блокировку; обход невозможен: {{command}}",
|
||||
"execFailed": "Сбой ssh-exec: {{error}}",
|
||||
"execFailed": "Сбой удалённого выполнения: {{error}}",
|
||||
"execUnknown": "Неизвестная ошибка",
|
||||
"execNoOutput": "Команда завершилась без вывода",
|
||||
"maxSteps": "Достигнут лимит в {{max}} шагов; сузьте задачу и повторите",
|
||||
|
||||
@@ -33,8 +33,10 @@
|
||||
"error": {
|
||||
"title": "Что-то пошло не так",
|
||||
"body": "В приложении возникла непредвиденная проблема",
|
||||
"bodyPane": "В этой вкладке произошла ошибка. Остальные вкладки работают.",
|
||||
"recoverable": "Эта ошибка обратима. Попробуйте обновить страницу.",
|
||||
"refreshPage": "Обновить страницу",
|
||||
"retryPane": "Повторить вкладку",
|
||||
"restartApp": "Перезапустить приложение",
|
||||
"supportHint": "Если проблема сохраняется, проверьте журналы или обратитесь в поддержку"
|
||||
},
|
||||
@@ -104,11 +106,12 @@
|
||||
},
|
||||
"menu": {
|
||||
"closeOthers": "Закрыть остальные",
|
||||
"closeRight": "Закрыть вкладки справа",
|
||||
"closeLeft": "Закрыть вкладки слева",
|
||||
"closeRight": "Закрыть справа",
|
||||
"closeLeft": "Закрыть слева",
|
||||
"closeAll": "Закрыть все",
|
||||
"clone": "Дублировать вкладку",
|
||||
"openSftp": "Открыть SFTP"
|
||||
"clone": "Клонировать вкладку",
|
||||
"openSftp": "Открыть SFTP",
|
||||
"reconnect": "Переподключиться"
|
||||
},
|
||||
"renameAria": "Переименовать вкладку {{title}}",
|
||||
"renameHint": "Двойной клик — переименовать"
|
||||
|
||||
@@ -35,13 +35,17 @@
|
||||
"fullscreenAria": "Развернуть на весь экран",
|
||||
"exitFullscreen": "Выйти из полного экрана",
|
||||
"exitFullscreenAria": "Выйти из полного экрана",
|
||||
"exitFullscreenHint": "Esc / F11 — выход"
|
||||
"exitFullscreenHint": "Esc / F11 — выход",
|
||||
"reconnect": "Переподключиться",
|
||||
"reconnectAria": "Переподключить удалённый рабочий стол"
|
||||
},
|
||||
"status": {
|
||||
"connecting": "Подключение…",
|
||||
"connected": "Подключено",
|
||||
"disconnected": "Отключено",
|
||||
"connectFailed": "Сбой подключения",
|
||||
"reconnecting": "Автопереподключение ({{attempt}}/{{max}})…",
|
||||
"reconnectLimit": "Достигнут лимит автопереподключения ({{max}})",
|
||||
"clipboardSynced": "Удалённый буфер обмена синхронизирован",
|
||||
"savedToDownloads": "Сохранено в локальную папку Downloads: {{name}}",
|
||||
"pastedToRemote": "Вставлено на удалённый сервер",
|
||||
|
||||
@@ -41,7 +41,10 @@
|
||||
"refused": "В подключении отказано",
|
||||
"disconnected": "Отключено",
|
||||
"keepaliveFailed": "SSH keep-alive несколько раз не удался; соединение закрыто",
|
||||
"idleKicked": "Удалённая оболочка завершилась по таймауту простоя (TMOUT)"
|
||||
"idleKicked": "Удалённая оболочка завершилась по таймауту простоя (TMOUT)",
|
||||
"unexpectedDropped": "Соединение разорвано (сеть или закрытие на сервере)",
|
||||
"shellExited": "Удалённая оболочка завершилась (exit, Ctrl+D или политика сервера)",
|
||||
"shellReadFailed": "Не удалось прочитать удалённую оболочку"
|
||||
},
|
||||
"auth": {
|
||||
"failed": "Сбой аутентификации. Проверьте имя пользователя и пароль.",
|
||||
@@ -160,7 +163,7 @@
|
||||
"invalidCommand": "Модель не вернула единственную однострочную команду; выполнение отклонено",
|
||||
"repeatLoop": "Обнаружен повторяющийся цикл команд; остановлено: {{command}}",
|
||||
"criticalBlocked": "Команда попала под деструктивную жёсткую блокировку; обход невозможен: {{command}}",
|
||||
"execFailed": "Сбой ssh-exec: {{error}}",
|
||||
"execFailed": "Сбой удалённого выполнения: {{error}}",
|
||||
"maxSteps": "Достигнут лимит в {{max}} шагов; сузьте задачу и повторите",
|
||||
"sshRequired": "Требуется подключённый удалённый SSH-сеанс"
|
||||
},
|
||||
@@ -168,6 +171,12 @@
|
||||
"readFailed": "Сбой чтения: {{name}}",
|
||||
"writeFailed": "Сбой записи: {{name}}"
|
||||
},
|
||||
"native": {
|
||||
"taskJoin": "Фоновая задача не выполнена. Повторите попытку.",
|
||||
"invalidBase64": "Данные файла не являются корректным Base64.",
|
||||
"knownHostsPath": "Не удалось найти ~/.ssh/known_hosts.",
|
||||
"invalidHostKeyDecision": "Некорректное решение по ключу хоста."
|
||||
},
|
||||
"unknown": {
|
||||
"error": "Произошла неизвестная ошибка"
|
||||
}
|
||||
|
||||
@@ -132,7 +132,8 @@
|
||||
"collapse": "Свернуть",
|
||||
"protocol": "Протокол",
|
||||
"displayName": "Отображаемое имя (необязательно)",
|
||||
"displayNamePlaceholder": "По умолчанию user@host или vnc:host",
|
||||
"displayNamePlaceholder": "По умолчанию user@host",
|
||||
"displayNamePlaceholderVnc": "По умолчанию имя хоста",
|
||||
"host": "Хост",
|
||||
"hostPlaceholder": "192.168.1.10",
|
||||
"hostHint": "IP или имя хоста; также принимается host:port",
|
||||
@@ -208,7 +209,8 @@
|
||||
},
|
||||
"importExport": {
|
||||
"exportTitle": "Экспорт сеансов",
|
||||
"exportDefaultPath": "opsxshell-sessions.bak",
|
||||
"exportDefaultPath": "opsxshell-hosts.oxs",
|
||||
"exportFilterOxs": "Резервная копия хостов OpsXShell",
|
||||
"exportFilterBak": "Резервная копия OpsXShell",
|
||||
"exportFilterJson": "JSON",
|
||||
"exportSuccess": "Экспортировано сеансов: {{count}} в:\n{{path}}",
|
||||
@@ -224,6 +226,7 @@
|
||||
"exportPasswordMismatch": "Пароли не совпадают",
|
||||
"exportPasswordRequired": "Для экспорта требуется пароль резервной копии",
|
||||
"importTitle": "Импорт сеансов",
|
||||
"importFilterOxs": "Резервная копия хостов OpsXShell",
|
||||
"importFilterBak": "Резервная копия OpsXShell",
|
||||
"importFilterJson": "JSON",
|
||||
"importFilterSshConfig": "SSH Config",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"ai": "AI-модели",
|
||||
"aiDesc": "AI-провайдеры",
|
||||
"about": "О приложении",
|
||||
"aboutDesc": "Версия и данные",
|
||||
"aboutDesc": "Версия, контакты и диагностика",
|
||||
"account": "Аккаунт",
|
||||
"accountDesc": "Email, лицензия и устройство"
|
||||
},
|
||||
@@ -88,10 +88,10 @@
|
||||
},
|
||||
"ssh": {
|
||||
"connection": "Подключение",
|
||||
"keepAlive": "Keep-alive",
|
||||
"keepAliveDesc": "Отправлять SSH keep-alive в простое; отключать только после нескольких сбоев",
|
||||
"keepAlive": "Поддержание связи",
|
||||
"keepAliveDesc": "В простое слать протокол SSH keep-alive (не вводить в оболочку). Защищает от NAT, не обходит удалённый TMOUT",
|
||||
"autoReconnect": "Авто-переподключение",
|
||||
"autoReconnectDesc": "Автоматически повторять после неожиданного разрыва",
|
||||
"autoReconnectDesc": "Пока вкладка открыта, после неожиданного разрыва переподключаться (новая оболочка). Закрытие вкладки, простой и удалённый TMOUT не удерживаются силой",
|
||||
"timeout": "Тайм-аут подключения",
|
||||
"timeoutDesc": "Максимальное время ожидания при установке SSH (секунды)",
|
||||
"maxReconnect": "Попытки переподключения",
|
||||
@@ -104,7 +104,8 @@
|
||||
"suspend": "Фоновая приостановка",
|
||||
"suspendDesc": "Через сколько минут приостанавливать простаивающие фоновые вкладки (активные Shell/SFTP остаются)",
|
||||
"keepAliveInterval": "Интервал keep-alive (сек)",
|
||||
"keepAliveIntervalDesc": "Как часто опрашивать сервер (как ClientAliveInterval). При нестабильной сети — ≤15 с"
|
||||
"keepAliveIntervalDesc": "Как часто опрашивать сервер (как ClientAliveInterval). При нестабильной сети — ≤15 с",
|
||||
"sessionPolicyHint": "Keep-alive держит TCP-канал, а не политику удалённого сеанса. Bash TMOUT, sshd ClientAlive и простой GPO Windows по-прежнему действуют. Долгие задачи — через tmux/screen на сервере."
|
||||
},
|
||||
"security": {
|
||||
"credentials": "Защита учётных данных",
|
||||
@@ -122,16 +123,25 @@
|
||||
},
|
||||
"about": {
|
||||
"tagline": "Простой и удобный нативный терминал для эксплуатации",
|
||||
"appVersion": "Приложение v{{version}}",
|
||||
"copyVersion": "Копировать сведения о версии",
|
||||
"runtime": "Среда выполнения",
|
||||
"system": "Система",
|
||||
"localData": "Локальные данные",
|
||||
"appVersion": "v{{version}}",
|
||||
"copyVersion": "Скопировать версию, систему и пути для GitHub",
|
||||
"copyDiagnostics": "Копировать диагностику",
|
||||
"copyPath": "Копировать путь",
|
||||
"resources": "Ресурсы",
|
||||
"wechat": "WeChat",
|
||||
"copyWechat": "Скопировать WeChat ID",
|
||||
"github": "Обсуждения",
|
||||
"githubDesc": "https://github.com/L00J/opsxshell/discussions",
|
||||
"releases": "Релизы",
|
||||
"releasesDesc": "Установщики и заметки к выпуску",
|
||||
"licenseLink": "Лицензия",
|
||||
"licenseLinkDesc": "MIT License",
|
||||
"diagnostics": "Диагностика",
|
||||
"sessionsSettings": "Сеансы и настройки",
|
||||
"masterKeyPath": "Мастер-ключ",
|
||||
"privacyNote": "Пароли и passphrase шифруются в Rust (AES-256-GCM). Без истории команд и без аудита-бастиона.",
|
||||
"license": "Лицензия MIT · Rust · XTerm.js",
|
||||
"logPath": "Журнал приложения"
|
||||
"license": "MIT License · Rust · XTerm.js",
|
||||
"logPath": "Журнал приложения",
|
||||
"reportBug": "Сообщить об ошибке",
|
||||
"reportBugDesc": "Открывает GitHub issue с текущей версией и платформой"
|
||||
},
|
||||
"ai": {
|
||||
"presets": "Пресеты провайдеров",
|
||||
@@ -151,13 +161,15 @@
|
||||
"saved": "Сохранено",
|
||||
"retest": "Пожалуйста, перепроверьте",
|
||||
"testFailed": "Сбой проверки подключения",
|
||||
"available": "OK · {{latency}} мс",
|
||||
"available": "Готово · {{latency}} мс",
|
||||
"savedKeepBlank": "Сохранено — оставьте пустым, чтобы сохранить",
|
||||
"notRequired": "Не требуется",
|
||||
"otherModels": "Другие модели…",
|
||||
"customModel": "Своя модель",
|
||||
"backToList": "Назад к списку",
|
||||
"apiKeyOptional": "API Key (не требуется)",
|
||||
"apiKey": "API-ключ",
|
||||
"apiKeyOptional": "API-ключ (не требуется)",
|
||||
"endpoint": "Адрес API",
|
||||
"advanced": "Дополнительно",
|
||||
"allowHttp": "Разрешить HTTP (localhost/LAN)",
|
||||
"setActive": "Сделать активным",
|
||||
@@ -175,26 +187,26 @@
|
||||
}
|
||||
},
|
||||
"account": {
|
||||
"title": "Account",
|
||||
"email": "Email",
|
||||
"emailDesc": "Used for checkout and license claims",
|
||||
"plan": "Current plan",
|
||||
"planFree": "Free",
|
||||
"title": "Аккаунт",
|
||||
"email": "Почта",
|
||||
"emailDesc": "Контактная почта для оплаты и получения лицензии",
|
||||
"plan": "Текущий план",
|
||||
"planFree": "Бесплатный",
|
||||
"planYearly": "Pro на год",
|
||||
"planPerpetual": "Pro бессрочно",
|
||||
"planTrial": "Pro пробный",
|
||||
"expires": "Expires {{date}}",
|
||||
"lifetime": "Lifetime",
|
||||
"devices": "Seats: up to {{count}} devices",
|
||||
"deviceId": "This device ID",
|
||||
"saveEmail": "Save email",
|
||||
"activate": "Activate license",
|
||||
"pasteLicense": "Paste LIC- license key",
|
||||
"clear": "Clear local license",
|
||||
"deactivate": "Deactivate this device",
|
||||
"upgrade": "Upgrade",
|
||||
"saved": "Saved",
|
||||
"activateOk": "Activated",
|
||||
"expires": "Действует до {{date}}",
|
||||
"lifetime": "Бессрочно",
|
||||
"devices": "Места устройств: до {{count}}",
|
||||
"deviceId": "ID этого устройства",
|
||||
"saveEmail": "Сохранить почту",
|
||||
"activate": "Активировать лицензию",
|
||||
"pasteLicense": "Вставьте лицензию LIC-",
|
||||
"clear": "Удалить локальную лицензию",
|
||||
"deactivate": "Отключить активацию на этом устройстве",
|
||||
"upgrade": "Перейти на Pro",
|
||||
"saved": "Сохранено",
|
||||
"activateOk": "Активировано",
|
||||
"deviceList": "Привязанные устройства",
|
||||
"loadingDevices": "Загрузка устройств…",
|
||||
"noDevices": "Нет привязанных устройств",
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"notConnected": "Нет подключения",
|
||||
"connecting": "Подключение...",
|
||||
"resumeHint": "После переподключения вернёмся в {{path}}",
|
||||
"reconnectingResume": "Переподключение и восстановление {{path}}…"
|
||||
"reconnectingResume": "Переподключение и восстановление {{path}}…",
|
||||
"autoReconnecting": "Автопереподключение ({{attempt}}/{{max}})…"
|
||||
},
|
||||
"errors": {
|
||||
"notConnected": "SFTP не подключён",
|
||||
|
||||
@@ -34,7 +34,9 @@
|
||||
"reopenShell": "Открыть оболочку снова",
|
||||
"reopening": "Открытие…",
|
||||
"disconnected": "Отключено",
|
||||
"disconnectedHint": "SSH-сеанс завершился или сеть упала. Один клик ниже — и можно продолжать работу.",
|
||||
"disconnectedHint": "Сеть оборвалась или сервер закрыл SSH. Пока вкладка открыта, будет автопереподключение; можно нажать кнопку ниже.",
|
||||
"disconnectedKeepalive": "Сбой keep-alive, отключено",
|
||||
"disconnectedKeepaliveHint": "SSH keep-alive не ответил — обычно NAT или простой сети. В оболочку клавиши не отправляются. Пока вкладка открыта, будет автопереподключение.",
|
||||
"reconnect": "Переподключиться",
|
||||
"suspended": "Подключение приостановлено",
|
||||
"suspendedHint": "Долгое отсутствие активности; подключение приостановлено для экономии ресурсов",
|
||||
|
||||
@@ -16,10 +16,10 @@
|
||||
"stop": "Dừng",
|
||||
"send": "Gửi",
|
||||
"placeholder": "Tác vụ hoặc theo dõi… (Enter để gửi, Shift+Enter để xuống dòng)",
|
||||
"hintFollowUp": "Các theo dõi bao gồm ngữ cảnh của luồng này",
|
||||
"hintFollowUp": "Câu hỏi tiếp theo kèm tóm tắt luồng này, nội dung quá dài sẽ bị cắt",
|
||||
"hintSelection": "Sẽ bao gồm {{lines}} dòng đã chọn",
|
||||
"hintSteps": "Tối đa {{max}} bước · không lưu gì",
|
||||
"footerNote": "Rủi ro cao cần xác nhận · rủi ro nghiêm trọng bị chặn · phiên không được lưu",
|
||||
"hintSteps": "Tối đa {{max}} bước",
|
||||
"footerNote": "Rủi ro cao cần xác nhận trước · lệnh phá hủy bị từ chối · cuộc trò chuyện không được lưu",
|
||||
"summarize": "Tóm tắt",
|
||||
"summarizeTitle": "AI tóm tắt đầu ra terminal đang hiển thị (ưu tiên vùng chọn), không chạy lệnh nào",
|
||||
"summarizeRequest": "Tóm tắt đầu ra terminal hiện tại"
|
||||
@@ -77,7 +77,7 @@
|
||||
"invalidCommand": "Mô hình không trả về một lệnh một dòng duy nhất; từ chối thực thi",
|
||||
"repeatLoop": "Phát hiện vòng lặp lệnh lặp lại; đã dừng: {{command}}",
|
||||
"criticalBlocked": "Lệnh gặp chặn cứng phá hủy; không có ghi đè: {{command}}",
|
||||
"execFailed": "ssh-exec thất bại: {{error}}",
|
||||
"execFailed": "Thực thi từ xa thất bại: {{error}}",
|
||||
"execUnknown": "Lỗi không xác định",
|
||||
"execNoOutput": "Lệnh hoàn tất không có đầu ra",
|
||||
"maxSteps": "Đã đạt giới hạn {{max}} bước; thu hẹp phạm vi và thử lại",
|
||||
|
||||
@@ -33,8 +33,10 @@
|
||||
"error": {
|
||||
"title": "Đã xảy ra lỗi",
|
||||
"body": "Ứng dụng gặp sự cố bất ngờ",
|
||||
"bodyPane": "Thẻ này gặp sự cố. Các thẻ khác vẫn dùng được.",
|
||||
"recoverable": "Lỗi này có thể khôi phục. Hãy thử làm mới trang.",
|
||||
"refreshPage": "Làm mới trang",
|
||||
"retryPane": "Thử lại thẻ này",
|
||||
"restartApp": "Khởi động lại ứng dụng",
|
||||
"supportHint": "Nếu sự cố vẫn tiếp diễn, hãy kiểm tra nhật ký hoặc liên hệ hỗ trợ"
|
||||
},
|
||||
@@ -104,11 +106,12 @@
|
||||
},
|
||||
"menu": {
|
||||
"closeOthers": "Đóng các tab khác",
|
||||
"closeRight": "Đóng các tab bên phải",
|
||||
"closeLeft": "Đóng các tab bên trái",
|
||||
"closeRight": "Đóng tab bên phải",
|
||||
"closeLeft": "Đóng tab bên trái",
|
||||
"closeAll": "Đóng tất cả",
|
||||
"clone": "Nhân đôi tab",
|
||||
"openSftp": "Mở SFTP"
|
||||
"openSftp": "Mở SFTP",
|
||||
"reconnect": "Kết nối lại"
|
||||
},
|
||||
"renameAria": "Đổi tên thẻ {{title}}",
|
||||
"renameHint": "Nhấn đúp để đổi tên"
|
||||
|
||||
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user