chore: 保存工作副本——客户端授权新契约(双端点/验签/令牌/周检)与桌面会话可靠性改进

This commit is contained in:
Logan.Li committed 2026-09-14 22:10:22 +08:00
1 parent f71eef0eba
commit ae6093c9b3
163 files changed
+4911 -2494

No files matched your search

+115
View File
@@ -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
+9
View File
@@ -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
+13 -2
View File
@@ -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` 才能给别人直接打开。
## 评审边界
+1 -1
View File
@@ -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`
+37 -98
View File
@@ -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
View File
@@ -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(非零退出、循环、注入、敏感输出)
+7 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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. 把已选终端文本、主机标签和系统信息组成最小上下文包,并显示实际发送范围。
+2 -2
View File
@@ -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`。
## 明确不做(本轨道也不做)
+4 -4
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+38
View File
@@ -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[@]}"
+129
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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")));
}
}
+45 -13
View File
@@ -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(),
+34 -11
View File
@@ -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",
),
},
)
}
+195 -80
View File
@@ -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",
),
},
)
}
+10 -8
View File
@@ -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";
}
+29
View File
@@ -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()),
}
}
}
+1 -1
View File
@@ -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,
+8 -1
View File
@@ -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);
}
+1
View File
@@ -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;
+66 -16
View File
@@ -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());
}
+13 -11
View File
@@ -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]
+15 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
)
}
+93 -73
View File
@@ -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>
+26
View File
@@ -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
)
}
+5 -8
View File
@@ -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>
)
}
+23 -19
View File
@@ -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>
)
+16 -30
View File
@@ -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
+26 -28
View File
@@ -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>
)
}
+33 -76
View File
@@ -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>
)
}
+20 -63
View File
@@ -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
+10 -5
View File
@@ -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)
}
+169 -28
View File
@@ -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)}
/>
+49 -128
View File
@@ -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,
})
+12 -2
View File
@@ -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>
)
+5 -2
View File
@@ -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'
+32 -12
View File
@@ -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
)
}
+15 -14
View File
@@ -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>
)
}
+341 -319
View File
@@ -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}
</>
)
+97 -84
View File
@@ -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>
}
+178 -73
View File
@@ -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
View File
@@ -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 */}
+19 -7
View File
@@ -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" />
+2
View File
@@ -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}
+62 -30
View File
@@ -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))
+3 -3
View File
@@ -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
View File
@@ -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])
+35 -3
View File
@@ -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
View File
@@ -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)
}
+4 -4
View File
@@ -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",
+7 -4
View File
@@ -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"
+5 -1
View File
@@ -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",
+11 -2
View File
@@ -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"
}
+5 -2
View File
@@ -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",
+31 -19
View File
@@ -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",
+2 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+4 -4
View File
@@ -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}} ステップの上限に達しました · 範囲を絞って再試行してください",
+4 -1
View File
@@ -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": "ダブルクリックで名前変更"
+5 -1
View File
@@ -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": "リモートに貼り付けました",
+11 -2
View File
@@ -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": "不明なエラーが発生しました"
}
+5 -2
View File
@@ -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",
+44 -32
View File
@@ -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": "紐付けデバイスはありません",
+2 -1
View File
@@ -15,7 +15,8 @@
"notConnected": "未接続",
"connecting": "接続中...",
"resumeHint": "再接続後は {{path}} に戻ります",
"reconnectingResume": "{{path}} を復元して再接続中…"
"reconnectingResume": "{{path}} を復元して再接続中…",
"autoReconnecting": "自動再接続中({{attempt}}/{{max}})…"
},
"errors": {
"notConnected": "SFTP に接続されていません",
+3 -1
View File
@@ -34,7 +34,9 @@
"reopenShell": "シェルを再開",
"reopening": "開いています…",
"disconnected": "切断されました",
"disconnectedHint": "SSH セッション終了またはネットワーク切断。下のボタンで再接続して作業を続けられます。",
"disconnectedHint": "ネットワーク切断、またはサーバーが SSH を閉じました。タブを開いたままなら自動再接続します。下のボタンですぐに再接続もできます。",
"disconnectedKeepalive": "キープアライブ失敗で切断",
"disconnectedKeepaliveHint": "SSH キープアライブに応答がありません。NAT やアイドル切断が典型です。シェルへキー入力は送りません。タブを開いたままなら自動再接続します。",
"reconnect": "再接続",
"suspended": "接続が一時停止",
"suspendedHint": "しばらく操作がなかったため、リソース節約のため接続を一時停止しました",
+4 -4
View File
@@ -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}}단계 한도에 도달했습니다; 범위를 좁히고 재시도하세요",
+4 -1
View File
@@ -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": "더블 클릭해 이름 변경"
+5 -1
View File
@@ -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": "원격에 붙여넣음",
+11 -2
View File
@@ -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": "알 수 없는 오류가 발생했습니다"
}
+5 -2
View File
@@ -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",
+42 -30
View File
@@ -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": "바인딩된 기기 없음",
+2 -1
View File
@@ -15,7 +15,8 @@
"notConnected": "연결되지 않음",
"connecting": "연결 중...",
"resumeHint": "다시 연결하면 {{path}}(으)로 복귀합니다",
"reconnectingResume": "{{path}} 복원하며 다시 연결 중…"
"reconnectingResume": "{{path}} 복원하며 다시 연결 중…",
"autoReconnecting": "자동 재연결 중 ({{attempt}}/{{max}})…"
},
"errors": {
"notConnected": "SFTP가 연결되지 않았습니다",
+3 -1
View File
@@ -34,7 +34,9 @@
"reopenShell": "셸 다시 열기",
"reopening": "여는 중…",
"disconnected": "연결 끊김",
"disconnectedHint": "SSH 세션이 종료되었거나 네트워크가 끊겼습니다. 아래를 누르면 다시 연결해 작업을 이어갑니다.",
"disconnectedHint": "네트워크가 끊겼거나 서버가 SSH를 닫았습니다. 탭이 열려 있으면 자동 재연결되며, 아래 버튼으로 바로 재연결할 수도 있습니다.",
"disconnectedKeepalive": "Keep-alive 실패로 연결 끊김",
"disconnectedKeepaliveHint": "SSH keep-alive에 응답이 없습니다. 대개 NAT나 유휴 네트워크 끊김입니다. 셸에 키 입력을 보내지는 않습니다. 탭이 열려 있으면 자동 재연결됩니다.",
"reconnect": "재연결",
"suspended": "연결 일시 중단됨",
"suspendedHint": "오랫동안 활동이 없어 리소스 절약을 위해 연결이 일시 중단되었습니다",
+4 -4
View File
@@ -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}} шагов; сузьте задачу и повторите",
+7 -4
View File
@@ -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": "Двойной клик — переименовать"
+5 -1
View File
@@ -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": "Вставлено на удалённый сервер",
+11 -2
View File
@@ -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": "Произошла неизвестная ошибка"
}
+5 -2
View File
@@ -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",
+45 -33
View File
@@ -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": "Нет привязанных устройств",
+2 -1
View File
@@ -15,7 +15,8 @@
"notConnected": "Нет подключения",
"connecting": "Подключение...",
"resumeHint": "После переподключения вернёмся в {{path}}",
"reconnectingResume": "Переподключение и восстановление {{path}}…"
"reconnectingResume": "Переподключение и восстановление {{path}}…",
"autoReconnecting": "Автопереподключение ({{attempt}}/{{max}})…"
},
"errors": {
"notConnected": "SFTP не подключён",
+3 -1
View File
@@ -34,7 +34,9 @@
"reopenShell": "Открыть оболочку снова",
"reopening": "Открытие…",
"disconnected": "Отключено",
"disconnectedHint": "SSH-сеанс завершился или сеть упала. Один клик ниже — и можно продолжать работу.",
"disconnectedHint": "Сеть оборвалась или сервер закрыл SSH. Пока вкладка открыта, будет автопереподключение; можно нажать кнопку ниже.",
"disconnectedKeepalive": "Сбой keep-alive, отключено",
"disconnectedKeepaliveHint": "SSH keep-alive не ответил — обычно NAT или простой сети. В оболочку клавиши не отправляются. Пока вкладка открыта, будет автопереподключение.",
"reconnect": "Переподключиться",
"suspended": "Подключение приостановлено",
"suspendedHint": "Долгое отсутствие активности; подключение приостановлено для экономии ресурсов",
+4 -4
View File
@@ -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",
+6 -3
View File
@@ -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