feat(web): enhance web deployment with Playwright integration for end-to-end testing and add CI workflow for automated checks
This commit is contained in:
1 parent
e13f35c764
commit
cf84f0764a
45 files changed
+4448
-1529
No files matched your search
@@ -19,3 +19,6 @@ src-tauri/resources
|
||||
src-tauri/vendor/portable-pty
|
||||
src-tauri/vendor/picky
|
||||
src-tauri/vendor/sspi
|
||||
|
||||
artifacts
|
||||
playwright-report
|
||||
@@ -0,0 +1,93 @@
|
||||
name: Web merge checks
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main, feat/web-deployment]
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: web-checks-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v5
|
||||
with:
|
||||
version: 11.10.0
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm lint
|
||||
- run: pnpm i18n:check && pnpm i18n:keys
|
||||
- run: pnpm web:audit:check
|
||||
- run: pnpm test --run --maxWorkers=4
|
||||
- run: pnpm build:web
|
||||
rust:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: sudo apt-get update && sudo apt-get install -y pkg-config libssl-dev libfontconfig1-dev libfreetype-dev
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: |
|
||||
src-tauri/crates/nyaterm-core -> target
|
||||
src-tauri/crates/nyaterm-web -> target
|
||||
- run: cargo test --manifest-path src-tauri/crates/nyaterm-core/Cargo.toml --locked
|
||||
- run: cargo test --manifest-path src-tauri/crates/nyaterm-web/Cargo.toml --locked
|
||||
desktop:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, macos-latest, ubuntu-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: pnpm/action-setup@v5
|
||||
with:
|
||||
version: 11.10.0
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- name: Linux Tauri dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libssl-dev libudev-dev libfontconfig1-dev libfreetype-dev pkg-config
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: |
|
||||
src-tauri -> target
|
||||
src-tauri/crates/nyaterm-mcp -> target
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build:mcp-sidecar
|
||||
- run: pnpm prepare:conpty
|
||||
if: runner.os == 'Windows'
|
||||
- run: cargo check --manifest-path src-tauri/Cargo.toml --locked
|
||||
deployment:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v5
|
||||
with:
|
||||
version: 11.10.0
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm exec playwright install --with-deps chromium
|
||||
- run: pnpm test:web:e2e
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: web-deployment-failure
|
||||
path: artifacts/web-e2e/
|
||||
retention-days: 7
|
||||
@@ -45,3 +45,7 @@ temp/
|
||||
.env.*
|
||||
nyaterm-web-data/
|
||||
web-test-data/
|
||||
|
||||
# Isolated deployment test traces and container logs
|
||||
artifacts/web-e2e/
|
||||
playwright-report/
|
||||
+1417
-1032
File diff suppressed because it is too large.
Load diff
@@ -40,9 +40,9 @@ Core 和 Web 是两个独立 Cargo crate;Desktop 以 path dependency 引用 Co
|
||||
| `src-tauri/src/{config,storage,utils,core/ai,core/history,core/sftp/util.rs,...}` | 原位置使用 re-export/wrapper,保留 Desktop 调用兼容性。文件删除对应迁入 Core,非删除桌面功能。 |
|
||||
| `src-tauri/src/{cmd/connection.rs,cmd/ai.rs,core/ssh,core/sftp/sftp_backend/session.rs}` | 调用共享服务与协议;原生管理器继续保留。 |
|
||||
| `src-tauri/crates/nyaterm-web/src/{main,lib,auth,state,session,commands,sftp,ai,plugins,error}.rs` | 独立服务、显式命令 allowlist、会话和资源清理。 |
|
||||
| `src-tauri/crates/nyaterm-web/tests/{transport,bootstrap,protocols}.rs` | 真实 SSH/SFTP、Telnet、VNC、代理/跳板 fixture 与进程启动测试。 |
|
||||
| `src-tauri/crates/nyaterm-web/tests/{transport,bootstrap,protocols}.rs` | 真实 SSH/SFTP、Telnet、VNC、代理/跳板 fixture 与进程启动测试。 |
|
||||
| 各 crate Cargo.toml/Cargo.lock、`package.json`、`vite.config.ts` | 依赖、构建入口和 base path。 |
|
||||
| `Dockerfile`、`.dockerignore`、`docker-compose.yml`、`.gitignore` | 三阶段非 root 容器、持久 volume、构建上下文/秘密排除。 |
|
||||
| `Dockerfile`、`.dockerignore`、`docker-compose.yml`、`.gitignore` | 三阶段非 root 容器、持久 volume、构建上下文/秘密排除。 |
|
||||
| `docs/web-deployment.md`、本文 | 操作步骤、能力、安全边界和验证报告。 |
|
||||
|
||||
原生 imports 只在 transport/platform 边界及被 capability 隔离的 RDP Channel 和桌面 VNC Channel 适配器、updater 和 native PluginPanel 资源转换中保留。不会在 Web startup 创建原生 Channel 或加载原生插件。
|
||||
@@ -80,10 +80,10 @@ Core 和 Web 是两个独立 Cargo crate;Desktop 以 path dependency 引用 Co
|
||||
| AI Assistant | provider API/Ask streaming、脱敏、历史;无 Agent、MCP 或本地附件。 |
|
||||
| 插件 | Web target/capability/permission 检查框架;只允许 UI 类权限,未提供安装或执行器。 |
|
||||
| 本地文件、剪贴板、外部链接、子窗口 | 浏览器 File/download、Clipboard、受限链接、同源 iframe 页面替代;浏览器权限仍适用。 |
|
||||
| Local Shell/PTY、Serial、RDP | 不支持;创建入口与服务均 gate。 |
|
||||
| Local Shell/PTY、Serial、RDP | 不支持;创建入口与服务均 gate。 |
|
||||
| tray、原生窗口、全局快捷键、桌面通知、OS credential manager、自动更新 | 不支持;Web 采用服务端加密存储和浏览器页面。 |
|
||||
| SCP/Zmodem、watcher、复杂本地文件集成、录制、远程监控、传输控制、同步备份 | 不支持,集中 capability/panel/settings gate。 |
|
||||
| SSH agent/X11/证书、SSH 启动命令、network_device、非 UTF-8 SSH | 拒绝连接;不能静默改成直接连接或忽略这些配置。 |
|
||||
| SSH agent/X11/证书、SSH 启动命令、network_device、非 UTF-8 SSH | 拒绝连接;不能静默改成直接连接或忽略这些配置。 |
|
||||
| OSC/CWD 自动跟踪、动态标题 | Web 暂不提供;Desktop 原逻辑保留。 |
|
||||
|
||||
补充能力:Telnet/VNC 使用独立 capability,启用 VNC 不会启用 RDP。Telnet 复用 terminal WebSocket,将字符串输入按连接编码转换,二进制输入保留原始字节;服务端输出统一转成 UTF-8。VNC 使用 `/api/sessions/{id}/vnc`:二进制帧与桌面共用 44 字节补丁头,JSON 承载状态、输入和剪贴板。附着时重放完整画面与状态,最多缓存两帧,溢出后从权威帧缓冲重同步,完整帧和增量帧有序发送。只读限制在服务端执行。
|
||||
@@ -98,7 +98,7 @@ cookie 为随机 256 bit、HttpOnly、SameSite=Strict,HTTPS 下 Secure,Path
|
||||
|
||||
session/prompt/AI cancellation 隔离于登录 owner,跨 owner 的 session 返回 404、prompt 返回 403。WS URL 不含 token。SSH unknown/changed key 均要求明确确认后才保存。secret 不进入日志/列表,响应使用 no-store,并设置 CSP/nosniff/referrer policy。Web command 显式 allowlist,不能反射调用任意 Tauri command、访问服务端任意本地路径或执行本地程序。浏览器插件框架不提供服务端文件/进程权限。
|
||||
|
||||
## 验证结果
|
||||
## 历史验证结果
|
||||
|
||||
已完成的验证:
|
||||
|
||||
@@ -116,8 +116,7 @@ session/prompt/AI cancellation 隔离于登录 owner,跨 owner 的 session 返
|
||||
|
||||
## 后续扩展
|
||||
|
||||
优先在 Linux CI 构建和启动 Docker 镜像,加入 Playwright 登录→保存 SSH→指纹确认→xterm→SFTP 的完整浏览器验收,并在三平台 native runner 验证 Desktop。再把更多 SSH auth/session policy 抽为注入 event sink 的服务,减少两端调度差异。随后实现更细的能力描述与本地化不可用提示、Web 插件 sandbox/runtime、SFTP overwrite/递归/取消控制、OSC/CWD。若需要多人使用,先增加用户、保存资源 ACL、AI history 隔离和审计,再放开多用户入口。
|
||||
|
||||
现已加入只读 Web 合并 CI、三平台桌面编译及 Docker/OpenSSH/Playwright 双路径验收脚本;本机未执行 Docker,实际通过情况须等待 CI,见 [合并准备报告](web-merge-readiness.md)。再把更多 SSH auth/session policy 抽为注入 event sink 的服务,减少两端调度差异。随后实现更细的能力描述与本地化不可用提示、Web 插件 sandbox/runtime、SFTP 递归/完整任务取消控制、OSC/CWD。若需要多人使用,先增加用户、保存资源 ACL、AI history 隔离和审计,再放开多用户入口。
|
||||
|
||||
## Telnet / VNC 增量验证
|
||||
|
||||
@@ -125,3 +124,9 @@ session/prompt/AI cancellation 隔离于登录 owner,跨 owner 的 session 返
|
||||
- Core 包含抽取后的 Telnet/编码/VNC 原有测试,以及分片 IAC、NAWS 转义和慢订阅者完整帧重同步测试。
|
||||
- Core 292 项、vendored VNC 协议库 40 项测试通过。Web 三项集成测试通过;Telnet/VNC/代理/生命周期集成测试连续 10 次通过,覆盖终端结束前最后输出的送达。
|
||||
- 前端全量 158 文件、1003 项测试通过;Web 构建、lint 和四语言格式检查通过,桌面 `cargo check --locked` 通过。Docker CLI 当前不可用,容器实构建及真实远端图形环境手工验收仍待部署环境执行。
|
||||
|
||||
## Web Beta 可靠性增量
|
||||
|
||||
浏览器 SSH/Telnet 由 `BrowserTerminals` 持有每会话连接状态,初次握手和掉线恢复统一执行有时间预算的附着重试,复用 `get_session_info` 查询和原有终端事件,无新增重连协议。跨 WS/SSE 的关闭事件只投递一次。
|
||||
|
||||
Web 上传使用 SFTP lstat 区分存在、目录、权限和连接错误,复用 owner 冲突提示。临时文件和传输许可由 RAII guard 持有直到异步清理结束,显式会话关闭等待清理后才断开 SSH;提示 future 被 HTTP 中断丢弃时仍会移除待响应记录。数据格式保持兼容,新增响应字段见部署文档。
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## 修复进展(2026-10-04)
|
||||
|
||||
本轮已完成主要公共 UI 缺口与浏览器替代流程。276 个命令、462 个调用点及 184 个未匹配命令仍是**原始审计基线**,未重新扫描;附带 `web-api-inventory.json` 也保留基线。不得将基线中“未匹配”当作当前实现状态或错误数量。
|
||||
本轮已完成主要公共 UI 缺口与浏览器替代流程,并追加 Web 文件、上传和附着恢复修复。合并状态和本轮验证以 [Web Beta 合并准备报告](web-merge-readiness.md) 为准。`web-api-inventory.json` 现由 `pnpm web:audit` 重新生成,包含当前调用位置及静态边界分类;下方 276 个命令、462 个调用点及 184 个未匹配命令和末尾表格保留为**原始审计基线**。不得将历史“未匹配”当作当前实现状态或错误数量。
|
||||
|
||||
| 功能 | 已实现行为 |
|
||||
| --- | --- |
|
||||
@@ -27,7 +27,7 @@ SFTP 覆盖和链接目标替换使用 OpenSSH `posix-rename@openssh.com` 原子
|
||||
|
||||
深度终端历史、持久录制、递归传输/完整任务取消、云备份、HTTP MCP、RDP 与 Docker 管理仍需独立服务端设计;本轮保持相关入口隔离。客户端本地终端、当前串口、托盘、系统快捷键、本机 CLI、native plugin 和文件 watcher 继续保持桌面专属。浏览器快捷命令导入支持 JSON/WindTerm,XTS 压缩包导入仍属于桌面流程。Web 尚无终端 CWD 跟踪,CWD 查询返回真实 `null`,不声称目录跟随已恢复。
|
||||
|
||||
### 验证
|
||||
### 前轮验证(历史记录)
|
||||
|
||||
- 共享 Core:355 项单元测试通过,包含迁移后的翻译、监控和快捷命令测试。
|
||||
- Web:4 项集成测试全部通过,覆盖 HTTP 数据契约、配置持久化、真实密码/限速、RFC HOTP 计数、笔记冲突、SSH/SFTP/WS/SSE owner 边界、预览限额、流式复制移动、权限、链接原子替换及独立监控通道。
|
||||
@@ -35,7 +35,7 @@ SFTP 覆盖和链接目标替换使用 OpenSSH `posix-rename@openssh.com` 原子
|
||||
- `pnpm build:web`、TypeScript、`pnpm lint`、四语言格式检查及桌面 `cargo check --locked` 通过。lint 仍有原有 `CommandSuggestions.tsx` hook dependency 警告;构建仍提示较大的 chunk 和旧 Browserslist 数据。
|
||||
- 本轮未进行真实浏览器交互/视觉验收、外部翻译服务实测或 GPU/NPU 硬件实测;自动化覆盖不能替代这些环境验证。
|
||||
|
||||
## 结论
|
||||
## 原始审计结论(历史记录)
|
||||
|
||||
当前问题是接口覆盖、数据契约和能力边界不完整,并不是 Web 性能不足。SSH/Telnet/VNC 的主连接路径已经存在,但公共 UI 中仍有未适配的入口;某些功能虽然不再报 501,却只返回空数据,不能算实现完成。
|
||||
|
||||
|
||||
+23
-3
@@ -1,6 +1,6 @@
|
||||
# Web / Docker 部署
|
||||
|
||||
Web 模式复用现有 React 界面,由 `nyaterm-web` 同时提供静态资源和 HTTP / WebSocket / SSE API。此 MVP 面向一个可信管理员:不同登录的终端、认证提示和运行中的 AI 请求隔离,但保存的连接、凭据、设置、known_hosts 和 AI 历史在同一实例内共享。
|
||||
Web 模式复用现有 React 界面,由 `nyaterm-web` 同时提供静态资源和 HTTP / WebSocket / SSE API。此 Beta 面向一个可信管理员:不同登录的终端、认证提示和运行中的 AI 请求隔离,但保存的连接、凭据、设置、known_hosts 和 AI 历史在同一实例内共享。
|
||||
|
||||
## 本地运行
|
||||
|
||||
@@ -115,10 +115,30 @@ location /nyaterm/ {
|
||||
|
||||
停服后备份整个数据目录,并单独备份加密密钥与登录密码。密钥丢失将无法解密凭据。不要让多个进程同时写同一个 redb volume,也不要把桌面用户数据目录直接作为服务器 volume。保存的私人密钥、密码和 AI provider 密钥使用原有 AES-GCM 存储。AI 历史和连接元数据属于实例共享数据,未作为整个数据库加密。
|
||||
|
||||
支持保存/临时 SSH、Telnet 和 VNC,密码/私钥与手工交互认证、主机指纹确认、终端输入/输出/resize、SFTP 浏览与单文件流式上传/下载、文本编辑、基础 AI Ask 和设置持久化。Telnet 支持原有字符编码、自动登录、本地回显、行编辑、NAWS 和 raw TCP;VNC 支持现有认证与服务器密钥确认、缩放、共享连接、只读、文本剪贴板和重连。三种协议均支持 SOCKS5、HTTP CONNECT 及代理认证、SSH 跳板(最多 8 层,使用跳板自身网络配置)。代理失败不回退到直连。插件部分提供兼容性及权限检查框架,当前没有浏览器插件安装/执行器。
|
||||
支持保存/临时 SSH、Telnet 和 VNC,密码/私钥与手工交互认证、主机指纹确认、终端输入/输出/resize、SFTP 浏览与单文件流式上传/下载、文本编辑、基础 AI Ask、设置持久化以及资源/GPU/NPU/进程监控。监控使用独立 SSH exec 通道,Docker 管理仍不支持。Telnet 支持原有字符编码、自动登录、本地回显、行编辑、NAWS 和 raw TCP;VNC 支持现有认证与服务器密钥确认、缩放、共享连接、只读、文本剪贴板和重连。三种协议均支持 SOCKS5、HTTP CONNECT 及代理认证、SSH 跳板(最多 8 层,使用跳板自身网络配置)。代理失败不回退到直连。插件部分提供兼容性及权限检查框架,当前没有浏览器插件安装/执行器。
|
||||
|
||||
Web 当前不支持本地 Shell、Serial、RDP、ProxyCommand、SSH agent/X11/证书登录、SSH 启动命令、非标准 SSH profile、非 UTF-8 SSH 终端/文件名、SFTP compatibility mode、CWD 自动跟踪、远程监控、录制、SCP/Zmodem、本地文件 watcher、传输暂停/重试、同步备份和 AI Agent/MCP/本地附件。系统托盘、原生窗口、OS credential manager、系统全局快捷键、桌面通知、自动更新也不提供。浏览器文件、剪贴板、链接和 iframe 页面代替相应原生入口,仍受浏览器权限约束。
|
||||
Web 当前不支持本地 Shell、Serial、RDP、ProxyCommand、SSH agent/X11/证书登录、SSH 启动命令、非标准 SSH profile、非 UTF-8 SSH 终端/文件名、SFTP compatibility mode、CWD 自动跟踪、录制、SCP/Zmodem、本地文件 watcher、传输暂停/重试、同步备份和 AI Agent/MCP/本地附件。系统托盘、原生窗口、OS credential manager、系统全局快捷键、桌面通知、自动更新也不提供。浏览器文件、剪贴板、链接和 iframe 页面代替相应原生入口,仍受浏览器权限约束。
|
||||
|
||||
刷新页面时,按工作区 pane ID 重新附着当前登录的有效会话;旧连接租期结束后,保存的连接按原有工作区恢复流程重新创建。SFTP 仅对 SSH 会话提供。浏览器剪贴板需要 HTTPS 和浏览器权限,未授权时显示提示,画面和键鼠仍可使用。网络面板可管理代理及分组,Web 子窗口使用同源 iframe。单镜像部署,无需 noVNC、Guacamole 或额外服务。
|
||||
|
||||
测试与架构见 [web-architecture.md](web-architecture.md)。当前环境未安装 Docker CLI;镜像实构建及浏览器手工验收尚未执行,仍需在部署环境完成。更新后请重新构建镜像并重建容器,旧镜像不会自动获得协议支持。
|
||||
|
||||
## Web Beta 能力矩阵与验收
|
||||
|
||||
| 能力 | Web Beta | 桌面兼容 |
|
||||
| ------------------------------- | ------------------------------------------------------- | ---------------------------------- |
|
||||
| SSH/Telnet | 登录 owner 隔离、输入输出、刷新附着、有限重试恢复 | 原生协议与生命周期保持 |
|
||||
| VNC | 现有 Web 认证、输入、剪贴板及恢复 | 保持 |
|
||||
| 单文件上传/下载 | 流式原始上传;浏览器 Blob 下载并提示失败 | 保持原生传输 |
|
||||
| 同名上传 | ask/skip/rename/overwrite;POSIX 原子覆盖,拒绝目录覆盖 | 共用 duplicate_strategy 设置 |
|
||||
| 文件编辑 | 默认内部编辑器,工作区或 iframe;二进制/不支持编码下载 | 保留外部编辑器、watcher 和本地路径 |
|
||||
| 传输设置 | 仅冲突策略与内部编辑显示方式 | 隐藏字段完整保留,无迁移 |
|
||||
| 目录传输、调优、暂停/重试 | 未实现,隐藏入口;含目录选择禁用下载 | 保持 |
|
||||
| 资源/GPU/NPU/进程监控 | 独立 SSH exec 与白名单进程信号 | 保持 |
|
||||
| RDP、录制、云同步、AI Agent/MCP | 当前不支持 | 保持 |
|
||||
|
||||
上传继续使用 `POST api/sessions/{id}/upload?path=...` 和原始文件正文;返回 `{ "bytes": 123, "status": "completed", "path": "/final/path" }`。跳过返回 bytes=0、status=skipped;改名返回 UUID 后缀的最终 path。覆盖依赖远端 `posix-rename@openssh.com`;不支持时明确失败并保留原文件,不回退到先删后写。
|
||||
|
||||
SSH/Telnet 初次附着及断线恢复的每轮预算为 25 秒,单次握手最多 5 秒;退避依次 1、2、4 秒,之后不超过 5 秒。握手失败会查询当前会话;401/404 停止恢复,其余临时失败继续到预算结束。显式关闭、注销、服务端关闭均取消恢复。注销从 File 菜单进入。
|
||||
|
||||
本轮检查结果、只读 CI 和真实 Docker/OpenSSH/Playwright 验收命令见 [Web Beta 合并准备](web-merge-readiness.md)。本机尚未执行实际 Docker/Chromium,不能以接口 fixture 测试代替部署验收。
|
||||
@@ -0,0 +1,53 @@
|
||||
# Web Beta 合并准备(2026-10-04)
|
||||
|
||||
范围是单管理员 Web Beta;共享设置、连接、凭据和历史仍属于同一实例。当前修复可进入代码审查,但合并结论仍为**待验证**:必须等待新增 CI 的 Linux/macOS 桌面编译和真实 Docker/Chromium 部署验收通过。本轮没有提交、推送、合并或发布镜像。
|
||||
|
||||
## 修复结果
|
||||
|
||||
| 项目 | 当前行为 |
|
||||
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 文件打开 | Web 默认使用内部编辑器,保留工作区/子窗口选择。共享的桌面外部编辑器设置不会被覆盖。二进制及不支持的编码先提示,再浏览器下载。 |
|
||||
| 文件入口 | 工具栏、树形/列表条目和背景菜单隐藏目录上传、目录下载、外部编辑器;含目录的混合选择禁用下载。普通目录管理不受影响。 |
|
||||
| 设置 | Web 传输页只展示同名冲突策略、内部编辑器显示方式,更新时保留所有隐藏桌面字段。 |
|
||||
| 错误反馈 | 上传、下载和打开失败显示 toast 并记录日志;下载先检查响应并接收完整 Blob,再交给浏览器保存,因此下载内容会占用浏览器内存。 |
|
||||
| 同名上传 | skip 不消费请求体、不写文件;rename 返回 UUID 后缀路径;ask 使用登录 owner 的已有 SSE 提示;overwrite 只使用 POSIX 原子替换。目录拒绝覆盖;原子替换不支持时保留原文件并失败。 |
|
||||
| 取消与清理 | 冲突等待、接收和提交受取消/超时约束。HTTP 中断清理提示及临时文件;显式关闭先取消传输,等待清理最多 6 秒,再断开 SSH。远端已失联或拒绝删除时只能尝试清理,不能保证远端执行成功。 |
|
||||
| SSH/Telnet 附着 | 每会话拥有 socket、解码器、取消控制器及关闭标记。初次附着和恢复共用单一重试流程,预算 25 秒,握手 5 秒,退避 1/2/4/5 秒;查询返回 401/404 即结束。关闭/注销停止重试,旧连接回调被隔离,关闭事件去重。 |
|
||||
| 上传契约 | 保留原始流式正文、URL 和 bytes;响应增加 status 与最终 path。浏览器文件和剪贴板上传处理跳过及改名。 |
|
||||
| 注销 | Web 的 File 菜单提供注销,复用既有退出和文件未保存处理流程。 |
|
||||
|
||||
## 自动化验收
|
||||
|
||||
`web-merge-checks.yml` 只有 `contents: read` 权限,在 PR、main、feat/web-deployment 和手动运行时执行:冻结锁文件安装、全量 Vitest、Web 构建、lint、四语言格式及键检查、接口清单一致性、Core/Web Rust 测试、Windows/macOS/Linux 桌面编译,以及 Docker/Chromium 验收。它不发布镜像,亦不需要仓库部署秘密。
|
||||
|
||||
执行真实部署验收:
|
||||
|
||||
```sh
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm exec playwright install --with-deps chromium
|
||||
pnpm test:web:e2e
|
||||
```
|
||||
|
||||
脚本为 `/` 和 `/nyaterm/` 分别构建生产 Docker 镜像,运行独立 OpenSSH/SFTP 容器及临时数据卷,生成临时凭据。用 Chromium 执行登录、指纹确认、终端输入输出、刷新附着、文本编辑保存、上传冲突/改名、二进制及普通下载、设置和编辑器子窗口、注销。失败保留 `artifacts/web-e2e/` 的 trace、截图和容器日志,finally 只删除脚本自己创建的容器、数据卷、网络、镜像标签及秘密目录。
|
||||
|
||||
本机 Rust 统一使用 `D:/nyaterm-build/web-merge`,没有清理用户的数据或构建目录。检查记录:
|
||||
|
||||
| 检查 | 本机结果 |
|
||||
| ----------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| 冻结锁文件安装 | `pnpm install --frozen-lockfile --offline` 通过 |
|
||||
| 前端全量 | 163 个文件、1040 项测试通过;新增/相关 7 个文件的 53 项回归通过 |
|
||||
| 前端构建与静态检查 | `/` 与 `/nyaterm/` 构建、TypeScript、lint、四语言格式检查通过;四语言 3054 个逻辑键一致 |
|
||||
| Rust Core/Web | Core 355 项单元测试通过,Web 4 个集成测试文件通过,包含新增上传冲突、权限、owner、中断及清理断言 |
|
||||
| 桌面 Windows | `cargo check --manifest-path src-tauri/Cargo.toml --locked` 通过 |
|
||||
| 部署脚本 | Node 语法检查、Playwright Chromium 测试发现通过;实际 Docker/Chromium **未验证**,本机没有 Docker CLI |
|
||||
| CI / macOS / Linux 桌面 | 工作流已加入,尚未在远端执行,**未验证** |
|
||||
|
||||
全量前端早期两轮与 Rust 编译并行时出现既有 AI 设置测试超时;该文件单独重跑通过,最终在无 Rust 编译并行负载下全量通过。CI 将前端与 Rust 分到独立 runner,前端限制 4 个 worker。lint 留有既有 CommandSuggestions hook dependency 警告,构建仍有 chunk 大小和 Browserslist 数据提示。
|
||||
|
||||
所有合并检查通过前,维持“待验证”结论。
|
||||
|
||||
## 接口覆盖与历史基线
|
||||
|
||||
`pnpm web:audit` 使用 TypeScript AST 扫描生产 invoke 调用及条件分支字面量,结合浏览器适配器和 Rust 显式 allowlist,生成 `docs/web-api-inventory.json`;`pnpm web:audit:check` 检查清单是否与源码一致。动态表达式仍保留供人工检查。
|
||||
|
||||
清单中的 desktop-or-unmatched 包括已被能力隔离的桌面命令,不能当作 Web 缺陷数;server-allowlist 包括兼容占位接口,也不能当作端到端通过数。旧兼容审计中的 276/462/184 属于历史快照,其命令表和行号不再代表当前覆盖。当前功能范围见部署文档的能力矩阵。
|
||||
@@ -0,0 +1,278 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const en = JSON.parse(
|
||||
readFileSync(new URL("../src/i18n/locales/en.json", import.meta.url), "utf8"),
|
||||
);
|
||||
const t = (key) => key.split(".").reduce((value, part) => value[part], en);
|
||||
|
||||
test("single administrator: login, SSH, refresh, editor, conflicts, download, child settings, logout", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
if (
|
||||
!process.env.NYATERM_E2E_PASSWORD ||
|
||||
!process.env.NYATERM_E2E_SSH_PASSWORD
|
||||
)
|
||||
throw new Error(
|
||||
"Run pnpm test:web:e2e to provision disposable Docker credentials.",
|
||||
);
|
||||
const base = process.env.NYATERM_E2E_URL;
|
||||
const errors = [],
|
||||
output = [];
|
||||
page.on("pageerror", (error) => errors.push(String(error)));
|
||||
page.on("websocket", (socket) => {
|
||||
if (socket.url().endsWith("/terminal"))
|
||||
socket.on("framereceived", ({ payload }) => {
|
||||
if (Buffer.isBuffer(payload)) output.push(payload);
|
||||
});
|
||||
});
|
||||
const terminalOutput = () => Buffer.concat(output).toString("utf8");
|
||||
try {
|
||||
await page.goto(base);
|
||||
await page
|
||||
.getByLabel(t("web.password"))
|
||||
.fill(process.env.NYATERM_E2E_PASSWORD);
|
||||
await page
|
||||
.getByRole("button", { name: t("web.signIn"), exact: true })
|
||||
.click();
|
||||
await expect(page.getByLabel(t("web.password"))).toHaveCount(0);
|
||||
const auth = await (
|
||||
await page.request.get(new URL("api/auth/session", base).href)
|
||||
).json();
|
||||
const headers = {
|
||||
"X-Nyaterm-Csrf": auth.csrf,
|
||||
"X-Nyaterm-Request": "1",
|
||||
Origin: new URL(base).origin,
|
||||
};
|
||||
const command = async (name, args = {}) => {
|
||||
const response = await page.request.post(
|
||||
new URL(`api/commands/${name}`, base).href,
|
||||
{ headers, data: args },
|
||||
);
|
||||
expect(response.ok(), `${name}: ${await response.text()}`).toBe(true);
|
||||
return response.json();
|
||||
};
|
||||
const settings = await command("get_app_settings");
|
||||
settings.ui.language = "en";
|
||||
settings.ui.file_explorer_view_mode = "list";
|
||||
settings.ui.active_left_panel = "fileExplorer";
|
||||
settings.ui.active_right_panel = "savedConnections";
|
||||
settings.ui.left_open_panels = ["fileExplorer"];
|
||||
settings.ui.right_open_panels = ["savedConnections"];
|
||||
settings.transfer.editor_type = "external"; // Web must override this without persisting a change.
|
||||
settings.transfer.internal_editor_display = "workspace";
|
||||
settings.transfer.duplicate_strategy = "ask";
|
||||
await command("save_app_settings", { settings });
|
||||
await command("save_connection", {
|
||||
connection: {
|
||||
id: "",
|
||||
name: "E2E OpenSSH",
|
||||
type: "ssh",
|
||||
host: "ssh-fixture",
|
||||
port: 22,
|
||||
username: "test",
|
||||
auth: {
|
||||
mode: "password",
|
||||
password: process.env.NYATERM_E2E_SSH_PASSWORD,
|
||||
},
|
||||
},
|
||||
});
|
||||
await page.reload();
|
||||
await page.getByText("E2E OpenSSH", { exact: true }).first().dblclick();
|
||||
await expect(
|
||||
page.getByText(t("settings.hostKeyVerifyTitle"), { exact: true }),
|
||||
).toBeVisible();
|
||||
await page
|
||||
.getByRole("button", {
|
||||
name: t("settings.hostKeyVerifyAccept"),
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await command("get_sessions")).filter((session) => session.connected)
|
||||
.length,
|
||||
)
|
||||
.toBe(1);
|
||||
const sessionId = (await command("get_sessions"))[0].id;
|
||||
expect(sessionId).toBeTruthy();
|
||||
async function typeCommand(marker) {
|
||||
const terminal = page.locator(".xterm-helper-textarea").first();
|
||||
await terminal.focus();
|
||||
await terminal.pressSequentially(`printf 'WEB_%s\\n' '${marker}'`, {
|
||||
delay: 5,
|
||||
});
|
||||
await terminal.press("Enter");
|
||||
await expect.poll(terminalOutput).toContain(`WEB_${marker}`);
|
||||
}
|
||||
await typeCommand("READY");
|
||||
await expect
|
||||
.poll(async () => (await command("get_app_settings")).ui.open_tabs.length)
|
||||
.toBeGreaterThan(0);
|
||||
await page.reload();
|
||||
await expect(page.locator(".xterm-helper-textarea").first()).toBeAttached();
|
||||
await typeCommand("REFRESHED");
|
||||
expect((await command("get_sessions"))[0].id).toBe(sessionId);
|
||||
|
||||
const readme = page.getByText("readme.txt", { exact: true }).first();
|
||||
await readme.dblclick();
|
||||
const editor = page.locator(".cm-content").first();
|
||||
await expect(editor).toContainText("fixture text");
|
||||
await editor.fill("edited through Chromium\n");
|
||||
await page
|
||||
.getByRole("button", { name: t("fileEditor.save"), exact: true })
|
||||
.click();
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(
|
||||
await command("open_remote_file_text", {
|
||||
sessionId,
|
||||
path: "/home/test/readme.txt",
|
||||
})
|
||||
).file.content,
|
||||
)
|
||||
.toBe("edited through Chromium\n");
|
||||
expect((await command("get_app_settings")).transfer.editor_type).toBe(
|
||||
"external",
|
||||
);
|
||||
|
||||
const binaryDownload = page.waitForEvent("download");
|
||||
await page.getByText("binary.bin", { exact: true }).first().dblclick();
|
||||
const binary = await binaryDownload;
|
||||
expect(readFileSync(await binary.path())).toEqual(
|
||||
Buffer.from([0, 1, 2, 3]),
|
||||
);
|
||||
await expect(
|
||||
page.getByText(t("fileExplorer.webUnsupportedDownload")),
|
||||
).toBeVisible();
|
||||
|
||||
async function upload(content) {
|
||||
await page
|
||||
.getByRole("button", { name: t("fileExplorer.upload"), exact: true })
|
||||
.click();
|
||||
const chooser = page.waitForEvent("filechooser");
|
||||
await page
|
||||
.getByRole("menuitem", { name: t("fileExplorer.upload"), exact: true })
|
||||
.click();
|
||||
await (
|
||||
await chooser
|
||||
).setFiles({
|
||||
name: "readme.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from(content),
|
||||
});
|
||||
}
|
||||
for (const action of ["duplicateSkip", "duplicateOverwrite"]) {
|
||||
const response = page.waitForResponse((response) =>
|
||||
response.url().includes(`/sessions/${sessionId}/upload?`),
|
||||
);
|
||||
await upload("browser upload\n");
|
||||
await page
|
||||
.getByRole("button", { name: t(`fileTransfer.${action}`), exact: true })
|
||||
.click();
|
||||
const result = await (await response).json();
|
||||
expect(result.status).toBe(
|
||||
action === "duplicateSkip" ? "skipped" : "completed",
|
||||
);
|
||||
expect(result.path).toBe("/home/test/readme.txt");
|
||||
const saved = await command("open_remote_file_text", {
|
||||
sessionId,
|
||||
path: result.path,
|
||||
});
|
||||
expect(saved.file.content).toBe(
|
||||
action === "duplicateSkip"
|
||||
? "edited through Chromium\n"
|
||||
: "browser upload\n",
|
||||
);
|
||||
}
|
||||
const current = await command("get_app_settings");
|
||||
current.transfer.duplicate_strategy = "rename";
|
||||
await command("save_app_settings", { settings: current });
|
||||
const renamedResponse = page.waitForResponse((response) =>
|
||||
response.url().includes(`/sessions/${sessionId}/upload?`),
|
||||
);
|
||||
await upload("renamed browser upload\n");
|
||||
const renamed = await (await renamedResponse).json();
|
||||
expect(renamed.path).toMatch(/^\/home\/test\/readme\.txt\.[0-9a-f-]{36}$/);
|
||||
expect(
|
||||
(
|
||||
await command("open_remote_file_text", {
|
||||
sessionId,
|
||||
path: renamed.path,
|
||||
})
|
||||
).file.content,
|
||||
).toBe("renamed browser upload\n");
|
||||
const downloaded = page.waitForEvent("download");
|
||||
await page
|
||||
.getByText("readme.txt", { exact: true })
|
||||
.first()
|
||||
.click({ button: "right" });
|
||||
await page
|
||||
.getByRole("menuitem", {
|
||||
name: t("fileExplorer.cmDownload"),
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
expect(readFileSync(await (await downloaded).path()).toString()).toBe(
|
||||
"browser upload\n",
|
||||
);
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: t("settings.title"), exact: true })
|
||||
.click();
|
||||
const child = page.frameLocator('iframe[src*="window=settings"]');
|
||||
await child
|
||||
.getByRole("button", { name: t("settings.transfer"), exact: true })
|
||||
.click();
|
||||
await expect(child.getByRole("combobox")).toHaveCount(2);
|
||||
await expect(
|
||||
child.getByText(t("settings.downloadPath"), { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await child.getByRole("combobox").nth(1).click();
|
||||
await child
|
||||
.getByRole("option", {
|
||||
name: t("settings.internalEditorDisplayWindow"),
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await child
|
||||
.getByRole("button", { name: t("common.confirm"), exact: true })
|
||||
.click();
|
||||
await expect(page.locator('iframe[src*="window=settings"]')).toHaveCount(0);
|
||||
await page
|
||||
.getByText(renamed.path.split("/").pop(), { exact: true })
|
||||
.first()
|
||||
.dblclick();
|
||||
const fileChild = page.frameLocator('iframe[src*="window=file-editor"]');
|
||||
await expect(fileChild.locator(".cm-content")).toContainText(
|
||||
"renamed browser upload",
|
||||
);
|
||||
await fileChild
|
||||
.getByRole("button", { name: t("common.close"), exact: true })
|
||||
.last()
|
||||
.click();
|
||||
await expect(page.locator('iframe[src*="window=file-editor"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
await page.getByText(t("menu.file"), { exact: true }).first().click();
|
||||
await page.getByText(t("web.signOut"), { exact: true }).click();
|
||||
const quit = page.getByRole("button", {
|
||||
name: t("dialog.confirmCloseAction"),
|
||||
exact: true,
|
||||
});
|
||||
if (await quit.isVisible()) await quit.click();
|
||||
await expect(page.getByLabel(t("web.password"))).toBeVisible();
|
||||
expect(
|
||||
(await page.request.get(new URL("api/auth/session", base).href)).status(),
|
||||
).toBe(401);
|
||||
expect(errors).toEqual([]);
|
||||
} finally {
|
||||
await testInfo.attach("browser-errors", {
|
||||
body: errors.join("\n"),
|
||||
contentType: "text/plain",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "deployment.spec.mjs",
|
||||
timeout: 90_000,
|
||||
expect: { timeout: 15_000 },
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
outputDir:
|
||||
process.env.NYATERM_E2E_ARTIFACTS ?? "../artifacts/web-e2e/results",
|
||||
reporter: [["list"]],
|
||||
use: {
|
||||
baseURL: process.env.NYATERM_E2E_URL ?? "http://localhost:18080/",
|
||||
browserName: "chromium",
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends openssh-server \
|
||||
&& rm -rf /var/lib/apt/lists/* && useradd --create-home --shell /bin/bash test \
|
||||
&& mkdir -p /run/sshd && rm -f /etc/ssh/ssh_host_* \
|
||||
&& printf 'PasswordAuthentication yes\nPermitRootLogin no\nUsePAM no\nSubsystem sftp internal-sftp\n' > /etc/ssh/sshd_config
|
||||
COPY start.sh /start.sh
|
||||
RUN sed -i 's/\r$//' /start.sh && chmod +x /start.sh
|
||||
VOLUME ["/home/test"]
|
||||
EXPOSE 22
|
||||
ENTRYPOINT ["/start.sh"]
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
printf 'test:%s\n' "$(cat /run/secrets/ssh-password)" | chpasswd
|
||||
ssh-keygen -A
|
||||
printf 'fixture text\n' > /home/test/readme.txt
|
||||
printf '\000\001\002\003' > /home/test/binary.bin
|
||||
mkdir -p /home/test/folder
|
||||
chown -R test:test /home/test
|
||||
exec /usr/sbin/sshd -D -e
|
||||
+6
-1
@@ -45,7 +45,11 @@
|
||||
"build:web": "tsc && vite build",
|
||||
"web:build:server": "cargo build --manifest-path src-tauri/crates/nyaterm-web/Cargo.toml --release --locked",
|
||||
"web:serve": "cargo run --manifest-path src-tauri/crates/nyaterm-web/Cargo.toml --locked",
|
||||
"test:web": "cargo test --manifest-path src-tauri/crates/nyaterm-web/Cargo.toml --locked"
|
||||
"test:web": "cargo test --manifest-path src-tauri/crates/nyaterm-web/Cargo.toml --locked",
|
||||
"i18n:keys": "node scripts/check-i18n-keys.mjs",
|
||||
"test:web:e2e": "node scripts/web-e2e.mjs",
|
||||
"web:audit": "node scripts/web-audit.mjs",
|
||||
"web:audit:check": "node scripts/web-audit.mjs --check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.3",
|
||||
@@ -116,6 +120,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.2",
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
|
||||
Generated
+28
@@ -207,6 +207,9 @@ importers:
|
||||
'@biomejs/biome':
|
||||
specifier: ^2.4.2
|
||||
version: 2.4.2
|
||||
'@playwright/test':
|
||||
specifier: ^1.63.0
|
||||
version: 1.63.0
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.18
|
||||
version: 4.1.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0))
|
||||
@@ -3128,6 +3131,11 @@ packages:
|
||||
resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@playwright/test@1.63.0':
|
||||
resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
'@pnpm/config.env-replace@1.1.0':
|
||||
resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==}
|
||||
engines: {node: '>=12.22.0'}
|
||||
@@ -7566,6 +7574,16 @@ packages:
|
||||
resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
playwright-core@1.63.0:
|
||||
resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.63.0:
|
||||
resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
postcss-attribute-case-insensitive@7.0.1:
|
||||
resolution: {integrity: sha512-Uai+SupNSqzlschRyNx3kbCTWgY/2hcwtHEI/ej2LJWc9JJ77qKgGptd8DHwY1mXtZ7Aoh4z4yxfwMBue9eNgw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -13415,6 +13433,10 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
tsyringe: 4.10.0
|
||||
|
||||
'@playwright/test@1.63.0':
|
||||
dependencies:
|
||||
playwright: 1.63.0
|
||||
|
||||
'@pnpm/config.env-replace@1.1.0': {}
|
||||
|
||||
'@pnpm/network.ca-file@1.0.2':
|
||||
@@ -18226,6 +18248,12 @@ snapshots:
|
||||
pvutils: 1.1.5
|
||||
tslib: 2.8.1
|
||||
|
||||
playwright-core@1.63.0: {}
|
||||
|
||||
playwright@1.63.0:
|
||||
dependencies:
|
||||
playwright-core: 1.63.0
|
||||
|
||||
postcss-attribute-case-insensitive@7.0.1(postcss@8.5.6):
|
||||
dependencies:
|
||||
postcss: 8.5.6
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
function keys(value, prefix = "") {
|
||||
return Object.entries(value)
|
||||
.flatMap(([key, child]) => {
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
return typeof child === "object" && child !== null
|
||||
? keys(child, path)
|
||||
: [path];
|
||||
})
|
||||
.map((key) => key.replace(/_(zero|one|two|few|many|other|plural)$/, ""))
|
||||
.filter((key, index, all) => all.indexOf(key) === index)
|
||||
.sort();
|
||||
}
|
||||
const locales = ["en", "zh-CN", "zh-TW", "ko"];
|
||||
const reference = keys(
|
||||
JSON.parse(readFileSync(`src/i18n/locales/en.json`, "utf8")),
|
||||
);
|
||||
for (const locale of locales.slice(1)) {
|
||||
const current = keys(
|
||||
JSON.parse(readFileSync(`src/i18n/locales/${locale}.json`, "utf8")),
|
||||
);
|
||||
const missing = reference.filter((key) => !current.includes(key));
|
||||
const extra = current.filter((key) => !reference.includes(key));
|
||||
if (missing.length || extra.length)
|
||||
throw new Error(
|
||||
`${locale}: missing ${missing.join(", ")}; extra ${extra.join(", ")}`,
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`All four locales have the same ${reference.length} translation keys.`,
|
||||
);
|
||||
@@ -0,0 +1,142 @@
|
||||
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { posix } from "node:path";
|
||||
import ts from "typescript";
|
||||
|
||||
const serverRoot = "src-tauri/crates/nyaterm-web/src";
|
||||
const server = new Set();
|
||||
function literals(text) {
|
||||
return [...text.matchAll(/"([a-z][a-z0-9_]+)"/g)].map((match) => match[1]);
|
||||
}
|
||||
const commands = readFileSync(`${serverRoot}/commands.rs`, "utf8");
|
||||
for (const match of commands.matchAll(
|
||||
/^ {8}((?:"[a-z][a-z0-9_]+"\s*\|\s*)*"[a-z][a-z0-9_]+")\s*=>/gm,
|
||||
)) {
|
||||
for (const name of literals(match[1])) server.add(name);
|
||||
}
|
||||
for (const module of [
|
||||
...commands.matchAll(/crate::(\w+)::supports\(command\)/g),
|
||||
].map((match) => match[1])) {
|
||||
const source = readFileSync(`${serverRoot}/${module}.rs`, "utf8");
|
||||
const start = source.indexOf("pub fn supports(");
|
||||
const end = source.indexOf("\n}", start) + 2;
|
||||
if (start < 0 || end < 2)
|
||||
throw new Error(`Cannot locate ${module} allowlist`);
|
||||
for (const name of literals(source.slice(start, end))) server.add(name);
|
||||
}
|
||||
const http = ts.createSourceFile(
|
||||
"http.ts",
|
||||
readFileSync("src/lib/backend/http.ts", "utf8"),
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
const browser = new Set();
|
||||
function collectBrowser(node) {
|
||||
if (ts.isCaseClause(node) && ts.isStringLiteral(node.expression))
|
||||
browser.add(node.expression.text);
|
||||
if (
|
||||
ts.isCallExpression(node) &&
|
||||
ts.isPropertyAccessExpression(node.expression) &&
|
||||
node.expression.name.text === "includes" &&
|
||||
node.arguments[0]?.getText(http) === "command" &&
|
||||
ts.isArrayLiteralExpression(node.expression.expression)
|
||||
) {
|
||||
for (const entry of node.expression.expression.elements)
|
||||
if (ts.isStringLiteral(entry)) browser.add(entry.text);
|
||||
}
|
||||
if (
|
||||
ts.isBinaryExpression(node) &&
|
||||
node.left.getText(http) === "command" &&
|
||||
ts.isStringLiteral(node.right) &&
|
||||
node.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken
|
||||
)
|
||||
browser.add(node.right.text);
|
||||
ts.forEachChild(node, collectBrowser);
|
||||
}
|
||||
collectBrowser(http);
|
||||
const calls = new Map(),
|
||||
dynamic = [];
|
||||
function walk(directory) {
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true }).sort(
|
||||
(a, b) => a.name.localeCompare(b.name, "en"),
|
||||
)) {
|
||||
const file = posix.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (file !== "src/test") walk(file);
|
||||
continue;
|
||||
}
|
||||
if (!/\.tsx?$/.test(file) || /\.(test|spec)\.tsx?$/.test(file)) continue;
|
||||
const source = ts.createSourceFile(
|
||||
file,
|
||||
readFileSync(file, "utf8"),
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
const visit = (node) => {
|
||||
if (
|
||||
ts.isCallExpression(node) &&
|
||||
ts.isIdentifier(node.expression) &&
|
||||
/^(invoke|tauriInvoke|httpInvoke)$/.test(node.expression.text) &&
|
||||
node.arguments[0]
|
||||
) {
|
||||
const arg = node.arguments[0],
|
||||
location = {
|
||||
file,
|
||||
line:
|
||||
source.getLineAndCharacterOfPosition(node.getStart(source)).line +
|
||||
1,
|
||||
};
|
||||
const add = (name) => {
|
||||
if (!calls.has(name)) calls.set(name, []);
|
||||
calls.get(name).push(location);
|
||||
};
|
||||
if (ts.isStringLiteralLike(arg)) add(arg.text);
|
||||
else {
|
||||
dynamic.push({ ...location, expression: arg.getText(source) });
|
||||
// Conditional literals are enumerated while retaining the dynamic call for review.
|
||||
if (ts.isConditionalExpression(arg))
|
||||
for (const branch of [arg.whenTrue, arg.whenFalse])
|
||||
if (ts.isStringLiteralLike(branch)) add(branch.text);
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(source);
|
||||
}
|
||||
}
|
||||
walk("src");
|
||||
const entries = [...calls]
|
||||
.sort(([a], [b]) => a.localeCompare(b, "en"))
|
||||
.map(([command, locations]) => ({
|
||||
command,
|
||||
boundary: browser.has(command)
|
||||
? "browser-adapter"
|
||||
: server.has(command)
|
||||
? "server-allowlist"
|
||||
: "desktop-or-unmatched",
|
||||
locations,
|
||||
}));
|
||||
const inventory = {
|
||||
schemaVersion: 2,
|
||||
generator: "pnpm web:audit",
|
||||
scope:
|
||||
"Production frontend invoke calls, conditional literal branches, browser adapter and explicit server allowlists. Static evidence only; capability-gated desktop calls are not Web defects.",
|
||||
counts: Object.fromEntries(
|
||||
["browser-adapter", "server-allowlist", "desktop-or-unmatched"].map(
|
||||
(kind) => [
|
||||
kind,
|
||||
entries.filter((entry) => entry.boundary === kind).length,
|
||||
],
|
||||
),
|
||||
),
|
||||
serverCommands: [...server].sort(),
|
||||
browserCommands: [...browser].sort(),
|
||||
entries,
|
||||
dynamic,
|
||||
};
|
||||
const output = `${JSON.stringify(inventory, null, 2)}\n`,
|
||||
destination = "docs/web-api-inventory.json";
|
||||
if (process.argv.includes("--check")) {
|
||||
if (readFileSync(destination, "utf8") !== output)
|
||||
throw new Error("Web inventory is stale. Run pnpm web:audit.");
|
||||
} else writeFileSync(destination, output);
|
||||
console.log(JSON.stringify(inventory.counts));
|
||||
@@ -0,0 +1,192 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import {
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve, join, relative, basename } from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
const id = `nyaterm-e2e-${randomUUID().slice(0, 8)}`;
|
||||
const secrets = mkdtempSync(join(tmpdir(), `${id}-`));
|
||||
const artifacts = resolve("artifacts/web-e2e");
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
const password = randomBytes(32).toString("base64");
|
||||
const sshPassword = randomBytes(32).toString("base64");
|
||||
for (const [name, value] of Object.entries({
|
||||
login: password,
|
||||
encryption: randomBytes(32).toString("base64"),
|
||||
"ssh-password": sshPassword,
|
||||
})) {
|
||||
writeFileSync(join(secrets, name), value, { mode: 0o644 });
|
||||
}
|
||||
chmodSync(secrets, 0o755); // Container UID 10001 must read only these disposable credentials.
|
||||
const docker = (args, capture = false, optional = false) => {
|
||||
const result = spawnSync("docker", args, {
|
||||
encoding: "utf8",
|
||||
stdio: capture ? "pipe" : "inherit",
|
||||
});
|
||||
if (!optional && (result.error || result.status !== 0))
|
||||
throw new Error(`Docker ${args[0]} failed`);
|
||||
return (
|
||||
(result.stdout ?? "") + (args[0] === "logs" ? (result.stderr ?? "") : "")
|
||||
);
|
||||
};
|
||||
const mount = (name, target) => [
|
||||
"--mount",
|
||||
`type=bind,src=${join(secrets, name)},dst=/run/secrets/${target},readonly`,
|
||||
];
|
||||
const failures = [];
|
||||
const containers = [],
|
||||
volumes = [],
|
||||
images = [];
|
||||
let networkCreated = false;
|
||||
async function freePort() {
|
||||
const server = createServer();
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const port = server.address().port;
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
return port;
|
||||
}
|
||||
try {
|
||||
docker(["version"], true);
|
||||
docker(["network", "create", id]);
|
||||
networkCreated = true;
|
||||
docker(["build", "-t", `${id}-ssh`, "e2e/ssh"]);
|
||||
images.push(`${id}-ssh`);
|
||||
for (const [name, basePath] of [
|
||||
["root", "/"],
|
||||
["subpath", "/nyaterm/"],
|
||||
]) {
|
||||
const output = join(artifacts, name);
|
||||
mkdirSync(output, { recursive: true });
|
||||
const image = `${id}-${name}`;
|
||||
docker([
|
||||
"build",
|
||||
"--build-arg",
|
||||
`NYATERM_WEB_BASE_PATH=${basePath}`,
|
||||
"-t",
|
||||
image,
|
||||
".",
|
||||
]);
|
||||
images.push(image);
|
||||
const ssh = `${id}-${name}-ssh`,
|
||||
web = `${id}-${name}-web`;
|
||||
const sshVolume = `${ssh}-data`,
|
||||
webVolume = `${web}-data`;
|
||||
for (const volume of [sshVolume, webVolume]) {
|
||||
docker(["volume", "create", volume]);
|
||||
volumes.push(volume);
|
||||
}
|
||||
containers.push(ssh, web);
|
||||
docker([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
ssh,
|
||||
"--network",
|
||||
id,
|
||||
"--network-alias",
|
||||
"ssh-fixture",
|
||||
...mount("ssh-password", "ssh-password"),
|
||||
"--mount",
|
||||
`type=volume,src=${sshVolume},dst=/home/test`,
|
||||
`${id}-ssh`,
|
||||
]);
|
||||
const port = await freePort();
|
||||
const url = `http://localhost:${port}${basePath}`;
|
||||
docker([
|
||||
"run",
|
||||
"-d",
|
||||
"--init",
|
||||
"--name",
|
||||
web,
|
||||
"--network",
|
||||
id,
|
||||
"-p",
|
||||
`127.0.0.1:${port}:8080`,
|
||||
"-e",
|
||||
`NYATERM_WEB_PUBLIC_URL=${url}`,
|
||||
"-e",
|
||||
"NYATERM_WEB_PASSWORD_FILE=/run/secrets/login",
|
||||
"-e",
|
||||
"NYATERM_WEB_ENCRYPTION_KEY_FILE=/run/secrets/encryption",
|
||||
...mount("login", "login"),
|
||||
...mount("encryption", "encryption"),
|
||||
"--mount",
|
||||
`type=volume,src=${webVolume},dst=/data`,
|
||||
"--read-only",
|
||||
"--tmpfs",
|
||||
"/tmp",
|
||||
"--cap-drop",
|
||||
"ALL",
|
||||
"--security-opt",
|
||||
"no-new-privileges:true",
|
||||
image,
|
||||
]);
|
||||
try {
|
||||
const deadline = Date.now() + 60_000;
|
||||
for (;;) {
|
||||
try {
|
||||
if ((await fetch(url, { signal: AbortSignal.timeout(2000) })).ok)
|
||||
break;
|
||||
} catch {}
|
||||
if (Date.now() >= deadline) throw new Error("Web readiness timed out");
|
||||
await delay(500);
|
||||
}
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"node_modules/@playwright/test/cli.js",
|
||||
"test",
|
||||
"--config",
|
||||
"e2e/playwright.config.mjs",
|
||||
],
|
||||
{
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
NYATERM_E2E_URL: url,
|
||||
NYATERM_E2E_PASSWORD: password,
|
||||
NYATERM_E2E_SSH_PASSWORD: sshPassword,
|
||||
NYATERM_E2E_ARTIFACTS: join(output, "results"),
|
||||
},
|
||||
},
|
||||
);
|
||||
if (result.error || result.status !== 0)
|
||||
throw new Error(`Deployment acceptance failed for ${basePath}`);
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
} finally {
|
||||
for (const container of [ssh, web])
|
||||
writeFileSync(
|
||||
join(output, `${container.endsWith("ssh") ? "ssh" : "web"}.log`),
|
||||
docker(["logs", container], true, true),
|
||||
);
|
||||
docker(["rm", "-f", web, ssh], true, true);
|
||||
}
|
||||
}
|
||||
if (failures.length)
|
||||
throw new AggregateError(failures, "Deployment acceptance failed");
|
||||
} finally {
|
||||
for (const container of containers)
|
||||
docker(["rm", "-f", container], true, true);
|
||||
for (const volume of volumes) docker(["volume", "rm", volume], true, true);
|
||||
if (networkCreated) docker(["network", "rm", id], true, true);
|
||||
for (const image of images) docker(["image", "rm", image], true, true);
|
||||
const secretRelative = relative(resolve(tmpdir()), resolve(secrets));
|
||||
if (
|
||||
secretRelative.startsWith("..") ||
|
||||
!basename(secrets).startsWith(`${id}-`)
|
||||
)
|
||||
throw new Error("Refusing to remove an unexpected secrets directory");
|
||||
rmSync(secrets, { recursive: true, force: true });
|
||||
}
|
||||
@@ -477,6 +477,12 @@ pub async fn create(state: Arc<State>, owner: &str, args: Value) -> Result<Strin
|
||||
}
|
||||
pub(crate) async fn finish(state: &State, session: &WebSession) {
|
||||
session.cancel.cancel();
|
||||
// Let cancelled transfers remove their temporary files while SSH is still alive.
|
||||
let _transfers = tokio::time::timeout(
|
||||
Duration::from_secs(6),
|
||||
session.transfers.clone().acquire_many_owned(4),
|
||||
)
|
||||
.await;
|
||||
if let Some(handle) = session.handle.lock().await.take() {
|
||||
let _ = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
|
||||
@@ -565,6 +565,9 @@ async fn copy_entry(state: &Arc<State>, owner: &str, command: &str, args: &Value
|
||||
total: attrs.size.unwrap_or(0),
|
||||
bytes: 0,
|
||||
sftp: target_sftp.clone(),
|
||||
permits: std::iter::once(_source_permit)
|
||||
.chain(_target_permit)
|
||||
.collect(),
|
||||
temporary: None,
|
||||
done: false,
|
||||
last_progress: std::time::Instant::now(),
|
||||
@@ -659,6 +662,7 @@ struct Transfer {
|
||||
total: u64,
|
||||
bytes: u64,
|
||||
sftp: Arc<russh_sftp::client::SftpSession>,
|
||||
permits: Vec<tokio::sync::OwnedSemaphorePermit>,
|
||||
temporary: Option<String>,
|
||||
done: bool,
|
||||
last_progress: std::time::Instant,
|
||||
@@ -679,6 +683,7 @@ impl Transfer {
|
||||
impl Drop for Transfer {
|
||||
fn drop(&mut self) {
|
||||
let sftp = self.sftp.clone();
|
||||
let permits = std::mem::take(&mut self.permits);
|
||||
let temporary = self.temporary.take();
|
||||
let state = self.state.clone();
|
||||
let owner = self.owner.clone();
|
||||
@@ -686,13 +691,13 @@ impl Drop for Transfer {
|
||||
let sid = self.session_id.clone();
|
||||
let done = self.done;
|
||||
tokio::spawn(async move {
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
if let Some(path) = temporary {
|
||||
let _ = sftp.remove_file(path).await;
|
||||
}
|
||||
let _ = sftp.close().await;
|
||||
})
|
||||
.await;
|
||||
// Session shutdown waits for permits, including asynchronous cleanup.
|
||||
let _permits = permits;
|
||||
if let Some(path) = temporary {
|
||||
let _ = tokio::time::timeout(Duration::from_secs(4), sftp.remove_file(path)).await;
|
||||
}
|
||||
// A stalled removal must still allow the SFTP close attempt.
|
||||
let _ = tokio::time::timeout(Duration::from_secs(1), sftp.close()).await;
|
||||
if !done {
|
||||
state
|
||||
.event(
|
||||
@@ -731,6 +736,7 @@ pub async fn download(
|
||||
total,
|
||||
bytes: 0,
|
||||
sftp,
|
||||
permits: vec![permit],
|
||||
temporary: None,
|
||||
done: false,
|
||||
last_progress: std::time::Instant::now(),
|
||||
@@ -738,7 +744,7 @@ pub async fn download(
|
||||
let cancel = session.cancel.clone();
|
||||
use futures_util::StreamExt;
|
||||
let stream = async_stream::try_stream! {
|
||||
let _permit=permit;let mut chunks=ReaderStream::with_capacity(file,64*1024);transfer.progress("started").await;
|
||||
let mut chunks=ReaderStream::with_capacity(file,64*1024);transfer.progress("started").await;
|
||||
loop {
|
||||
let next=tokio::select!{_ = cancel.cancelled()=>Some(Err(std::io::Error::other("Session closed"))),result=tokio::time::timeout(Duration::from_secs(30),chunks.next())=>result.unwrap_or(Some(Err(std::io::Error::other("SFTP download timed out"))))};
|
||||
match next { Some(Ok(bytes))=>{transfer.bytes+=bytes.len() as u64;transfer.progress("progress").await;yield bytes;},Some(Err(error))=>{transfer.finish("error").await;Err(error)?;},None=>break }
|
||||
@@ -785,13 +791,12 @@ pub async fn upload(
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| WebError::bad("Too many transfers"))?;
|
||||
let sftp = Arc::new(open(&session).await?);
|
||||
let transfer_id = uuid::Uuid::new_v4().to_string();
|
||||
let temporary = format!("{}.nyaterm-{}.part", query.path, transfer_id);
|
||||
// Own cleanup before metadata lookup or a potentially long conflict prompt.
|
||||
let mut transfer = Transfer {
|
||||
state,
|
||||
owner: owner.0,
|
||||
session_id: id,
|
||||
id: transfer_id,
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
path: query.path.clone(),
|
||||
direction: "upload",
|
||||
total: request
|
||||
@@ -802,13 +807,61 @@ pub async fn upload(
|
||||
.unwrap_or(0),
|
||||
bytes: 0,
|
||||
sftp: sftp.clone(),
|
||||
temporary: Some(temporary.clone()),
|
||||
permits: vec![_permit],
|
||||
temporary: None,
|
||||
done: false,
|
||||
last_progress: std::time::Instant::now(),
|
||||
};
|
||||
let mut file = sftp.create(&temporary).await?;
|
||||
let mut stream = request.into_body().into_data_stream();
|
||||
let copy = async {
|
||||
let operation = async {
|
||||
let mut overwrite = false;
|
||||
match sftp.symlink_metadata(&transfer.path).await {
|
||||
Ok(existing) => {
|
||||
let strategy = nyaterm_core::config::load_app_settings(&())?
|
||||
.transfer
|
||||
.duplicate_strategy;
|
||||
let strategy = if strategy == "ask" {
|
||||
transfer
|
||||
.state
|
||||
.prompt(
|
||||
&transfer.owner,
|
||||
"transfer-duplicate-request",
|
||||
json!({"sessionId":session.id,"remotePath":transfer.path,
|
||||
"fileName":transfer.path.rsplit('/').next().unwrap_or("file"),
|
||||
"isDirectory":existing.is_dir()}),
|
||||
&session.cancel,
|
||||
)
|
||||
.await?
|
||||
.as_str()
|
||||
.unwrap_or("skip")
|
||||
.to_owned()
|
||||
} else {
|
||||
strategy
|
||||
};
|
||||
match strategy.as_str() {
|
||||
"skip" => {
|
||||
transfer.finish("cancelled").await;
|
||||
return Ok(json!({"bytes":0,"status":"skipped","path":transfer.path}));
|
||||
}
|
||||
"rename" => {
|
||||
transfer.path = format!("{}.{}", transfer.path, uuid::Uuid::new_v4());
|
||||
validate_path(&transfer.path)?;
|
||||
}
|
||||
"overwrite" if !existing.is_dir() => overwrite = true,
|
||||
"overwrite" => {
|
||||
return Err(WebError::bad("Cannot overwrite a directory with a file"));
|
||||
}
|
||||
_ => return Err(WebError::bad("Invalid duplicate strategy")),
|
||||
}
|
||||
}
|
||||
Err(russh_sftp::client::error::Error::Status(status))
|
||||
if status.status_code == russh_sftp::protocol::StatusCode::NoSuchFile => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
let temporary = format!("{}.nyaterm-{}.part", transfer.path, transfer.id);
|
||||
validate_path(&temporary)?;
|
||||
transfer.temporary = Some(temporary.clone());
|
||||
let mut file = sftp.create(&temporary).await?;
|
||||
let mut stream = request.into_body().into_data_stream();
|
||||
transfer.progress("started").await;
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|_| WebError::bad("Upload interrupted"))?;
|
||||
@@ -824,16 +877,31 @@ pub async fn upload(
|
||||
file.shutdown()
|
||||
.await
|
||||
.map_err(|_| WebError::bad("SFTP write failed"))?;
|
||||
sftp.rename(&temporary, &query.path).await?;
|
||||
Ok::<_, WebError>(())
|
||||
if overwrite {
|
||||
sftp.rename_replace(&temporary, &transfer.path)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
WebError::bad(
|
||||
"Atomic upload replacement failed; no non-atomic fallback was attempted",
|
||||
)
|
||||
})?;
|
||||
} else {
|
||||
sftp.rename(&temporary, &transfer.path).await?;
|
||||
}
|
||||
transfer.temporary = None;
|
||||
transfer.total = transfer.bytes;
|
||||
transfer.finish("completed").await;
|
||||
Ok::<_, WebError>(json!({"bytes":transfer.bytes,"status":"completed","path":transfer.path}))
|
||||
};
|
||||
let result = tokio::select! {_ = session.cancel.cancelled()=>Err(WebError::bad("Session closed")),result=tokio::time::timeout(Duration::from_secs(3600),copy)=>result.unwrap_or(Err(WebError::bad("Upload timed out")))};
|
||||
if let Err(error) = result {
|
||||
transfer.finish("error").await;
|
||||
return Err(error);
|
||||
let result = tokio::select! {
|
||||
_ = session.cancel.cancelled() => Err(WebError::bad("Session closed")),
|
||||
result = tokio::time::timeout(Duration::from_secs(3600), operation) => result.unwrap_or(Err(WebError::bad("Upload timed out"))),
|
||||
};
|
||||
match result {
|
||||
Ok(value) => Ok(Json(value)),
|
||||
Err(error) => {
|
||||
transfer.finish("error").await;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
transfer.temporary = None;
|
||||
transfer.total = transfer.bytes;
|
||||
transfer.finish("completed").await;
|
||||
Ok(Json(json!({"bytes":transfer.bytes})))
|
||||
}
|
||||
@@ -71,7 +71,7 @@ impl State {
|
||||
}
|
||||
}
|
||||
pub async fn prompt(
|
||||
&self,
|
||||
self: &Arc<Self>,
|
||||
owner: &str,
|
||||
event: &str,
|
||||
mut payload: Value,
|
||||
@@ -88,6 +88,11 @@ impl State {
|
||||
reply,
|
||||
},
|
||||
);
|
||||
// HTTP disconnects can drop this future before its normal cleanup.
|
||||
let _cleanup = PromptCleanup {
|
||||
state: self.clone(),
|
||||
id: id.clone(),
|
||||
};
|
||||
self.event(owner, event, payload).await;
|
||||
let result = tokio::select! {
|
||||
_ = cancel.cancelled() => Err(WebError::bad("Session creation cancelled")),
|
||||
@@ -127,3 +132,17 @@ impl State {
|
||||
.retain(|_, prompt| prompt.owner != owner);
|
||||
}
|
||||
}
|
||||
|
||||
struct PromptCleanup {
|
||||
state: Arc<State>,
|
||||
id: String,
|
||||
}
|
||||
impl Drop for PromptCleanup {
|
||||
fn drop(&mut self) {
|
||||
let state = self.state.clone();
|
||||
let id = self.id.clone();
|
||||
tokio::spawn(async move {
|
||||
state.prompts.lock().await.remove(&id);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -261,6 +261,9 @@ impl russh_sftp::server::Handler for MemoryFiles {
|
||||
Ok(success(id))
|
||||
}
|
||||
async fn stat(&mut self, id: u32, path: String) -> Result<sf::Attrs, SfCode> {
|
||||
if path == "/denied.bin" {
|
||||
return Err(SfCode::PermissionDenied);
|
||||
}
|
||||
let files = self.files.lock().unwrap();
|
||||
let file = files.get(&path).ok_or(SfCode::NoSuchFile)?;
|
||||
Ok(sf::Attrs {
|
||||
@@ -282,6 +285,9 @@ impl russh_sftp::server::Handler for MemoryFiles {
|
||||
}
|
||||
async fn rename(&mut self, id: u32, old: String, new: String) -> Result<sf::Status, SfCode> {
|
||||
let mut files = self.files.lock().unwrap();
|
||||
if files.contains_key(&new) {
|
||||
return Err(SfCode::Failure);
|
||||
}
|
||||
let bytes = files.remove(&old).ok_or(SfCode::NoSuchFile)?;
|
||||
files.insert(new.clone(), bytes);
|
||||
let mut attributes = self.attributes.lock().unwrap();
|
||||
@@ -382,9 +388,14 @@ impl russh_sftp::server::Handler for MemoryFiles {
|
||||
bytes = &bytes[4 + len..];
|
||||
}
|
||||
assert!(bytes.is_empty());
|
||||
Ok(sf::Packet::Status(
|
||||
self.rename(id, paths[0].clone(), paths[1].clone()).await?,
|
||||
))
|
||||
let mut files = self.files.lock().unwrap();
|
||||
let bytes = files.remove(&paths[0]).ok_or(SfCode::NoSuchFile)?;
|
||||
files.insert(paths[1].clone(), bytes);
|
||||
let mut attributes = self.attributes.lock().unwrap();
|
||||
if let Some(value) = attributes.remove(&paths[0]) {
|
||||
attributes.insert(paths[1].clone(), value);
|
||||
}
|
||||
Ok(sf::Packet::Status(success(id)))
|
||||
}
|
||||
async fn realpath(&mut self, id: u32, _: String) -> Result<sf::Name, SfCode> {
|
||||
Ok(sf::Name {
|
||||
@@ -434,6 +445,41 @@ fn state(host: String, base_path: &str) -> Arc<State> {
|
||||
shutdown: CancellationToken::new(),
|
||||
})
|
||||
}
|
||||
async fn upload_file(
|
||||
app: &Router,
|
||||
state: &State,
|
||||
id: &str,
|
||||
path: &str,
|
||||
owner: &str,
|
||||
csrf: &str,
|
||||
body: Body,
|
||||
) -> (StatusCode, Value) {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/nyaterm/api/sessions/{id}/upload?path={path}"))
|
||||
.header("host", &state.host)
|
||||
.header("origin", &state.origin)
|
||||
.header("cookie", format!("{}={owner}", auth::COOKIE))
|
||||
.header("x-nyaterm-csrf", csrf)
|
||||
.body(body)
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let bytes = to_bytes(response.into_body(), 4096).await.unwrap();
|
||||
(status, serde_json::from_slice(&bytes).unwrap())
|
||||
}
|
||||
async fn wait_clean(files: &Arc<std::sync::Mutex<HashMap<String, Vec<u8>>>>) {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while files.lock().unwrap().keys().any(|p| p.contains(".part")) {
|
||||
assert!(Instant::now() < deadline, "Temporary upload files leaked");
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
}
|
||||
async fn request(
|
||||
app: &Router,
|
||||
state: &State,
|
||||
@@ -1138,6 +1184,320 @@ async fn authenticated_ssh_vertical_slice_and_security() {
|
||||
.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
// Real SFTP conflict semantics: ordinary rename may never replace an existing file.
|
||||
let mut upload_settings = nyaterm_core::config::load_app_settings(&()).unwrap();
|
||||
let original_strategy = upload_settings.transfer.duplicate_strategy.clone();
|
||||
files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/collision.txt".into(), b"original".to_vec());
|
||||
upload_settings.transfer.duplicate_strategy = "skip".into();
|
||||
nyaterm_core::config::save_app_settings(&(), &upload_settings).unwrap();
|
||||
// A skipped body must not be polled, even if it would fail.
|
||||
let bad_body = Body::from_stream(futures_util::stream::iter(vec![
|
||||
Err::<axum::body::Bytes, _>(std::io::Error::other("must not read")),
|
||||
]));
|
||||
let (status, result) =
|
||||
upload_file(&app, &state, id, "/collision.txt", &owner, &csrf, bad_body).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(
|
||||
result,
|
||||
json!({"bytes":0,"status":"skipped","path":"/collision.txt"})
|
||||
);
|
||||
assert_eq!(files.lock().unwrap()["/collision.txt"], b"original");
|
||||
upload_settings.transfer.duplicate_strategy = "rename".into();
|
||||
nyaterm_core::config::save_app_settings(&(), &upload_settings).unwrap();
|
||||
let (status, renamed) = upload_file(
|
||||
&app,
|
||||
&state,
|
||||
id,
|
||||
"/collision.txt",
|
||||
&owner,
|
||||
&csrf,
|
||||
Body::from("renamed"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let renamed_path = renamed["path"].as_str().unwrap();
|
||||
assert!(renamed_path.starts_with("/collision.txt."));
|
||||
uuid::Uuid::parse_str(renamed_path.trim_start_matches("/collision.txt.")).unwrap();
|
||||
assert_eq!(renamed["status"], "completed");
|
||||
assert_eq!(files.lock().unwrap()[renamed_path], b"renamed");
|
||||
upload_settings.transfer.duplicate_strategy = "overwrite".into();
|
||||
nyaterm_core::config::save_app_settings(&(), &upload_settings).unwrap();
|
||||
assert_eq!(
|
||||
upload_file(
|
||||
&app,
|
||||
&state,
|
||||
id,
|
||||
"/collision.txt",
|
||||
&owner,
|
||||
&csrf,
|
||||
Body::from("replaced")
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(files.lock().unwrap()["/collision.txt"], b"replaced");
|
||||
posix_rename.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
let (status, result) = upload_file(
|
||||
&app,
|
||||
&state,
|
||||
id,
|
||||
"/collision.txt",
|
||||
&owner,
|
||||
&csrf,
|
||||
Body::from("unsafe"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert!(result["error"].as_str().unwrap().contains("Atomic"));
|
||||
assert_eq!(files.lock().unwrap()["/collision.txt"], b"replaced");
|
||||
posix_rename.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
wait_clean(&files).await;
|
||||
files.lock().unwrap().insert("/upload-dir".into(), vec![]);
|
||||
let mut directory_attrs = attrs(0);
|
||||
directory_attrs.permissions = Some(0o40755);
|
||||
attributes
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/upload-dir".into(), directory_attrs);
|
||||
assert_eq!(
|
||||
upload_file(
|
||||
&app,
|
||||
&state,
|
||||
id,
|
||||
"/upload-dir",
|
||||
&owner,
|
||||
&csrf,
|
||||
Body::from("unsafe")
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
assert_eq!(
|
||||
upload_file(
|
||||
&app,
|
||||
&state,
|
||||
id,
|
||||
"/denied.bin",
|
||||
&owner,
|
||||
&csrf,
|
||||
Body::from("unsafe")
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
assert!(!files.lock().unwrap().contains_key("/denied.bin"));
|
||||
assert_eq!(
|
||||
upload_file(
|
||||
&app,
|
||||
&state,
|
||||
id,
|
||||
"/not-owned",
|
||||
&other,
|
||||
&other_csrf,
|
||||
Body::from("unsafe")
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
upload_settings.transfer.duplicate_strategy = "ask".into();
|
||||
nyaterm_core::config::save_app_settings(&(), &upload_settings).unwrap();
|
||||
let mut other_events = state.login(&other).await.unwrap().events.subscribe();
|
||||
for choice in ["skip", "overwrite"] {
|
||||
while events.try_recv().is_ok() {}
|
||||
let upload_app = app.clone();
|
||||
let state_for_upload = state.clone();
|
||||
let (upload_id, upload_owner, upload_csrf) = (id.to_string(), owner.clone(), csrf.clone());
|
||||
let pending = tokio::spawn(async move {
|
||||
upload_file(
|
||||
&upload_app,
|
||||
&state_for_upload,
|
||||
&upload_id,
|
||||
"/collision.txt",
|
||||
&upload_owner,
|
||||
&upload_csrf,
|
||||
Body::from("asked"),
|
||||
)
|
||||
.await
|
||||
});
|
||||
let prompt = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
let event = events.recv().await.unwrap();
|
||||
if event.event == "transfer-duplicate-request" {
|
||||
break event;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
other_events.try_recv().is_err(),
|
||||
"Conflict prompts must be owner-only"
|
||||
);
|
||||
let reply = json!({"requestId":prompt.payload["requestId"],"action":choice});
|
||||
assert_eq!(
|
||||
request(
|
||||
&app,
|
||||
&state,
|
||||
"commands/respond_transfer_duplicate",
|
||||
Some(reply.clone()),
|
||||
Some(&other),
|
||||
Some(&other_csrf)
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
assert_eq!(
|
||||
request(
|
||||
&app,
|
||||
&state,
|
||||
"commands/respond_transfer_duplicate",
|
||||
Some(reply),
|
||||
Some(&owner),
|
||||
Some(&csrf)
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
let (status, result) = pending.await.unwrap();
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(
|
||||
result["status"],
|
||||
if choice == "skip" {
|
||||
"skipped"
|
||||
} else {
|
||||
"completed"
|
||||
}
|
||||
);
|
||||
}
|
||||
assert_eq!(files.lock().unwrap()["/collision.txt"], b"asked");
|
||||
// Disconnect while waiting for a conflict must dispose the owner prompt.
|
||||
while events.try_recv().is_ok() {}
|
||||
let (upload_app, upload_state, upload_id, upload_owner, upload_csrf) = (
|
||||
app.clone(),
|
||||
state.clone(),
|
||||
id.to_owned(),
|
||||
owner.clone(),
|
||||
csrf.clone(),
|
||||
);
|
||||
let disconnected = tokio::spawn(async move {
|
||||
upload_file(
|
||||
&upload_app,
|
||||
&upload_state,
|
||||
&upload_id,
|
||||
"/collision.txt",
|
||||
&upload_owner,
|
||||
&upload_csrf,
|
||||
Body::from("disconnect"),
|
||||
)
|
||||
.await
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
if events.recv().await.unwrap().event == "transfer-duplicate-request" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
disconnected.abort();
|
||||
let _ = disconnected.await;
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while !state.prompts.lock().await.is_empty() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
// Explicit session closure cancels a blocked body and cleans its temporary file.
|
||||
let (_, _, spare) = request(
|
||||
&app,
|
||||
&state,
|
||||
"sessions",
|
||||
Some(args.clone()),
|
||||
Some(&owner),
|
||||
Some(&csrf),
|
||||
)
|
||||
.await;
|
||||
let spare_id = spare["session_id"].as_str().unwrap();
|
||||
let mut spare_socket = ws(&host, &state, spare_id, &owner).await;
|
||||
spare_socket
|
||||
.send(Message::Binary(b"ready".to_vec().into()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(binary(&mut spare_socket).await, b"ready");
|
||||
let (upload_app, upload_state, upload_id, upload_owner, upload_csrf) = (
|
||||
app.clone(),
|
||||
state.clone(),
|
||||
spare_id.to_owned(),
|
||||
owner.clone(),
|
||||
csrf.clone(),
|
||||
);
|
||||
let body = Body::from_stream(
|
||||
futures_util::stream::once(async {
|
||||
Ok::<_, std::io::Error>(axum::body::Bytes::from_static(b"partial"))
|
||||
})
|
||||
.chain(futures_util::stream::pending()),
|
||||
);
|
||||
let cancelled = tokio::spawn(async move {
|
||||
upload_file(
|
||||
&upload_app,
|
||||
&upload_state,
|
||||
&upload_id,
|
||||
"/cancelled.bin",
|
||||
&upload_owner,
|
||||
&upload_csrf,
|
||||
body,
|
||||
)
|
||||
.await
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while !files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.any(|p| p.starts_with("/cancelled.bin.") && p.ends_with(".part"))
|
||||
{
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
request(
|
||||
&app,
|
||||
&state,
|
||||
"commands/close_session",
|
||||
Some(json!({"sessionId":spare_id})),
|
||||
Some(&owner),
|
||||
Some(&csrf)
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
tokio::time::timeout(Duration::from_secs(5), cancelled)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.0,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
wait_clean(&files).await;
|
||||
assert!(!files.lock().unwrap().contains_key("/cancelled.bin"));
|
||||
upload_settings.transfer.duplicate_strategy = original_strategy;
|
||||
nyaterm_core::config::save_app_settings(&(), &upload_settings).unwrap();
|
||||
wait_clean(&files).await;
|
||||
let body = Body::from_stream(futures_util::stream::iter(vec![
|
||||
Ok(axum::body::Bytes::from_static(b"partial")),
|
||||
Err(std::io::Error::other("test interrupted upload")),
|
||||
|
||||
@@ -551,6 +551,7 @@ function ActivityBarButton({
|
||||
<ContextMenuTrigger asChild>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label={item.tooltip}
|
||||
draggable={draggable}
|
||||
data-activity-drop-zone={dropZoneName}
|
||||
data-activity-drop-index={dropIndex}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { supports } from "@/lib/backend/runtime";
|
||||
import { runtime, supports } from "@/lib/backend/runtime";
|
||||
import { getCurrentWindow } from "@/lib/backend/platform/window";
|
||||
import { listen } from "@/lib/backend/api";
|
||||
import { openUrl } from "@/lib/backend/platform/opener";
|
||||
@@ -948,6 +948,7 @@ export default function Header({
|
||||
|
||||
const menus: Record<string, MenuItem[]> = {
|
||||
file: [
|
||||
...(runtime === "web" ? [{ id: "file.signOut", label: t("web.signOut"), action: onRequestQuit }] : []),
|
||||
addNativeAccelerator({
|
||||
id: "file.newSession",
|
||||
label: t("menu.newSession"),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { runtime, requireCapability } from "@/lib/backend/runtime";
|
||||
import { runtime, requireCapability, supports } from "@/lib/backend/runtime";
|
||||
import { downloadBrowserFile, uploadBrowserFiles } from "@/lib/backend/files";
|
||||
import { emit, listen } from "@/lib/backend/api";
|
||||
import { downloadDir, join, tempDir } from "@/lib/backend/platform/path";
|
||||
@@ -121,7 +121,10 @@ import type {
|
||||
SessionInfo,
|
||||
SessionType,
|
||||
} from "@/types/global";
|
||||
import { resolveFileEditorOpenTarget, resolveInternalEditorDisplay } from "./editorOpenMode";
|
||||
import {
|
||||
resolveFileEditorOpenTarget,
|
||||
resolveInternalEditorDisplay,
|
||||
} from "./editorOpenMode";
|
||||
import { FileExplorerDialogs } from "./FileExplorerDialogs";
|
||||
import {
|
||||
clearDirectoryChildrenCacheForPath,
|
||||
@@ -1140,7 +1143,9 @@ function FileExplorerPane({
|
||||
listenSessionsChanged: (handler) => listen("sessions-changed", handler),
|
||||
readSessions: () => invoke<SessionInfo[]>("list_sessions"),
|
||||
onSessions: (sessions) => {
|
||||
const session = sessions.find((session) => session.id === activeSessionId);
|
||||
const session = sessions.find(
|
||||
(session) => session.id === activeSessionId,
|
||||
);
|
||||
setCwdTrackingActive(canTrackTerminalCwd(session));
|
||||
setRemoteFileBrowserEnabled(
|
||||
hasLocalSession ? true : (session?.remote_file_browser_enabled ?? true),
|
||||
@@ -2859,11 +2864,7 @@ function FileExplorerPane({
|
||||
invalidateTreeDirectories([parentPath]);
|
||||
}
|
||||
if (
|
||||
isSameExplorerDirectory(
|
||||
parentPath,
|
||||
currentPathRef.current,
|
||||
backend,
|
||||
)
|
||||
isSameExplorerDirectory(parentPath, currentPathRef.current, backend)
|
||||
) {
|
||||
await loadDirectory(parentPath, {
|
||||
history: "preserve",
|
||||
@@ -3098,14 +3099,14 @@ function FileExplorerPane({
|
||||
const movedCurrentDirectory = moveDialogData?.items.find(
|
||||
(item) =>
|
||||
item.isDirectory &&
|
||||
isSameExplorerDirectory(
|
||||
item.oldPath,
|
||||
currentPathRef.current,
|
||||
backend,
|
||||
),
|
||||
isSameExplorerDirectory(item.oldPath, currentPathRef.current, backend),
|
||||
);
|
||||
const nextCurrentPath = movedCurrentDirectory
|
||||
? buildMoveTargetPath(targetDirectory, movedCurrentDirectory.name, backend)
|
||||
? buildMoveTargetPath(
|
||||
targetDirectory,
|
||||
movedCurrentDirectory.name,
|
||||
backend,
|
||||
)
|
||||
: null;
|
||||
setSelectedFiles(new Set());
|
||||
lastSelectedRef.current = null;
|
||||
@@ -3147,9 +3148,11 @@ function FileExplorerPane({
|
||||
|
||||
try {
|
||||
if (runtime === "web") {
|
||||
if (entries.some((entry) => entry.is_dir))
|
||||
requireCapability("recursiveTransfers");
|
||||
for (const entry of entries) {
|
||||
if (entry.is_dir) requireCapability("nativeFiles");
|
||||
downloadBrowserFile(activeSessionId, resolveEntryPath(entry));
|
||||
if (entry.is_dir) requireCapability("recursiveTransfers");
|
||||
await downloadBrowserFile(activeSessionId, resolveEntryPath(entry));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -3230,6 +3233,7 @@ function FileExplorerPane({
|
||||
ids: activeSessionId ? { session_id: activeSessionId } : undefined,
|
||||
error: e,
|
||||
});
|
||||
if (runtime === "web") toast.error(getErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3258,7 +3262,10 @@ function FileExplorerPane({
|
||||
if (!target) return;
|
||||
|
||||
try {
|
||||
if (await uploadBrowserFiles(target.sessionId, target.remoteDir)) {
|
||||
if (await uploadBrowserFiles(target.sessionId, target.remoteDir, (result, requestedPath) => {
|
||||
if (result.status === "skipped") toast.info(t("fileExplorer.webUploadSkipped", { path: result.path }));
|
||||
else if (result.path !== requestedPath) toast.info(t("fileExplorer.webUploadRenamed", { path: result.path }));
|
||||
})) {
|
||||
await loadDirectory(directoryPath);
|
||||
return;
|
||||
}
|
||||
@@ -3284,6 +3291,7 @@ function FileExplorerPane({
|
||||
ids: { session_id: target.sessionId },
|
||||
error,
|
||||
});
|
||||
if (runtime === "web") toast.error(getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3313,6 +3321,7 @@ function FileExplorerPane({
|
||||
ids: { session_id: target.sessionId },
|
||||
error,
|
||||
});
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3353,6 +3362,10 @@ function FileExplorerPane({
|
||||
fullPath = getEntryFullPath(entry),
|
||||
) => {
|
||||
if (!activeSessionId || entry.is_dir) return;
|
||||
if (runtime === "web") {
|
||||
await downloadBrowserFile(activeSessionId, fullPath);
|
||||
return;
|
||||
}
|
||||
if (explorerBackendRef.current === "local") {
|
||||
try {
|
||||
await openPath(
|
||||
@@ -3423,7 +3436,11 @@ function FileExplorerPane({
|
||||
|
||||
const backend = explorerBackendRef.current;
|
||||
const path = fullPath;
|
||||
if (resolveInternalEditorDisplay(appSettings.transfer.internal_editor_display) === "window") {
|
||||
if (
|
||||
resolveInternalEditorDisplay(
|
||||
appSettings.transfer.internal_editor_display,
|
||||
) === "window"
|
||||
) {
|
||||
try {
|
||||
await openRemoteFileEditor({
|
||||
sessionId: activeSessionId,
|
||||
@@ -3435,6 +3452,13 @@ function FileExplorerPane({
|
||||
target: fileWindowTarget,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
domain: "ui.error",
|
||||
event: "editor.open_failed",
|
||||
message: "File editor window open failed",
|
||||
ids: { session_id: activeSessionId },
|
||||
error,
|
||||
});
|
||||
toast.error(
|
||||
getErrorMessage(error) || t("fileExplorer.openInternalFailed"),
|
||||
);
|
||||
@@ -3460,14 +3484,16 @@ function FileExplorerPane({
|
||||
if (result.status === "unsupported") {
|
||||
toast.info(
|
||||
t(
|
||||
result.reason === "binary"
|
||||
? "fileExplorer.binaryOpenExternal"
|
||||
: "fileExplorer.unsupportedEncodingOpenExternal",
|
||||
runtime === "web"
|
||||
? "fileExplorer.webUnsupportedDownload"
|
||||
: result.reason === "binary"
|
||||
? "fileExplorer.binaryOpenExternal"
|
||||
: "fileExplorer.unsupportedEncodingOpenExternal",
|
||||
),
|
||||
);
|
||||
await handleOpenExternal(entry, fullPath);
|
||||
return;
|
||||
}
|
||||
await handleOpenExternal(entry, fullPath);
|
||||
return;
|
||||
}
|
||||
|
||||
openFileDocument({
|
||||
sessionId: activeSessionId,
|
||||
@@ -3485,6 +3511,13 @@ function FileExplorerPane({
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
domain: "ui.error",
|
||||
event: "editor.open_failed",
|
||||
message: "File open failed",
|
||||
ids: { session_id: activeSessionId },
|
||||
error,
|
||||
});
|
||||
toast.error(
|
||||
getErrorMessage(error) || t("fileExplorer.openInternalFailed"),
|
||||
);
|
||||
@@ -3495,7 +3528,12 @@ function FileExplorerPane({
|
||||
entry: FileEntry,
|
||||
fullPath = getEntryFullPath(entry),
|
||||
) => {
|
||||
if (resolveFileEditorOpenTarget(appSettings.transfer) !== "external") {
|
||||
if (
|
||||
resolveFileEditorOpenTarget(
|
||||
appSettings.transfer,
|
||||
supports("nativeFiles"),
|
||||
) !== "external"
|
||||
) {
|
||||
await handleOpenInternal(entry, fullPath);
|
||||
return;
|
||||
}
|
||||
@@ -3583,7 +3621,12 @@ function FileExplorerPane({
|
||||
treeState.toggleDirectory(row);
|
||||
}
|
||||
},
|
||||
[loadDirectory, treeState.activateDirectory, treeState.getDirectorySnapshot, treeState.toggleDirectory],
|
||||
[
|
||||
loadDirectory,
|
||||
treeState.activateDirectory,
|
||||
treeState.getDirectorySnapshot,
|
||||
treeState.toggleDirectory,
|
||||
],
|
||||
);
|
||||
|
||||
const handleTreePreview = (row: FileExplorerTreeRow) => {
|
||||
@@ -3716,17 +3759,20 @@ function FileExplorerPane({
|
||||
);
|
||||
};
|
||||
|
||||
const handleTreeProperties = useCallback((row: FileExplorerTreeRow) => {
|
||||
if (!activeSessionId) return;
|
||||
setPropertiesDialogData({
|
||||
sessionId: activeSessionId,
|
||||
backend: explorerBackend,
|
||||
fullPath: row.path,
|
||||
rawPathToken: row.rawPathToken,
|
||||
name: row.entry.name,
|
||||
is_dir: row.entry.is_dir,
|
||||
});
|
||||
}, [activeSessionId, explorerBackend]);
|
||||
const handleTreeProperties = useCallback(
|
||||
(row: FileExplorerTreeRow) => {
|
||||
if (!activeSessionId) return;
|
||||
setPropertiesDialogData({
|
||||
sessionId: activeSessionId,
|
||||
backend: explorerBackend,
|
||||
fullPath: row.path,
|
||||
rawPathToken: row.rawPathToken,
|
||||
name: row.entry.name,
|
||||
is_dir: row.entry.is_dir,
|
||||
});
|
||||
},
|
||||
[activeSessionId, explorerBackend],
|
||||
);
|
||||
|
||||
const handleTreeAIAction = (
|
||||
row: FileExplorerTreeRow,
|
||||
@@ -3782,12 +3828,7 @@ function FileExplorerPane({
|
||||
targetSessionId,
|
||||
);
|
||||
},
|
||||
[
|
||||
activeSessionId,
|
||||
currentPath,
|
||||
explorerBackend,
|
||||
onSendEntriesToTarget,
|
||||
],
|
||||
[activeSessionId, currentPath, explorerBackend, onSendEntriesToTarget],
|
||||
);
|
||||
|
||||
const handleDialogRefresh = useCallback(async () => {
|
||||
@@ -3986,7 +4027,13 @@ function FileExplorerPane({
|
||||
{canBrowseFiles && (
|
||||
<FileExplorerToolbar
|
||||
isTreeView={isTreeView}
|
||||
selectedCount={isTreeView ? selectedTreeRows.length : selectedRealFiles.length}
|
||||
selectedCount={
|
||||
isTreeView ? selectedTreeRows.length : selectedRealFiles.length
|
||||
}
|
||||
selectionHasDirectory={(isTreeView
|
||||
? selectedTreeRows.map((row) => row.entry)
|
||||
: selectedRealFiles
|
||||
).some((entry) => entry.is_dir)}
|
||||
isFileSearchActive={isFileSearchActive}
|
||||
isFileSearchExpanded={isFileSearchExpanded}
|
||||
showHiddenFiles={showHiddenFiles}
|
||||
@@ -3994,16 +4041,24 @@ function FileExplorerPane({
|
||||
fileSearchQuery={fileSearchQuery}
|
||||
fileSearchInputRef={fileSearchInputRef}
|
||||
onNewFile={() =>
|
||||
handleNewFile(isTreeView ? getTreeOperationDirectoryPath() : undefined)
|
||||
handleNewFile(
|
||||
isTreeView ? getTreeOperationDirectoryPath() : undefined,
|
||||
)
|
||||
}
|
||||
onNewFolder={() =>
|
||||
handleNewFolder(isTreeView ? getTreeOperationDirectoryPath() : undefined)
|
||||
handleNewFolder(
|
||||
isTreeView ? getTreeOperationDirectoryPath() : undefined,
|
||||
)
|
||||
}
|
||||
onUploadFiles={() =>
|
||||
handleUploadFiles(isTreeView ? getTreeOperationDirectoryPath() : undefined)
|
||||
handleUploadFiles(
|
||||
isTreeView ? getTreeOperationDirectoryPath() : undefined,
|
||||
)
|
||||
}
|
||||
onUploadFolder={() =>
|
||||
handleUploadFolder(isTreeView ? getTreeOperationDirectoryPath() : undefined)
|
||||
handleUploadFolder(
|
||||
isTreeView ? getTreeOperationDirectoryPath() : undefined,
|
||||
)
|
||||
}
|
||||
onUploadFolderContents={() =>
|
||||
handleUploadFolderContents(
|
||||
@@ -4312,6 +4367,10 @@ function FileExplorerPane({
|
||||
onOpenExternal={handleOpenExternal}
|
||||
onRefresh={() => void refreshCurrentDirectory()}
|
||||
showTransferActions={canUseRemoteTransfer}
|
||||
downloadDisabled={
|
||||
!supports("recursiveTransfers") &&
|
||||
selectedRealFiles.some((file) => file.is_dir)
|
||||
}
|
||||
onUpload={handleUploadFiles}
|
||||
onUploadFolder={handleUploadFolder}
|
||||
onUploadFolderContents={handleUploadFolderContents}
|
||||
@@ -4499,16 +4558,22 @@ function FileExplorerPane({
|
||||
<MdUpload className="mr-2 h-4 w-4" />
|
||||
{t("fileExplorer.upload")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => void handleUploadFolder()}>
|
||||
<MdDriveFolderUpload className="mr-2 h-4 w-4" />
|
||||
{t("fileExplorer.uploadFolder")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => void handleUploadFolderContents()}
|
||||
>
|
||||
<MdDriveFolderUpload className="mr-2 h-4 w-4" />
|
||||
{t("fileExplorer.uploadFolderContents")}
|
||||
</ContextMenuItem>
|
||||
{supports("recursiveTransfers") && (
|
||||
<>
|
||||
<ContextMenuItem
|
||||
onClick={() => void handleUploadFolder()}
|
||||
>
|
||||
<MdDriveFolderUpload className="mr-2 h-4 w-4" />
|
||||
{t("fileExplorer.uploadFolder")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => void handleUploadFolderContents()}
|
||||
>
|
||||
<MdDriveFolderUpload className="mr-2 h-4 w-4" />
|
||||
{t("fileExplorer.uploadFolderContents")}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuSeparator />
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from "@/components/ui/context-menu";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import type { FileEntry } from "@/types/global";
|
||||
import FileExplorerEntryContextMenu, {
|
||||
FileExplorerContextMenuActionBar,
|
||||
} from "./FileExplorerEntryContextMenu";
|
||||
import type { FileExplorerTreeRow } from "./fileExplorerTreeModel";
|
||||
|
||||
const mode = vi.hoisted(() => ({ web: false }));
|
||||
vi.mock("@/lib/backend/runtime", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/backend/runtime")>()),
|
||||
supports: () => !mode.web,
|
||||
}));
|
||||
beforeEach(() => {
|
||||
mode.web = false;
|
||||
});
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) =>
|
||||
@@ -85,7 +98,7 @@ function renderEntryMenu(row: FileExplorerTreeRow) {
|
||||
selectedTargets={[row]}
|
||||
activeSessionId="session-1"
|
||||
editorType="internal"
|
||||
showTransferActions={false}
|
||||
showTransferActions
|
||||
terminalInputEnabled
|
||||
sendTargetOptions={[]}
|
||||
getAiActions={() => []}
|
||||
@@ -112,7 +125,9 @@ function renderEntryMenu(row: FileExplorerTreeRow) {
|
||||
/>
|
||||
</ContextMenu>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByRole("button", { name: "Open entry menu" }));
|
||||
fireEvent.contextMenu(
|
||||
screen.getByRole("button", { name: "Open entry menu" }),
|
||||
);
|
||||
return {
|
||||
onCopyPath,
|
||||
onSendToTerminal,
|
||||
@@ -122,6 +137,22 @@ function renderEntryMenu(row: FileExplorerTreeRow) {
|
||||
}
|
||||
|
||||
describe("FileExplorerEntryContextMenu", () => {
|
||||
it("hides Web directory transfer and external editor entries", async () => {
|
||||
mode.web = true;
|
||||
renderEntryMenu(entryRow(true));
|
||||
expect(await screen.findByText("fileExplorer.cmOpen")).toBeTruthy();
|
||||
expect(screen.queryByText("fileExplorer.cmDownload")).toBeNull();
|
||||
fireEvent.click(screen.getByText("fileExplorer.cmUpload"));
|
||||
expect(await screen.findByText("fileExplorer.upload")).toBeTruthy();
|
||||
expect(screen.queryByText("fileExplorer.uploadFolder")).toBeNull();
|
||||
expect(screen.queryByText("fileExplorer.uploadFolderContents")).toBeNull();
|
||||
});
|
||||
it("keeps Web file downloads while hiding the external editor", async () => {
|
||||
mode.web = true;
|
||||
renderEntryMenu(entryRow(false));
|
||||
expect(await screen.findByText("fileExplorer.cmDownload")).toBeTruthy();
|
||||
expect(screen.queryByText("fileExplorer.cmOpenExternalEditor")).toBeNull();
|
||||
});
|
||||
it("groups copy information and terminal actions for directories", async () => {
|
||||
const row = entryRow(true);
|
||||
const actions = renderEntryMenu(row);
|
||||
@@ -129,12 +160,16 @@ describe("FileExplorerEntryContextMenu", () => {
|
||||
fireEvent.click(await screen.findByText("fileExplorer.cmCopyPath"));
|
||||
expect(actions.onCopyPath).toHaveBeenCalledWith(row, "full");
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole("button", { name: "Open entry menu" }));
|
||||
fireEvent.contextMenu(
|
||||
screen.getByRole("button", { name: "Open entry menu" }),
|
||||
);
|
||||
fireEvent.click(await screen.findByText("Terminal"));
|
||||
fireEvent.click(await screen.findByText("Enter Directory"));
|
||||
expect(actions.onEnterDirectoryInTerminal).toHaveBeenCalledWith(row);
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole("button", { name: "Open entry menu" }));
|
||||
fireEvent.contextMenu(
|
||||
screen.getByRole("button", { name: "Open entry menu" }),
|
||||
);
|
||||
fireEvent.click(await screen.findByText("Terminal"));
|
||||
fireEvent.click(await screen.findByText("Open New Terminal"));
|
||||
expect(actions.onOpenDirectoryInNewTerminal).toHaveBeenCalledWith(row);
|
||||
@@ -157,7 +192,9 @@ function menuItem(label: string): HTMLElement {
|
||||
return item;
|
||||
}
|
||||
|
||||
function renderActionBar(props: React.ComponentProps<typeof FileExplorerContextMenuActionBar>) {
|
||||
function renderActionBar(
|
||||
props: React.ComponentProps<typeof FileExplorerContextMenuActionBar>,
|
||||
) {
|
||||
render(
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { supports } from "@/lib/backend/runtime";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -47,6 +48,7 @@ interface FileExplorerEntryContextMenuProps {
|
||||
activeSessionId: string | null;
|
||||
editorType: "external" | "internal";
|
||||
showTransferActions: boolean;
|
||||
downloadDisabled?: boolean;
|
||||
terminalInputEnabled: boolean;
|
||||
sendTargetOptions: Array<{
|
||||
sessionId: string;
|
||||
@@ -69,13 +71,19 @@ interface FileExplorerEntryContextMenuProps {
|
||||
onUploadFolderContents: (directoryPath: string) => void;
|
||||
onDownload: (rows: FileExplorerTreeRow[]) => void;
|
||||
onSendToPeer?: (rows: FileExplorerTreeRow[]) => void;
|
||||
onSendToTarget?: (rows: FileExplorerTreeRow[], targetSessionId: string) => void;
|
||||
onSendToTarget?: (
|
||||
rows: FileExplorerTreeRow[],
|
||||
targetSessionId: string,
|
||||
) => void;
|
||||
onRename: (row: FileExplorerTreeRow) => void;
|
||||
onMove: (rows: FileExplorerTreeRow[]) => void;
|
||||
onDelete: (rows: FileExplorerTreeRow[]) => void;
|
||||
onAddToFavorites: (row: FileExplorerTreeRow) => void;
|
||||
onCopyPath: (row: FileExplorerTreeRow, mode: "dir" | "name" | "full") => void;
|
||||
onSendToTerminal?: (row: FileExplorerTreeRow, mode: "dir" | "name" | "full") => void;
|
||||
onSendToTerminal?: (
|
||||
row: FileExplorerTreeRow,
|
||||
mode: "dir" | "name" | "full",
|
||||
) => void;
|
||||
onEnterDirectoryInTerminal?: (row: FileExplorerTreeRow) => void;
|
||||
onOpenDirectoryInNewTerminal?: (row: FileExplorerTreeRow) => void;
|
||||
onProperties: (row: FileExplorerTreeRow) => void;
|
||||
@@ -188,6 +196,7 @@ export default function FileExplorerEntryContextMenu({
|
||||
activeSessionId,
|
||||
editorType,
|
||||
showTransferActions,
|
||||
downloadDisabled = false,
|
||||
terminalInputEnabled,
|
||||
sendTargetOptions,
|
||||
getAiActions,
|
||||
@@ -227,17 +236,26 @@ export default function FileExplorerEntryContextMenu({
|
||||
if (!target || target.isRoot) return [];
|
||||
return rowsContain(selectedTargets, target) ? selectedTargets : [target];
|
||||
}, [selectedTargets, target]);
|
||||
const targetDirectory = target?.entry.is_dir ? target.path : target?.parentPath;
|
||||
const targetDirectory = target?.entry.is_dir
|
||||
? target.path
|
||||
: target?.parentPath;
|
||||
const aiActions = target ? getAiActions(target) : [];
|
||||
const isFile = !!target && !target.entry.is_dir;
|
||||
const showOpenInternal = isFile && editorType === "external";
|
||||
const showOpenExternal = isFile && editorType === "internal";
|
||||
const showOpenInternal =
|
||||
isFile && supports("nativeFiles") && editorType === "external";
|
||||
const showOpenExternal =
|
||||
isFile && supports("nativeFiles") && editorType === "internal";
|
||||
const showActionBar = !!onCopyEntries || !!onCutEntries || !!onPaste;
|
||||
const hasActionableTarget = !!target && !target.isRoot && actionRows.length > 0;
|
||||
const hasActionableTarget =
|
||||
!!target && !target.isRoot && actionRows.length > 0;
|
||||
|
||||
return (
|
||||
<ContextMenuContent
|
||||
className={showActionBar ? "w-64 max-w-[calc(100vw-1rem)] min-w-0" : "min-w-[200px]"}
|
||||
className={
|
||||
showActionBar
|
||||
? "w-64 max-w-[calc(100vw-1rem)] min-w-0"
|
||||
: "min-w-[200px]"
|
||||
}
|
||||
onCloseAutoFocus={onCloseAutoFocus}
|
||||
>
|
||||
{showActionBar && (
|
||||
@@ -260,7 +278,9 @@ export default function FileExplorerEntryContextMenu({
|
||||
<>
|
||||
<ContextMenuItem
|
||||
onClick={() =>
|
||||
target.entry.is_dir ? onOpenDirectory(target) : onOpenDefault(target)
|
||||
target.entry.is_dir
|
||||
? onOpenDirectory(target)
|
||||
: onOpenDefault(target)
|
||||
}
|
||||
>
|
||||
<MdFileOpen className="text-[0.875rem] text-muted-foreground mr-2" />
|
||||
@@ -313,26 +333,42 @@ export default function FileExplorerEntryContextMenu({
|
||||
{t("fileExplorer.cmUpload")}
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="w-48">
|
||||
<ContextMenuItem onClick={() => onUpload(targetDirectory)}>
|
||||
<ContextMenuItem
|
||||
onClick={() => onUpload(targetDirectory)}
|
||||
>
|
||||
<MdUpload className="text-[0.875rem] text-muted-foreground mr-2" />
|
||||
{t("fileExplorer.upload")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onUploadFolder(targetDirectory)}>
|
||||
<MdDriveFolderUpload className="text-[0.875rem] text-muted-foreground mr-2" />
|
||||
{t("fileExplorer.uploadFolder")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onUploadFolderContents(targetDirectory)}
|
||||
>
|
||||
<MdDriveFolderUpload className="text-[0.875rem] text-muted-foreground mr-2" />
|
||||
{t("fileExplorer.uploadFolderContents")}
|
||||
</ContextMenuItem>
|
||||
{supports("recursiveTransfers") && (
|
||||
<>
|
||||
<ContextMenuItem
|
||||
onClick={() => onUploadFolder(targetDirectory)}
|
||||
>
|
||||
<MdDriveFolderUpload className="text-[0.875rem] text-muted-foreground mr-2" />
|
||||
{t("fileExplorer.uploadFolder")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() =>
|
||||
onUploadFolderContents(targetDirectory)
|
||||
}
|
||||
>
|
||||
<MdDriveFolderUpload className="text-[0.875rem] text-muted-foreground mr-2" />
|
||||
{t("fileExplorer.uploadFolderContents")}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuItem onClick={() => onDownload(actionRows)}>
|
||||
<MdDownload className="text-[0.875rem] text-muted-foreground mr-2" />
|
||||
{t("fileExplorer.cmDownload")}
|
||||
</ContextMenuItem>
|
||||
{(supports("recursiveTransfers") ||
|
||||
!actionRows.some((row) => row.entry.is_dir)) && (
|
||||
<ContextMenuItem
|
||||
disabled={downloadDisabled}
|
||||
onClick={() => onDownload(actionRows)}
|
||||
>
|
||||
<MdDownload className="text-[0.875rem] text-muted-foreground mr-2" />
|
||||
{t("fileExplorer.cmDownload")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
@@ -352,30 +388,36 @@ export default function FileExplorerEntryContextMenu({
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
|
||||
{sendTargetOptions.length > 0 && actionRows.length > 0 && onSendToTarget && (
|
||||
<>
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>
|
||||
<MdSend className="text-[0.875rem] text-muted-foreground mr-2" />
|
||||
{t("fileExplorer.sendToPeer")}
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="min-w-52">
|
||||
{sendTargetOptions.map((sendTarget) => (
|
||||
<ContextMenuItem
|
||||
key={sendTarget.sessionId}
|
||||
onClick={() => onSendToTarget(actionRows, sendTarget.sessionId)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{sendTarget.label}</span>
|
||||
<span className="ml-2 shrink-0 text-[0.625rem] text-muted-foreground">
|
||||
{sendTarget.meta}
|
||||
</span>
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{sendTargetOptions.length > 0 &&
|
||||
actionRows.length > 0 &&
|
||||
onSendToTarget && (
|
||||
<>
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>
|
||||
<MdSend className="text-[0.875rem] text-muted-foreground mr-2" />
|
||||
{t("fileExplorer.sendToPeer")}
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="min-w-52">
|
||||
{sendTargetOptions.map((sendTarget) => (
|
||||
<ContextMenuItem
|
||||
key={sendTarget.sessionId}
|
||||
onClick={() =>
|
||||
onSendToTarget(actionRows, sendTarget.sessionId)
|
||||
}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{sendTarget.label}
|
||||
</span>
|
||||
<span className="ml-2 shrink-0 text-[0.625rem] text-muted-foreground">
|
||||
{sendTarget.meta}
|
||||
</span>
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!target.isRoot && (
|
||||
<>
|
||||
@@ -455,26 +497,36 @@ export default function FileExplorerEntryContextMenu({
|
||||
onEnterDirectoryInTerminal &&
|
||||
onOpenDirectoryInNewTerminal && (
|
||||
<>
|
||||
<ContextMenuItem onClick={() => onEnterDirectoryInTerminal(target)}>
|
||||
<ContextMenuItem
|
||||
onClick={() => onEnterDirectoryInTerminal(target)}
|
||||
>
|
||||
<MdFolderOpen className="text-[0.875rem] text-muted-foreground mr-2" />
|
||||
{t("fileExplorer.cmEnterDirectory")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onOpenDirectoryInNewTerminal(target)}>
|
||||
<ContextMenuItem
|
||||
onClick={() => onOpenDirectoryInNewTerminal(target)}
|
||||
>
|
||||
<MdOpenInNew className="text-[0.875rem] text-muted-foreground mr-2" />
|
||||
{t("fileExplorer.cmOpenDirectoryNewTerminal")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<ContextMenuItem onClick={() => onSendToTerminal(target, "full")}>
|
||||
<ContextMenuItem
|
||||
onClick={() => onSendToTerminal(target, "full")}
|
||||
>
|
||||
<MdKeyboardReturn className="text-[0.875rem] text-muted-foreground mr-2" />
|
||||
{t("fileExplorer.cmTerminalPath")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onSendToTerminal(target, "name")}>
|
||||
<ContextMenuItem
|
||||
onClick={() => onSendToTerminal(target, "name")}
|
||||
>
|
||||
<MdKeyboardArrowRight className="text-[0.875rem] text-muted-foreground mr-2" />
|
||||
{t("fileExplorer.cmTerminalName")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onSendToTerminal(target, "dir")}>
|
||||
<ContextMenuItem
|
||||
onClick={() => onSendToTerminal(target, "dir")}
|
||||
>
|
||||
<MdKeyboardDoubleArrowRight className="text-[0.875rem] text-muted-foreground mr-2" />
|
||||
{t("fileExplorer.cmTerminalDirPath")}
|
||||
</ContextMenuItem>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { FileExplorerToolbar } from "./FileExplorerToolbar";
|
||||
|
||||
const mode = vi.hoisted(() => ({ web: true }));
|
||||
vi.mock("@/lib/backend/runtime", async (original) => ({
|
||||
...(await original<typeof import("@/lib/backend/runtime")>()),
|
||||
supports: () => !mode.web,
|
||||
}));
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
function toolbar(directory: boolean) {
|
||||
const noop = vi.fn();
|
||||
return (
|
||||
<FileExplorerToolbar
|
||||
isTreeView={false}
|
||||
selectedCount={2}
|
||||
selectionHasDirectory={directory}
|
||||
isFileSearchActive={false}
|
||||
isFileSearchExpanded={false}
|
||||
showHiddenFiles={false}
|
||||
showTransferActions
|
||||
fileSearchQuery=""
|
||||
fileSearchInputRef={{ current: null }}
|
||||
onNewFile={noop}
|
||||
onNewFolder={noop}
|
||||
onUploadFiles={noop}
|
||||
onUploadFolder={noop}
|
||||
onUploadFolderContents={noop}
|
||||
onDownloadSelected={noop}
|
||||
onDeleteSelected={noop}
|
||||
onGoUp={noop}
|
||||
onRefresh={noop}
|
||||
onLocatePath={noop}
|
||||
locateLabel="cwd"
|
||||
canLocatePath
|
||||
onToggleViewMode={noop}
|
||||
onToggleHiddenFiles={noop}
|
||||
onExpandSearch={noop}
|
||||
onSearchQueryChange={noop}
|
||||
onCollapseSearch={noop}
|
||||
/>
|
||||
);
|
||||
}
|
||||
describe("file transfer toolbar", () => {
|
||||
it("disables mixed directory download in Web, preserving delete and file downloads", () => {
|
||||
mode.web = true;
|
||||
const { rerender } = render(toolbar(true));
|
||||
expect(
|
||||
(
|
||||
screen.getByLabelText(
|
||||
"fileExplorer.downloadSelected",
|
||||
) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(screen.getByLabelText("fileExplorer.delete") as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(false);
|
||||
rerender(toolbar(false));
|
||||
expect(
|
||||
(
|
||||
screen.getByLabelText(
|
||||
"fileExplorer.downloadSelected",
|
||||
) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(false);
|
||||
fireEvent.pointerDown(screen.getByLabelText("fileExplorer.upload"), {
|
||||
button: 0,
|
||||
ctrlKey: false,
|
||||
pointerType: "mouse",
|
||||
});
|
||||
expect(screen.queryByText("fileExplorer.uploadFolder")).toBeNull();
|
||||
expect(screen.queryByText("fileExplorer.uploadFolderContents")).toBeNull();
|
||||
});
|
||||
it("keeps recursive transfers available on desktop", () => {
|
||||
mode.web = false;
|
||||
render(toolbar(true));
|
||||
expect(
|
||||
(
|
||||
screen.getByLabelText(
|
||||
"fileExplorer.downloadSelected",
|
||||
) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(false);
|
||||
fireEvent.pointerDown(screen.getByLabelText("fileExplorer.upload"), {
|
||||
button: 0,
|
||||
ctrlKey: false,
|
||||
pointerType: "mouse",
|
||||
});
|
||||
expect(screen.getByText("fileExplorer.uploadFolder")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { supports } from "@/lib/backend/runtime";
|
||||
import type { ComponentProps, RefObject } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -24,14 +25,22 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ToolbarIconButtonProps = ComponentProps<typeof Button> & {
|
||||
label: string;
|
||||
};
|
||||
|
||||
function ToolbarIconButton({ label, children, ...props }: ToolbarIconButtonProps) {
|
||||
function ToolbarIconButton({
|
||||
label,
|
||||
children,
|
||||
...props
|
||||
}: ToolbarIconButtonProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -57,6 +66,7 @@ function ToolbarDivider() {
|
||||
interface FileExplorerToolbarProps {
|
||||
isTreeView: boolean;
|
||||
selectedCount: number;
|
||||
selectionHasDirectory?: boolean;
|
||||
isFileSearchActive: boolean;
|
||||
isFileSearchExpanded: boolean;
|
||||
showHiddenFiles: boolean;
|
||||
@@ -85,6 +95,7 @@ interface FileExplorerToolbarProps {
|
||||
export function FileExplorerToolbar({
|
||||
isTreeView,
|
||||
selectedCount,
|
||||
selectionHasDirectory = false,
|
||||
isFileSearchActive,
|
||||
isFileSearchExpanded,
|
||||
showHiddenFiles,
|
||||
@@ -114,7 +125,10 @@ export function FileExplorerToolbar({
|
||||
return (
|
||||
<div
|
||||
className="nyaterm-wallpaper-transparent-surface relative flex items-center px-1.5 py-1 border-b gap-0.5"
|
||||
style={{ backgroundColor: "var(--df-bg-panel)", borderColor: "var(--df-border)" }}
|
||||
style={{
|
||||
backgroundColor: "var(--df-bg-panel)",
|
||||
borderColor: "var(--df-border)",
|
||||
}}
|
||||
>
|
||||
<ToolbarIconButton
|
||||
label={t("fileExplorer.newFile")}
|
||||
@@ -154,21 +168,27 @@ export function FileExplorerToolbar({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{t("fileExplorer.upload")}</TooltipContent>
|
||||
<TooltipContent side="top">
|
||||
{t("fileExplorer.upload")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="start" className="min-w-44">
|
||||
<DropdownMenuItem onClick={onUploadFiles}>
|
||||
<MdUpload className="mr-2 h-4 w-4" />
|
||||
{t("fileExplorer.upload")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onUploadFolder}>
|
||||
<MdDriveFolderUpload className="mr-2 h-4 w-4" />
|
||||
{t("fileExplorer.uploadFolder")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onUploadFolderContents}>
|
||||
<MdDriveFolderUpload className="mr-2 h-4 w-4" />
|
||||
{t("fileExplorer.uploadFolderContents")}
|
||||
</DropdownMenuItem>
|
||||
{supports("recursiveTransfers") && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={onUploadFolder}>
|
||||
<MdDriveFolderUpload className="mr-2 h-4 w-4" />
|
||||
{t("fileExplorer.uploadFolder")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onUploadFolderContents}>
|
||||
<MdDriveFolderUpload className="mr-2 h-4 w-4" />
|
||||
{t("fileExplorer.uploadFolderContents")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ToolbarIconButton
|
||||
@@ -177,7 +197,10 @@ export function FileExplorerToolbar({
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md text-muted-foreground hover:text-foreground"
|
||||
onClick={onDownloadSelected}
|
||||
disabled={selectedCount === 0}
|
||||
disabled={
|
||||
selectedCount === 0 ||
|
||||
(!supports("recursiveTransfers") && selectionHasDirectory)
|
||||
}
|
||||
>
|
||||
<MdDownload className="h-4 w-4" />
|
||||
</ToolbarIconButton>
|
||||
@@ -251,7 +274,11 @@ export function FileExplorerToolbar({
|
||||
className="h-7 w-7 rounded-md text-muted-foreground hover:text-foreground"
|
||||
onClick={onToggleViewMode}
|
||||
>
|
||||
{isTreeView ? <MdViewList className="h-4 w-4" /> : <MdAccountTree className="h-4 w-4" />}
|
||||
{isTreeView ? (
|
||||
<MdViewList className="h-4 w-4" />
|
||||
) : (
|
||||
<MdAccountTree className="h-4 w-4" />
|
||||
)}
|
||||
</ToolbarIconButton>
|
||||
{!isTreeView && (
|
||||
<ToolbarIconButton
|
||||
@@ -260,7 +287,9 @@ export function FileExplorerToolbar({
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-md hover:text-foreground",
|
||||
isFileSearchActive ? "bg-primary/10 text-primary" : "text-muted-foreground",
|
||||
isFileSearchActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
onClick={onExpandSearch}
|
||||
>
|
||||
@@ -269,13 +298,17 @@ export function FileExplorerToolbar({
|
||||
)}
|
||||
<ToolbarIconButton
|
||||
label={
|
||||
showHiddenFiles ? t("fileExplorer.hideHiddenFiles") : t("fileExplorer.showHiddenFiles")
|
||||
showHiddenFiles
|
||||
? t("fileExplorer.hideHiddenFiles")
|
||||
: t("fileExplorer.showHiddenFiles")
|
||||
}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-md hover:text-foreground",
|
||||
showHiddenFiles ? "bg-primary/10 text-primary" : "text-muted-foreground",
|
||||
showHiddenFiles
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
onClick={onToggleHiddenFiles}
|
||||
>
|
||||
@@ -320,7 +353,11 @@ export function FileExplorerToolbar({
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-[var(--df-text-dimmed)] transition-colors hover:bg-[var(--df-bg-hover)] hover:text-[var(--df-text)]"
|
||||
aria-label={fileSearchQuery ? t("fileExplorer.clearSearch") : t("common.close")}
|
||||
aria-label={
|
||||
fileSearchQuery
|
||||
? t("fileExplorer.clearSearch")
|
||||
: t("common.close")
|
||||
}
|
||||
onClick={() => {
|
||||
if (fileSearchQuery) {
|
||||
onSearchQueryChange("");
|
||||
|
||||
@@ -15,7 +15,11 @@ import {
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "../../ui/context-menu";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "../../ui/hover-card";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "../../ui/hover-card";
|
||||
|
||||
interface FileListItemProps {
|
||||
entry: FileEntry;
|
||||
@@ -38,6 +42,7 @@ interface FileListItemProps {
|
||||
onOpenExternal: (entry: FileEntry) => void;
|
||||
onRefresh: () => void;
|
||||
showTransferActions: boolean;
|
||||
downloadDisabled?: boolean;
|
||||
onUpload: () => void;
|
||||
onUploadFolder: () => void;
|
||||
onUploadFolderContents: () => void;
|
||||
@@ -107,6 +112,7 @@ export function FileListItem({
|
||||
onOpenExternal,
|
||||
onRefresh,
|
||||
showTransferActions,
|
||||
downloadDisabled,
|
||||
onUpload,
|
||||
onUploadFolder,
|
||||
onUploadFolderContents,
|
||||
@@ -280,7 +286,9 @@ export function FileListItem({
|
||||
<div className="flex min-w-0 items-center gap-2 px-2">
|
||||
<entryIcon.icon
|
||||
className="shrink-0 text-base"
|
||||
style={{ color: isSelected ? "var(--df-primary)" : entryIcon.color }}
|
||||
style={{
|
||||
color: isSelected ? "var(--df-primary)" : entryIcon.color,
|
||||
}}
|
||||
/>
|
||||
{isRenaming ? (
|
||||
<input
|
||||
@@ -450,6 +458,7 @@ export function FileListItem({
|
||||
activeSessionId={activeSessionId}
|
||||
editorType={editorType}
|
||||
showTransferActions={showTransferActions}
|
||||
downloadDisabled={downloadDisabled}
|
||||
terminalInputEnabled={!!onSendToTerminal}
|
||||
sendTargetOptions={sendTargetOptions}
|
||||
getAiActions={() => aiActions}
|
||||
|
||||
@@ -5,6 +5,12 @@ import {
|
||||
} from "./editorOpenMode";
|
||||
|
||||
describe("file editor open mode", () => {
|
||||
it("uses the browser editor without changing external desktop preferences", () => {
|
||||
const settings = { editor_type: "external", internal_editor_display: "window" } as const;
|
||||
expect(resolveFileEditorOpenTarget(settings, false)).toBe("internal-window");
|
||||
expect(resolveFileEditorOpenTarget({ editor_type: "external" }, false)).toBe("internal-workspace");
|
||||
expect(settings.editor_type).toBe("external");
|
||||
});
|
||||
it("uses the external editor when editor_type is external", () => {
|
||||
expect(
|
||||
resolveFileEditorOpenTarget({
|
||||
|
||||
@@ -17,8 +17,9 @@ export function resolveInternalEditorDisplay(
|
||||
|
||||
export function resolveFileEditorOpenTarget(
|
||||
settings: EditorOpenSettings,
|
||||
nativeFiles = true,
|
||||
): FileEditorOpenTarget {
|
||||
if ((settings.editor_type || "external") !== "internal") {
|
||||
if (nativeFiles && (settings.editor_type || "external") !== "internal") {
|
||||
return "external";
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TransferTab } from "./TransferTab";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
web: true,
|
||||
update: vi.fn(),
|
||||
downloadDir: vi.fn(async () => "/downloads"),
|
||||
}));
|
||||
const transfer = {
|
||||
duplicate_strategy: "ask",
|
||||
editor_type: "external",
|
||||
default_editor: "desktop-editor",
|
||||
internal_editor_display: "workspace",
|
||||
download_path: "C:/saved",
|
||||
download_threads: 3,
|
||||
upload_threads: 4,
|
||||
max_transfer_retries: 2,
|
||||
transfer_buffer_size: 64,
|
||||
default_file_permissions: "644",
|
||||
ask_save_location: true,
|
||||
preserve_timestamps: true,
|
||||
resume_broken_transfer: true,
|
||||
};
|
||||
vi.mock("@/context/AppContext", () => ({
|
||||
useApp: () => ({
|
||||
appSettings: { transfer },
|
||||
updateAppSettings: state.update,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/backend/runtime", async (original) => ({
|
||||
...(await original<typeof import("@/lib/backend/runtime")>()),
|
||||
supports: () => !state.web,
|
||||
}));
|
||||
vi.mock("@/lib/backend/platform/path", () => ({
|
||||
downloadDir: state.downloadDir,
|
||||
}));
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
beforeEach(() => {
|
||||
state.web = true;
|
||||
state.update.mockClear();
|
||||
state.downloadDir.mockClear();
|
||||
});
|
||||
|
||||
describe("transfer capability settings", () => {
|
||||
it("shows only Web conflict and display settings, preserving desktop fields on edits", () => {
|
||||
render(<TransferTab />);
|
||||
expect(screen.getAllByRole("combobox")).toHaveLength(2);
|
||||
expect(screen.queryByText("settings.downloadPath")).toBeNull();
|
||||
expect(screen.queryByText("settings.editorType")).toBeNull();
|
||||
expect(screen.queryByText("settings.uploadConcurrentTasks")).toBeNull();
|
||||
expect(state.downloadDir).not.toHaveBeenCalled();
|
||||
fireEvent.keyDown(screen.getAllByRole("combobox")[0], { key: "ArrowDown" });
|
||||
fireEvent.click(
|
||||
screen.getByRole("option", { name: "settings.strategySkip" }),
|
||||
);
|
||||
expect(state.update).toHaveBeenCalledWith({
|
||||
transfer: { ...transfer, duplicate_strategy: "skip" },
|
||||
});
|
||||
});
|
||||
it("retains desktop path, editor and tuning settings", () => {
|
||||
state.web = false;
|
||||
render(<TransferTab />);
|
||||
expect(screen.getByText("settings.downloadPath")).toBeTruthy();
|
||||
expect(screen.getByText("settings.editorType")).toBeTruthy();
|
||||
expect(screen.getByText("settings.defaultEditor")).toBeTruthy();
|
||||
expect(screen.getByText("settings.uploadConcurrentTasks")).toBeTruthy();
|
||||
expect(state.downloadDir).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { supports } from "@/lib/backend/runtime";
|
||||
import { downloadDir } from "@/lib/backend/platform/path";
|
||||
import { open as openDialog } from "@/lib/backend/platform/dialog";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -83,6 +84,7 @@ export function TransferTab() {
|
||||
const [defaultDownloadDir, setDefaultDownloadDir] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!supports("nativeFiles")) return;
|
||||
downloadDir()
|
||||
.then(setDefaultDownloadDir)
|
||||
.catch(() => {});
|
||||
@@ -94,21 +96,27 @@ export function TransferTab() {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<SettingSection contentClassName="space-y-5">
|
||||
<PathPickerInput
|
||||
label={t("settings.downloadPath")}
|
||||
desc={t("settings.downloadPathDesc")}
|
||||
value={transfer.download_path}
|
||||
placeholder={defaultDownloadDir}
|
||||
onChange={(v) => update({ download_path: v })}
|
||||
/>
|
||||
|
||||
<SettingRow label={t("settings.askSaveLocation")} desc={t("settings.askSaveLocationDesc")}>
|
||||
<SettingSwitch
|
||||
checked={transfer.ask_save_location}
|
||||
onChange={(v) => update({ ask_save_location: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
{supports("nativeFiles") && (
|
||||
<>
|
||||
<PathPickerInput
|
||||
label={t("settings.downloadPath")}
|
||||
desc={t("settings.downloadPathDesc")}
|
||||
value={transfer.download_path}
|
||||
placeholder={defaultDownloadDir}
|
||||
onChange={(v) => update({ download_path: v })}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.askSaveLocation")}
|
||||
desc={t("settings.askSaveLocationDesc")}
|
||||
>
|
||||
<SettingSwitch
|
||||
checked={transfer.ask_save_location}
|
||||
onChange={(v) => update({ ask_save_location: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
<SettingSelect
|
||||
label={t("settings.duplicateStrategy")}
|
||||
desc={t("settings.duplicateStrategyDesc")}
|
||||
@@ -116,38 +124,53 @@ export function TransferTab() {
|
||||
controlClassName="max-w-sm"
|
||||
onValueChange={(v) => update({ duplicate_strategy: v })}
|
||||
>
|
||||
<SelectItem value="overwrite">{t("settings.strategyOverwrite")}</SelectItem>
|
||||
<SelectItem value="overwrite">
|
||||
{t("settings.strategyOverwrite")}
|
||||
</SelectItem>
|
||||
<SelectItem value="skip">{t("settings.strategySkip")}</SelectItem>
|
||||
<SelectItem value="rename">{t("settings.strategyRename")}</SelectItem>
|
||||
<SelectItem value="ask">{t("settings.strategyAsk")}</SelectItem>
|
||||
</SettingSelect>
|
||||
|
||||
<SettingSelect
|
||||
label={t("settings.editorType")}
|
||||
desc={t("settings.editorTypeDesc")}
|
||||
value={transfer.editor_type || "external"}
|
||||
controlClassName="max-w-sm"
|
||||
onValueChange={(v) => update({ editor_type: v as "external" | "internal" })}
|
||||
>
|
||||
<SelectItem value="external">{t("settings.editorTypeExternal")}</SelectItem>
|
||||
<SelectItem value="internal">{t("settings.editorTypeInternal")}</SelectItem>
|
||||
</SettingSelect>
|
||||
|
||||
{(transfer.editor_type || "external") === "external" && (
|
||||
<PathPickerInput
|
||||
label={t("settings.defaultEditor")}
|
||||
desc={t("settings.defaultEditorDesc")}
|
||||
value={transfer.default_editor}
|
||||
placeholder={t("settings.defaultEditorDesc")}
|
||||
onChange={(v) => update({ default_editor: v })}
|
||||
directory={false}
|
||||
filters={[
|
||||
{ name: "Executable", extensions: ["exe", "cmd", "bat", "com", "app", "sh", ""] },
|
||||
]}
|
||||
/>
|
||||
{supports("nativeFiles") && (
|
||||
<SettingSelect
|
||||
label={t("settings.editorType")}
|
||||
desc={t("settings.editorTypeDesc")}
|
||||
value={transfer.editor_type || "external"}
|
||||
controlClassName="max-w-sm"
|
||||
onValueChange={(v) =>
|
||||
update({ editor_type: v as "external" | "internal" })
|
||||
}
|
||||
>
|
||||
<SelectItem value="external">
|
||||
{t("settings.editorTypeExternal")}
|
||||
</SelectItem>
|
||||
<SelectItem value="internal">
|
||||
{t("settings.editorTypeInternal")}
|
||||
</SelectItem>
|
||||
</SettingSelect>
|
||||
)}
|
||||
|
||||
{(transfer.editor_type || "external") === "internal" && (
|
||||
{supports("nativeFiles") &&
|
||||
(transfer.editor_type || "external") === "external" && (
|
||||
<PathPickerInput
|
||||
label={t("settings.defaultEditor")}
|
||||
desc={t("settings.defaultEditorDesc")}
|
||||
value={transfer.default_editor}
|
||||
placeholder={t("settings.defaultEditorDesc")}
|
||||
onChange={(v) => update({ default_editor: v })}
|
||||
directory={false}
|
||||
filters={[
|
||||
{
|
||||
name: "Executable",
|
||||
extensions: ["exe", "cmd", "bat", "com", "app", "sh", ""],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(!supports("nativeFiles") ||
|
||||
(transfer.editor_type || "external") === "internal") && (
|
||||
<SettingSelect
|
||||
label={t("settings.internalEditorDisplay")}
|
||||
desc={t("settings.internalEditorDisplayDesc")}
|
||||
@@ -157,85 +180,93 @@ export function TransferTab() {
|
||||
update({ internal_editor_display: v as "workspace" | "window" })
|
||||
}
|
||||
>
|
||||
<SelectItem value="workspace">{t("settings.internalEditorDisplayWorkspace")}</SelectItem>
|
||||
<SelectItem value="window">{t("settings.internalEditorDisplayWindow")}</SelectItem>
|
||||
<SelectItem value="workspace">
|
||||
{t("settings.internalEditorDisplayWorkspace")}
|
||||
</SelectItem>
|
||||
<SelectItem value="window">
|
||||
{t("settings.internalEditorDisplayWindow")}
|
||||
</SelectItem>
|
||||
</SettingSelect>
|
||||
)}
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection contentClassName="space-y-5">
|
||||
<SettingFieldGrid>
|
||||
<SettingNumberInput
|
||||
label={t("settings.downloadConcurrentTasks")}
|
||||
desc={t("settings.downloadConcurrentTasksDesc")}
|
||||
min={1}
|
||||
max={10}
|
||||
value={transfer.download_threads}
|
||||
controlClassName="max-w-sm"
|
||||
onChange={(v) => update({ download_threads: v })}
|
||||
/>
|
||||
{supports("nativeFiles") && (
|
||||
<SettingSection contentClassName="space-y-5">
|
||||
<SettingFieldGrid>
|
||||
<SettingNumberInput
|
||||
label={t("settings.downloadConcurrentTasks")}
|
||||
desc={t("settings.downloadConcurrentTasksDesc")}
|
||||
min={1}
|
||||
max={10}
|
||||
value={transfer.download_threads}
|
||||
controlClassName="max-w-sm"
|
||||
onChange={(v) => update({ download_threads: v })}
|
||||
/>
|
||||
|
||||
<SettingNumberInput
|
||||
label={t("settings.uploadConcurrentTasks")}
|
||||
desc={t("settings.uploadConcurrentTasksDesc")}
|
||||
min={1}
|
||||
max={10}
|
||||
value={transfer.upload_threads}
|
||||
controlClassName="max-w-sm"
|
||||
onChange={(v) => update({ upload_threads: v })}
|
||||
/>
|
||||
<SettingNumberInput
|
||||
label={t("settings.uploadConcurrentTasks")}
|
||||
desc={t("settings.uploadConcurrentTasksDesc")}
|
||||
min={1}
|
||||
max={10}
|
||||
value={transfer.upload_threads}
|
||||
controlClassName="max-w-sm"
|
||||
onChange={(v) => update({ upload_threads: v })}
|
||||
/>
|
||||
|
||||
<SettingNumberInput
|
||||
label={t("settings.maxTransferRetries")}
|
||||
desc={t("settings.maxTransferRetriesDesc")}
|
||||
min={0}
|
||||
max={10}
|
||||
value={transfer.max_transfer_retries}
|
||||
controlClassName="max-w-sm"
|
||||
onChange={(v) => update({ max_transfer_retries: v })}
|
||||
/>
|
||||
<SettingNumberInput
|
||||
label={t("settings.maxTransferRetries")}
|
||||
desc={t("settings.maxTransferRetriesDesc")}
|
||||
min={0}
|
||||
max={10}
|
||||
value={transfer.max_transfer_retries}
|
||||
controlClassName="max-w-sm"
|
||||
onChange={(v) => update({ max_transfer_retries: v })}
|
||||
/>
|
||||
|
||||
<SettingNumberInput
|
||||
label={t("settings.transferBufferSize")}
|
||||
desc={t("settings.transferBufferSizeDesc")}
|
||||
min={8}
|
||||
max={256}
|
||||
step={8}
|
||||
value={transfer.transfer_buffer_size}
|
||||
controlClassName="max-w-sm"
|
||||
onChange={(v) => update({ transfer_buffer_size: v })}
|
||||
/>
|
||||
<SettingNumberInput
|
||||
label={t("settings.transferBufferSize")}
|
||||
desc={t("settings.transferBufferSizeDesc")}
|
||||
min={8}
|
||||
max={256}
|
||||
step={8}
|
||||
value={transfer.transfer_buffer_size}
|
||||
controlClassName="max-w-sm"
|
||||
onChange={(v) => update({ transfer_buffer_size: v })}
|
||||
/>
|
||||
|
||||
<SettingInput
|
||||
label={t("settings.defaultFilePermissions")}
|
||||
desc={t("settings.defaultFilePermissionsDesc")}
|
||||
placeholder="644"
|
||||
value={transfer.default_file_permissions}
|
||||
controlClassName="max-w-sm"
|
||||
onChange={(e) => update({ default_file_permissions: e.target.value })}
|
||||
/>
|
||||
</SettingFieldGrid>
|
||||
<SettingInput
|
||||
label={t("settings.defaultFilePermissions")}
|
||||
desc={t("settings.defaultFilePermissionsDesc")}
|
||||
placeholder="644"
|
||||
value={transfer.default_file_permissions}
|
||||
controlClassName="max-w-sm"
|
||||
onChange={(e) =>
|
||||
update({ default_file_permissions: e.target.value })
|
||||
}
|
||||
/>
|
||||
</SettingFieldGrid>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.preserveTimestamps")}
|
||||
desc={t("settings.preserveTimestampsDesc")}
|
||||
>
|
||||
<SettingSwitch
|
||||
checked={transfer.preserve_timestamps}
|
||||
onChange={(v) => update({ preserve_timestamps: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.preserveTimestamps")}
|
||||
desc={t("settings.preserveTimestampsDesc")}
|
||||
>
|
||||
<SettingSwitch
|
||||
checked={transfer.preserve_timestamps}
|
||||
onChange={(v) => update({ preserve_timestamps: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.resumeBrokenTransfer")}
|
||||
desc={t("settings.resumeBrokenTransferDesc")}
|
||||
>
|
||||
<SettingSwitch
|
||||
checked={transfer.resume_broken_transfer}
|
||||
onChange={(v) => update({ resume_broken_transfer: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingSection>
|
||||
<SettingRow
|
||||
label={t("settings.resumeBrokenTransfer")}
|
||||
desc={t("settings.resumeBrokenTransferDesc")}
|
||||
>
|
||||
<SettingSwitch
|
||||
checked={transfer.resume_broken_transfer}
|
||||
onChange={(v) => update({ resume_broken_transfer: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingSection>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1031,6 +1031,7 @@
|
||||
"reloadDirtyTitle": "Discard unsaved changes?",
|
||||
"reloadUnsupportedEncoding": "The file is no longer valid UTF-8 text.",
|
||||
"remoteTarget": "Remote",
|
||||
"save": "Save",
|
||||
"saveAll": "Save All",
|
||||
"saveAllAndClose": "Save All and Close",
|
||||
"saveAndClose": "Save and Close",
|
||||
@@ -1212,7 +1213,10 @@
|
||||
"uploadFolderContentsEmpty": "The selected folder contains no items to upload.",
|
||||
"uploadOnce": "Upload Once",
|
||||
"uploadPrompt": "Remote file changed. Upload changes?",
|
||||
"webRecursiveTransferUnavailable": "Recursive transfers are not available in Web mode. Move directories within the same session."
|
||||
"webRecursiveTransferUnavailable": "Recursive transfers are not available in Web mode. Move directories within the same session.",
|
||||
"webUnsupportedDownload": "This file cannot be opened in the browser editor. Downloading it instead.",
|
||||
"webUploadRenamed": "Uploaded as: {{path}}",
|
||||
"webUploadSkipped": "Skipped existing file: {{path}}"
|
||||
},
|
||||
"filePreview": {
|
||||
"clearSort": "Clear sort",
|
||||
@@ -3177,7 +3181,8 @@
|
||||
"proxyCommandUnavailable": "ProxyCommand is only available in the desktop app.",
|
||||
"sessionTypesHint": "Web mode supports SSH, Telnet, and VNC. Use the desktop app for local terminals, serial, and RDP.",
|
||||
"signIn": "Sign in",
|
||||
"signInFailed": "Unable to sign in. Check your password and server connection."
|
||||
"signInFailed": "Unable to sign in. Check your password and server connection.",
|
||||
"signOut": "Sign out"
|
||||
},
|
||||
"zmodem": {
|
||||
"cancelled": "ZMODEM transfer cancelled",
|
||||
|
||||
@@ -1030,6 +1030,7 @@
|
||||
"reloadDirtyTitle": "저장하지 않은 변경 사항을 버릴까요?",
|
||||
"reloadUnsupportedEncoding": "파일이 더 이상 유효한 UTF-8 텍스트가 아닙니다.",
|
||||
"remoteTarget": "원격",
|
||||
"save": "저장",
|
||||
"saveAll": "모두 저장",
|
||||
"saveAllAndClose": "모두 저장하고 닫기",
|
||||
"saveAndClose": "저장하고 닫기",
|
||||
@@ -1211,7 +1212,10 @@
|
||||
"uploadFolderContentsEmpty": "선택한 폴더에 업로드할 항목이 없습니다.",
|
||||
"uploadOnce": "한 번만 업로드",
|
||||
"uploadPrompt": "원격 파일이 변경되었습니다. 변경 사항을 업로드할까요?",
|
||||
"webRecursiveTransferUnavailable": "Web에서는 재귀 전송을 사용할 수 없습니다. 디렉터리는 같은 세션 안에서 이동할 수 있습니다."
|
||||
"webRecursiveTransferUnavailable": "Web에서는 재귀 전송을 사용할 수 없습니다. 디렉터리는 같은 세션 안에서 이동할 수 있습니다.",
|
||||
"webUnsupportedDownload": "이 파일은 브라우저 편집기에서 열 수 없어 다운로드합니다.",
|
||||
"webUploadRenamed": "다음 이름으로 업로드했습니다: {{path}}",
|
||||
"webUploadSkipped": "기존 파일을 건너뛰었습니다: {{path}}"
|
||||
},
|
||||
"filePreview": {
|
||||
"clearSort": "정렬 지우기",
|
||||
@@ -2829,6 +2833,12 @@
|
||||
"webdavEndpoint": "WebDAV 엔드포인트",
|
||||
"webdavEndpointRequired": "WebDAV 엔드포인트가 필요합니다.",
|
||||
"windowBehavior": "창",
|
||||
"windowTransparency": "창 투명도",
|
||||
"windowTransparencyBlur": "배경 흐림",
|
||||
"windowTransparencyBlurDesc": "투명한 창 배경에 흐림 효과를 적용합니다.",
|
||||
"windowTransparencyDesc": "창 배경의 투명도를 조정합니다.",
|
||||
"windowTransparencyOpacity": "배경 불투명도",
|
||||
"windowTransparencyOpacityDesc": "창 배경의 불투명도를 조정합니다.",
|
||||
"wordSeparators": "단어 구분 문자",
|
||||
"wordSeparatorsDesc": "더블 클릭 시 단어를 구분하는 문자입니다.",
|
||||
"x11Display": "로컬 X11 DISPLAY",
|
||||
@@ -3169,7 +3179,8 @@
|
||||
"proxyCommandUnavailable": "ProxyCommand는 데스크톱 앱에서만 사용할 수 있습니다.",
|
||||
"sessionTypesHint": "웹 모드는 SSH, Telnet 및 VNC를 지원합니다. 로컬 터미널, 직렬 포트 및 RDP는 데스크톱 앱을 사용하세요.",
|
||||
"signIn": "로그인",
|
||||
"signInFailed": "로그인하지 못했습니다. 비밀번호와 서버 연결을 확인하세요."
|
||||
"signInFailed": "로그인하지 못했습니다. 비밀번호와 서버 연결을 확인하세요.",
|
||||
"signOut": "로그아웃"
|
||||
},
|
||||
"zmodem": {
|
||||
"cancelled": "ZMODEM 전송이 취소되었습니다",
|
||||
|
||||
@@ -1030,6 +1030,7 @@
|
||||
"reloadDirtyTitle": "放弃未保存修改?",
|
||||
"reloadUnsupportedEncoding": "文件已不再是有效的 UTF-8 文本。",
|
||||
"remoteTarget": "远程",
|
||||
"save": "保存",
|
||||
"saveAll": "全部保存",
|
||||
"saveAllAndClose": "全部保存并关闭",
|
||||
"saveAndClose": "保存并关闭",
|
||||
@@ -1211,7 +1212,10 @@
|
||||
"uploadFolderContentsEmpty": "所选文件夹中没有可上传的内容。",
|
||||
"uploadOnce": "上传一次",
|
||||
"uploadPrompt": "远程文件已修改,是否上传更改?",
|
||||
"webRecursiveTransferUnavailable": "Web 暂不支持递归传输,目录仅支持在同一会话内移动。"
|
||||
"webRecursiveTransferUnavailable": "Web 暂不支持递归传输,目录仅支持在同一会话内移动。",
|
||||
"webUnsupportedDownload": "此文件无法在浏览器编辑器中打开,将下载到本地。",
|
||||
"webUploadRenamed": "已重命名上传为:{{path}}",
|
||||
"webUploadSkipped": "已跳过同名文件:{{path}}"
|
||||
},
|
||||
"filePreview": {
|
||||
"clearSort": "清除排序",
|
||||
@@ -3175,7 +3179,8 @@
|
||||
"proxyCommandUnavailable": "ProxyCommand 仅适用于桌面版。",
|
||||
"sessionTypesHint": "Web 模式支持 SSH、Telnet 和 VNC。本地终端、串口和 RDP 请使用桌面版。",
|
||||
"signIn": "登录",
|
||||
"signInFailed": "登录失败,请检查密码与服务端连接。"
|
||||
"signInFailed": "登录失败,请检查密码与服务端连接。",
|
||||
"signOut": "注销"
|
||||
},
|
||||
"zmodem": {
|
||||
"cancelled": "ZMODEM 传输已取消",
|
||||
|
||||
@@ -1030,6 +1030,7 @@
|
||||
"reloadDirtyTitle": "要捨棄未儲存的變更嗎?",
|
||||
"reloadUnsupportedEncoding": "檔案已不再是有效的 UTF-8 文字。",
|
||||
"remoteTarget": "遠端",
|
||||
"save": "儲存",
|
||||
"saveAll": "全部儲存",
|
||||
"saveAllAndClose": "全部儲存並關閉",
|
||||
"saveAndClose": "儲存並關閉",
|
||||
@@ -1211,7 +1212,10 @@
|
||||
"uploadFolderContentsEmpty": "所選資料夾中沒有可上傳的內容。",
|
||||
"uploadOnce": "上傳一次",
|
||||
"uploadPrompt": "遠端檔案已修改,是否上傳變更?",
|
||||
"webRecursiveTransferUnavailable": "Web 暫不支援遞迴傳輸,目錄僅支援在同一工作階段內移動。"
|
||||
"webRecursiveTransferUnavailable": "Web 暫不支援遞迴傳輸,目錄僅支援在同一工作階段內移動。",
|
||||
"webUnsupportedDownload": "此檔案無法在瀏覽器編輯器中開啟,將下載至本機。",
|
||||
"webUploadRenamed": "已重新命名上傳為:{{path}}",
|
||||
"webUploadSkipped": "已略過同名檔案:{{path}}"
|
||||
},
|
||||
"filePreview": {
|
||||
"clearSort": "清除排序",
|
||||
@@ -1952,6 +1956,8 @@
|
||||
"jumpHostChain": "跳板鏈路",
|
||||
"jumpHostCycle": "偵測到迴圈",
|
||||
"jumpHostMissing": "跳板機已缺失",
|
||||
"moveToGroup": "移至群組",
|
||||
"moveToGroupFailed": "移動失敗:{{error}}",
|
||||
"newConnection": "新增連線",
|
||||
"newFolder": "新增資料夾",
|
||||
"newSubfolder": "新增子資料夾",
|
||||
@@ -1981,6 +1987,7 @@
|
||||
"stopBits": "停止位",
|
||||
"tags": "標籤",
|
||||
"terminalPath": "終端路徑",
|
||||
"ungroupedConnections": "未分組",
|
||||
"user": "使用者",
|
||||
"windtermHint": ".wind/profiles/default.v10/terminal/user.sessions",
|
||||
"windtermMasterPasswordConfirm": "匯入",
|
||||
@@ -2060,8 +2067,10 @@
|
||||
"shellTargets": "非序列埠工作階段:{{count}}",
|
||||
"shellUnavailable": "沒有可用的 Shell 視窗",
|
||||
"shellUnavailableDesc": "目前使用中視窗中沒有 SSH、本機終端或 Telnet 工作階段可接收命令。",
|
||||
"singleSession": "{{name}} · {{type}}{{tabSuffix}}",
|
||||
"stop": "停止",
|
||||
"target": "目標工作階段",
|
||||
"targetSessions": "工作階段",
|
||||
"text": "文字",
|
||||
"textPlaceholder": "輸入要傳送的文字...",
|
||||
"unavailable": "沒有可用的活動工作階段",
|
||||
@@ -3170,7 +3179,8 @@
|
||||
"proxyCommandUnavailable": "ProxyCommand 僅適用於桌面版。",
|
||||
"sessionTypesHint": "Web 模式支援 SSH、Telnet 和 VNC。本機終端、序列埠和 RDP 請使用桌面版。",
|
||||
"signIn": "登入",
|
||||
"signInFailed": "登入失敗,請檢查密碼與伺服器連線。"
|
||||
"signInFailed": "登入失敗,請檢查密碼與伺服器連線。",
|
||||
"signOut": "登出"
|
||||
},
|
||||
"zmodem": {
|
||||
"cancelled": "ZMODEM 傳輸已取消",
|
||||
|
||||
+42
-17
@@ -1,22 +1,59 @@
|
||||
import { backendURL, runtime, requireCapability } from "./runtime";
|
||||
import { csrfHeader } from "./http";
|
||||
|
||||
export function downloadBrowserFile(sessionId: string, path: string): void {
|
||||
export interface BrowserUploadResult {
|
||||
bytes: number;
|
||||
status: "completed" | "skipped";
|
||||
path: string;
|
||||
}
|
||||
|
||||
export async function uploadBrowserFile(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
file: Blob,
|
||||
): Promise<BrowserUploadResult> {
|
||||
const url = backendURL(
|
||||
`api/sessions/${encodeURIComponent(sessionId)}/upload`,
|
||||
);
|
||||
url.searchParams.set("path", path);
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { ...csrfHeader(), "Content-Type": "application/octet-stream" },
|
||||
body: file,
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.error ?? "Upload failed");
|
||||
return result as BrowserUploadResult;
|
||||
}
|
||||
|
||||
export async function downloadBrowserFile(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
requireCapability("browserFiles");
|
||||
const url = backendURL(
|
||||
`api/sessions/${encodeURIComponent(sessionId)}/download`,
|
||||
);
|
||||
url.searchParams.set("path", path);
|
||||
const link = document.createElement("a");
|
||||
link.href = url.href;
|
||||
const response = await fetch(url, { credentials: "same-origin" });
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
throw new Error(result.error ?? "Download failed");
|
||||
}
|
||||
const objectURL = URL.createObjectURL(await response.blob());
|
||||
link.href = objectURL;
|
||||
link.download = path.split("/").pop() ?? "download";
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectURL), 60_000);
|
||||
}
|
||||
export async function uploadBrowserFiles(
|
||||
sessionId: string,
|
||||
directory: string,
|
||||
onResult?: (result: BrowserUploadResult, requestedPath: string) => void,
|
||||
): Promise<boolean> {
|
||||
if (runtime === "desktop") return false;
|
||||
const picker = document.createElement("input");
|
||||
@@ -28,21 +65,9 @@ export async function uploadBrowserFiles(
|
||||
picker.click();
|
||||
});
|
||||
for (const file of files) {
|
||||
const url = backendURL(
|
||||
`api/sessions/${encodeURIComponent(sessionId)}/upload`,
|
||||
);
|
||||
url.searchParams.set(
|
||||
"path",
|
||||
`${directory.replace(/\/$/, "")}/${file.name}`,
|
||||
);
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { ...csrfHeader(), "Content-Type": "application/octet-stream" },
|
||||
body: file,
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error((await response.json()).error ?? "Upload failed");
|
||||
const path = `${directory.replace(/\/$/, "")}/${file.name}`;
|
||||
const result = await uploadBrowserFile(sessionId, path, file);
|
||||
onResult?.(result, path);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
+50
-101
@@ -1,11 +1,25 @@
|
||||
import { backendURL, requireCapability } from "./runtime";
|
||||
import { deliver } from "./events";
|
||||
import { BrowserTerminals } from "./terminal";
|
||||
import { uploadBrowserFile } from "./files";
|
||||
import { closeBrowserVnc, closeAllBrowserVnc, sendBrowserVnc } from "./vnc";
|
||||
|
||||
let csrf: string | undefined;
|
||||
let source: EventSource | undefined;
|
||||
let eventReady: Promise<void> | undefined;
|
||||
const sockets = new Map<string, { socket: WebSocket; ready: Promise<void>; closed: boolean }>();
|
||||
const terminals = new BrowserTerminals(
|
||||
(id, signal) =>
|
||||
request("api/commands/get_session_info", { sessionId: id }, "POST", signal),
|
||||
(id) => request("api/commands/close_session", { sessionId: id }),
|
||||
);
|
||||
export class BackendRequestError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
const nativeCommands =
|
||||
/^(create_local_session|create_serial_session|create_rdp_session|.*zmodem.*|.*serial_modem.*|.*local_file.*|.*watcher.*|open_local.*|install_plugin|import_plugin)$/;
|
||||
|
||||
@@ -13,9 +27,11 @@ export async function request<T>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
method = body === undefined ? "GET" : "POST",
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const response = await fetch(backendURL(path), {
|
||||
method,
|
||||
signal,
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -25,7 +41,11 @@ export async function request<T>(
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const value = await response.json();
|
||||
if (!response.ok) throw new Error(value.error ?? `Backend request failed (${response.status})`);
|
||||
if (!response.ok)
|
||||
throw new BackendRequestError(
|
||||
value.error ?? `Backend request failed (${response.status})`,
|
||||
response.status,
|
||||
);
|
||||
return value as T;
|
||||
}
|
||||
export async function authenticate(password?: string): Promise<void> {
|
||||
@@ -57,89 +77,27 @@ export function startEvents(): Promise<void> {
|
||||
event.event.replace("connection-error-", "session-error-"),
|
||||
typeof payload === "string" ? payload : payload.error,
|
||||
);
|
||||
} else if (event.event.startsWith("session-closed-")) {
|
||||
terminals.stop(event.event.slice("session-closed-".length), true);
|
||||
} else deliver(event.event, event.payload);
|
||||
};
|
||||
source.addEventListener("expired", () => window.location.reload());
|
||||
source.addEventListener("expired", () => {
|
||||
terminals.stopAll();
|
||||
closeAllBrowserVnc();
|
||||
source?.close();
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
return eventReady;
|
||||
}
|
||||
async function attach(id: string): Promise<void> {
|
||||
const previous = sockets.get(id);
|
||||
if (previous && !previous.closed) return previous.ready;
|
||||
const url = backendURL(`api/sessions/${encodeURIComponent(id)}/terminal`);
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||
const socket = new WebSocket(url);
|
||||
socket.binaryType = "arraybuffer";
|
||||
const decoder = new TextDecoder();
|
||||
const state = { socket, ready: Promise.resolve(), closed: false };
|
||||
let opened = false;
|
||||
let rejectReady: (reason: Error) => void = () => {};
|
||||
state.ready = new Promise<void>((resolve, reject) => {
|
||||
rejectReady = reject;
|
||||
socket.onopen = () => {
|
||||
opened = true;
|
||||
resolve();
|
||||
};
|
||||
socket.onerror = () => reject(new Error("Terminal WebSocket unavailable"));
|
||||
});
|
||||
socket.onmessage = ({ data }) => {
|
||||
if (typeof data !== "string") {
|
||||
// Stream decoding preserves UTF-8 characters split across SSH packets.
|
||||
try {
|
||||
deliver(`terminal-output-${id}`, {
|
||||
data: decoder.decode(data, { stream: true }),
|
||||
bytes: data.byteLength,
|
||||
});
|
||||
} catch {
|
||||
state.closed = true;
|
||||
socket.close();
|
||||
void request("api/commands/close_session", { sessionId: id }).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
const event = JSON.parse(data);
|
||||
if (event.type === "error") deliver(`session-error-${id}`, event.error);
|
||||
if (event.type === "closed" || event.type === "error") {
|
||||
state.closed = true;
|
||||
deliver(`session-closed-${id}`, null);
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
};
|
||||
socket.onclose = () => {
|
||||
if (!opened) {
|
||||
state.closed = true;
|
||||
rejectReady(new Error("Terminal WebSocket closed"));
|
||||
return;
|
||||
}
|
||||
if (state.closed) {
|
||||
if (sockets.get(id) === state) sockets.delete(id);
|
||||
return;
|
||||
}
|
||||
state.closed = true;
|
||||
// A transport interruption has a bounded server lease. Attempt a same-session
|
||||
// attachment; an explicit reconnect in the UI still creates a new SSH session.
|
||||
window.setTimeout(() => {
|
||||
if (sockets.get(id) !== state) return;
|
||||
void attach(id).catch(() => deliver(`session-closed-${id}`, null));
|
||||
}, 1000);
|
||||
};
|
||||
sockets.set(id, state);
|
||||
return state.ready;
|
||||
}
|
||||
async function terminalSend(id: string, message: string | Uint8Array): Promise<void> {
|
||||
await attach(id);
|
||||
const socket = sockets.get(id)?.socket;
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) throw new Error("Terminal is disconnected");
|
||||
if (socket.bufferedAmount > 1024 * 1024) throw new Error("Terminal input queue is full");
|
||||
socket.send(message);
|
||||
}
|
||||
export async function httpInvoke<T>(
|
||||
command: string,
|
||||
args: Record<string, unknown> = {},
|
||||
): Promise<T> {
|
||||
if (command === "get_default_local_shell") requireCapability("localShell");
|
||||
if (nativeCommands.test(command)) requireCapability("nativeFiles");
|
||||
if (command === "read_clipboard_text") return (await navigator.clipboard.readText()) as T;
|
||||
if (command === "read_clipboard_text")
|
||||
return (await navigator.clipboard.readText()) as T;
|
||||
if (command === "write_clipboard_text") {
|
||||
await navigator.clipboard.writeText(String(args.text));
|
||||
return undefined as T;
|
||||
@@ -151,24 +109,18 @@ export async function httpInvoke<T>(
|
||||
const item = items.find((entry) => entry.types.includes("image/png"));
|
||||
if (!item) return null as T;
|
||||
const image = await item.getType("image/png");
|
||||
if (image.size > 10 * 1024 * 1024) throw new Error("Clipboard image exceeds 10 MiB");
|
||||
if (image.size > 10 * 1024 * 1024)
|
||||
throw new Error("Clipboard image exceeds 10 MiB");
|
||||
const sessionId = String(args.sessionId);
|
||||
const directory =
|
||||
typeof args.remoteDir === "string"
|
||||
? args.remoteDir
|
||||
: await request<string>("api/commands/get_home_dir", { sessionId });
|
||||
const path = `${directory.replace(/\/$/, "")}/nyaterm-clipboard-${crypto.randomUUID()}.png`;
|
||||
const url = backendURL(`api/sessions/${encodeURIComponent(sessionId)}/upload`);
|
||||
url.searchParams.set("path", path);
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { ...csrfHeader(), "Content-Type": "application/octet-stream" },
|
||||
body: image,
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error((await response.json()).error ?? "Clipboard image upload failed");
|
||||
return { remote_path: path } as T;
|
||||
const result = await uploadBrowserFile(sessionId, path, image);
|
||||
return (
|
||||
result.status === "skipped" ? null : { remote_path: result.path }
|
||||
) as T;
|
||||
}
|
||||
if (command === "read_clipboard_file_paths") return [] as T;
|
||||
if (
|
||||
@@ -186,13 +138,9 @@ export async function httpInvoke<T>(
|
||||
const id = String(args.sessionId ?? "");
|
||||
switch (command) {
|
||||
case "quit_application": {
|
||||
terminals.stopAll();
|
||||
await request("api/auth/logout", {});
|
||||
source?.close();
|
||||
for (const active of sockets.values()) {
|
||||
active.closed = true;
|
||||
active.socket.close();
|
||||
}
|
||||
sockets.clear();
|
||||
closeAllBrowserVnc();
|
||||
window.location.reload();
|
||||
return undefined as T;
|
||||
@@ -214,16 +162,22 @@ export async function httpInvoke<T>(
|
||||
return result.session_id as T;
|
||||
}
|
||||
case "attach_session":
|
||||
await attach(id);
|
||||
await terminals.attach(id);
|
||||
return undefined as T;
|
||||
case "write_to_session":
|
||||
await terminalSend(id, JSON.stringify({ type: "input", data: args.data }));
|
||||
await terminals.send(
|
||||
id,
|
||||
JSON.stringify({ type: "input", data: args.data }),
|
||||
);
|
||||
return undefined as T;
|
||||
case "write_bytes_to_session":
|
||||
await terminalSend(id, new Uint8Array(args.data as number[]));
|
||||
await terminals.send(id, new Uint8Array(args.data as number[]));
|
||||
return undefined as T;
|
||||
case "resize_session":
|
||||
await terminalSend(id, JSON.stringify({ type: "resize", cols: args.cols, rows: args.rows }));
|
||||
await terminals.send(
|
||||
id,
|
||||
JSON.stringify({ type: "resize", cols: args.cols, rows: args.rows }),
|
||||
);
|
||||
return undefined as T;
|
||||
case "vnc_input_batch":
|
||||
await sendBrowserVnc(id, { type: "input", events: args.events });
|
||||
@@ -235,12 +189,7 @@ export async function httpInvoke<T>(
|
||||
closeBrowserVnc(id);
|
||||
break;
|
||||
case "close_session": {
|
||||
const active = sockets.get(id);
|
||||
if (active) {
|
||||
active.closed = true;
|
||||
active.socket.close();
|
||||
sockets.delete(id);
|
||||
}
|
||||
terminals.stop(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { BrowserTerminals } from "./terminal";
|
||||
import { browserListen } from "./events";
|
||||
|
||||
class Socket {
|
||||
static OPEN = 1;
|
||||
static instances: Socket[] = [];
|
||||
readyState = 0;
|
||||
bufferedAmount = 0;
|
||||
binaryType = "";
|
||||
onopen?: () => void;
|
||||
onclose?: () => void;
|
||||
onerror?: () => void;
|
||||
onmessage?: (event: { data: unknown }) => void;
|
||||
send = vi.fn();
|
||||
constructor() {
|
||||
Socket.instances.push(this);
|
||||
}
|
||||
open() {
|
||||
this.readyState = 1;
|
||||
this.onopen?.();
|
||||
}
|
||||
close() {
|
||||
this.readyState = 3;
|
||||
this.onclose?.();
|
||||
}
|
||||
}
|
||||
let terminals: BrowserTerminals;
|
||||
let probe: ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<unknown>>>;
|
||||
let closeRemote: ReturnType<
|
||||
typeof vi.fn<(...args: unknown[]) => Promise<unknown>>
|
||||
>;
|
||||
let unlisten: Array<() => void>;
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal("WebSocket", Socket);
|
||||
vi.stubGlobal("BroadcastChannel", undefined);
|
||||
Socket.instances = [];
|
||||
probe = vi.fn().mockResolvedValue({ connected: true });
|
||||
closeRemote = vi.fn().mockResolvedValue(null);
|
||||
terminals = new BrowserTerminals(probe, closeRemote);
|
||||
unlisten = [];
|
||||
});
|
||||
afterEach(() => {
|
||||
terminals.stopAll();
|
||||
for (const stop of unlisten) stop();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
async function closed(id = "test") {
|
||||
const callback = vi.fn();
|
||||
unlisten.push(await browserListen(`session-closed-${id}`, callback));
|
||||
return callback;
|
||||
}
|
||||
async function open() {
|
||||
const ready = terminals.attach("test");
|
||||
Socket.instances[Socket.instances.length - 1].open();
|
||||
await ready;
|
||||
}
|
||||
|
||||
describe("browser terminal recovery", () => {
|
||||
it("shares concurrent attachment and retries a failed initial handshake", async () => {
|
||||
const callback = await closed();
|
||||
const ready = terminals.attach("test");
|
||||
expect(terminals.attach("test")).toBe(ready);
|
||||
Socket.instances[0].onerror?.();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(Socket.instances).toHaveLength(2);
|
||||
Socket.instances[1].open();
|
||||
await ready;
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
it("survives multiple recovery handshake failures and ignores old callbacks", async () => {
|
||||
const callback = await closed();
|
||||
await open();
|
||||
const old = Socket.instances[0];
|
||||
old.close();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
Socket.instances[1].onerror?.();
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
Socket.instances[2].onerror?.();
|
||||
await vi.advanceTimersByTimeAsync(4000);
|
||||
Socket.instances[3].open();
|
||||
await terminals.attach("test");
|
||||
old.onmessage?.({ data: JSON.stringify({ type: "closed" }) });
|
||||
old.onclose?.();
|
||||
await terminals.send("test", "ok");
|
||||
expect(Socket.instances[3].send).toHaveBeenCalledWith("ok");
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
it("retries temporary session query failures", async () => {
|
||||
probe.mockRejectedValue({ status: 503 });
|
||||
const ready = terminals.attach("test");
|
||||
Socket.instances[0].onerror?.();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
Socket.instances[1].open();
|
||||
await ready;
|
||||
expect(probe).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("bounds hanging handshakes and emits closure once when the lease budget ends", async () => {
|
||||
const callback = await closed();
|
||||
const ready = terminals.attach("test");
|
||||
const rejected = expect(ready).rejects.toThrow();
|
||||
await vi.advanceTimersByTimeAsync(25_000);
|
||||
await rejected;
|
||||
expect(probe).toHaveBeenCalled();
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
terminals.stop("test", true);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(Socket.instances.every((socket) => socket.readyState === 3)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
it.each([401, 404])(
|
||||
"stops immediately for a %i session probe",
|
||||
async (status) => {
|
||||
const callback = await closed();
|
||||
probe.mockRejectedValue({ status });
|
||||
const ready = terminals.attach("test");
|
||||
const rejected = expect(ready).rejects.toEqual({ status });
|
||||
Socket.instances[0].onerror?.();
|
||||
await rejected;
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(Socket.instances).toHaveLength(1);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
it.each(["stop", "logout"])(
|
||||
"cancels delayed retries on %s",
|
||||
async (action) => {
|
||||
const callback = await closed();
|
||||
await open();
|
||||
Socket.instances[0].close();
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
if (action === "stop") terminals.stop("test");
|
||||
else terminals.stopAll();
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(Socket.instances).toHaveLength(1);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
it("cancels a pending handshake on server closure", async () => {
|
||||
const callback = await closed();
|
||||
const ready = terminals.attach("test");
|
||||
const rejected = expect(ready).rejects.toThrow();
|
||||
terminals.stop("test", true);
|
||||
await rejected;
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
expect(Socket.instances).toHaveLength(1);
|
||||
});
|
||||
it("keeps UTF-8 decoding across recovered attachments", async () => {
|
||||
const output: string[] = [];
|
||||
unlisten.push(
|
||||
await browserListen<{ data: string }>(
|
||||
"terminal-output-test",
|
||||
({ payload }) => output.push(payload.data),
|
||||
),
|
||||
);
|
||||
await open();
|
||||
const bytes = new TextEncoder().encode("你好");
|
||||
Socket.instances[0].onmessage?.({ data: bytes.slice(0, 2).buffer });
|
||||
Socket.instances[0].close();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
Socket.instances[1].open();
|
||||
await terminals.attach("test");
|
||||
Socket.instances[1].onmessage?.({ data: bytes.slice(2).buffer });
|
||||
expect(output.join("")).toBe("你好");
|
||||
});
|
||||
it("preserves input backpressure", async () => {
|
||||
await open();
|
||||
Socket.instances[0].bufferedAmount = 2 * 1024 * 1024;
|
||||
await expect(terminals.send("test", "input")).rejects.toThrow(
|
||||
"queue is full",
|
||||
);
|
||||
expect(Socket.instances[0].send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import { backendURL } from "./runtime";
|
||||
import { deliver } from "./events";
|
||||
|
||||
interface TerminalStream {
|
||||
id: string;
|
||||
socket?: WebSocket;
|
||||
ready?: Promise<void>;
|
||||
decoder: TextDecoder;
|
||||
stopped: boolean;
|
||||
closedDelivered: boolean;
|
||||
recovery?: AbortController;
|
||||
}
|
||||
|
||||
/** A recovery cycle fits inside the server's 30-second attachment lease. */
|
||||
export class BrowserTerminals {
|
||||
private streams = new Map<string, TerminalStream>();
|
||||
constructor(
|
||||
private probe: (id: string, signal: AbortSignal) => Promise<unknown>,
|
||||
private closeRemote: (id: string) => Promise<unknown>,
|
||||
) {}
|
||||
|
||||
attach(id: string): Promise<void> {
|
||||
let stream = this.streams.get(id);
|
||||
if (!stream) {
|
||||
stream = {
|
||||
id,
|
||||
decoder: new TextDecoder(),
|
||||
stopped: false,
|
||||
closedDelivered: false,
|
||||
};
|
||||
this.streams.set(id, stream);
|
||||
}
|
||||
if (stream.stopped) return Promise.reject(new Error("Terminal is closed"));
|
||||
if (stream.ready) return stream.ready;
|
||||
if (stream.socket?.readyState === WebSocket.OPEN) return Promise.resolve();
|
||||
return this.recover(stream);
|
||||
}
|
||||
|
||||
private recover(stream: TerminalStream, delay = 0): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
stream.recovery = controller;
|
||||
const deadline = window.setTimeout(() => controller.abort(), 25_000);
|
||||
const run = async () => {
|
||||
let retry = delay ? 1 : 0;
|
||||
try {
|
||||
if (delay) await this.wait(delay, controller.signal);
|
||||
for (;;) {
|
||||
controller.signal.throwIfAborted();
|
||||
try {
|
||||
await this.connect(stream, controller.signal);
|
||||
return;
|
||||
} catch (error) {
|
||||
controller.signal.throwIfAborted();
|
||||
try {
|
||||
await this.probe(stream.id, controller.signal);
|
||||
} catch (probeError) {
|
||||
const status = (probeError as { status?: number })?.status;
|
||||
if (status === 401 || status === 404) throw probeError;
|
||||
}
|
||||
controller.signal.throwIfAborted();
|
||||
await this.wait(
|
||||
Math.min(1000 * 2 ** retry++, 5000),
|
||||
controller.signal,
|
||||
);
|
||||
// Failed handshakes do not mean the remote session has ended.
|
||||
void error;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!stream.stopped) this.stop(stream.id, true);
|
||||
throw error;
|
||||
} finally {
|
||||
window.clearTimeout(deadline);
|
||||
if (stream.recovery === controller) {
|
||||
stream.recovery = undefined;
|
||||
stream.ready = undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
const ready = run();
|
||||
stream.ready = ready;
|
||||
// Socket-driven recovery has no awaiting caller; still retain rejection for attach/send.
|
||||
void ready.catch(() => {});
|
||||
return ready;
|
||||
}
|
||||
|
||||
private wait(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const finish = () => {
|
||||
signal.removeEventListener("abort", cancel);
|
||||
resolve();
|
||||
};
|
||||
const timer = window.setTimeout(finish, ms);
|
||||
const cancel = () => {
|
||||
window.clearTimeout(timer);
|
||||
signal.removeEventListener("abort", cancel);
|
||||
reject(new Error("Terminal recovery cancelled"));
|
||||
};
|
||||
signal.addEventListener("abort", cancel, { once: true });
|
||||
if (signal.aborted) cancel();
|
||||
});
|
||||
}
|
||||
|
||||
private connect(stream: TerminalStream, signal: AbortSignal): Promise<void> {
|
||||
const url = backendURL(
|
||||
`api/sessions/${encodeURIComponent(stream.id)}/terminal`,
|
||||
);
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||
const socket = new WebSocket(url);
|
||||
socket.binaryType = "arraybuffer";
|
||||
stream.socket = socket;
|
||||
return new Promise((resolve, reject) => {
|
||||
let opened = false;
|
||||
let settled = false;
|
||||
const current = () => !stream.stopped && stream.socket === socket;
|
||||
const cleanup = () => {
|
||||
window.clearTimeout(timer);
|
||||
signal.removeEventListener("abort", fail);
|
||||
};
|
||||
const fail = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
if (stream.socket === socket) stream.socket = undefined;
|
||||
socket.close();
|
||||
reject(new Error("Terminal WebSocket unavailable"));
|
||||
};
|
||||
const timer = window.setTimeout(fail, 5000);
|
||||
signal.addEventListener("abort", fail, { once: true });
|
||||
socket.onopen = () => {
|
||||
if (!current() || settled) {
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
opened = settled = true;
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
socket.onerror = () => {
|
||||
if (!opened) fail();
|
||||
};
|
||||
socket.onclose = () => {
|
||||
if (!opened) {
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
if (!current()) return;
|
||||
stream.socket = undefined;
|
||||
// Usually ready has cleared by now. A same-tick close waits for its finally.
|
||||
void Promise.resolve(stream.ready)
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
if (!stream.stopped && !stream.socket && !stream.ready)
|
||||
void this.recover(stream, 1000);
|
||||
});
|
||||
};
|
||||
socket.onmessage = ({ data }) => {
|
||||
if (!current()) return;
|
||||
try {
|
||||
if (typeof data !== "string") {
|
||||
deliver(`terminal-output-${stream.id}`, {
|
||||
data: stream.decoder.decode(data, { stream: true }),
|
||||
bytes: data.byteLength,
|
||||
});
|
||||
} else {
|
||||
const event = JSON.parse(data);
|
||||
if (event.type === "error")
|
||||
deliver(`session-error-${stream.id}`, event.error);
|
||||
if (event.type === "closed" || event.type === "error")
|
||||
this.stop(stream.id, true);
|
||||
}
|
||||
} catch {
|
||||
this.stop(stream.id, true);
|
||||
void this.closeRemote(stream.id).catch(() => {});
|
||||
}
|
||||
};
|
||||
if (signal.aborted) fail();
|
||||
});
|
||||
}
|
||||
|
||||
async send(id: string, message: string | Uint8Array): Promise<void> {
|
||||
await this.attach(id);
|
||||
const socket = this.streams.get(id)?.socket;
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN)
|
||||
throw new Error("Terminal is disconnected");
|
||||
if (socket.bufferedAmount > 1024 * 1024)
|
||||
throw new Error("Terminal input queue is full");
|
||||
socket.send(message);
|
||||
}
|
||||
|
||||
stop(id: string, notify = false): void {
|
||||
let stream = this.streams.get(id);
|
||||
if (!stream) {
|
||||
stream = {
|
||||
id,
|
||||
decoder: new TextDecoder(),
|
||||
stopped: true,
|
||||
closedDelivered: false,
|
||||
};
|
||||
this.streams.set(id, stream);
|
||||
}
|
||||
stream.stopped = true;
|
||||
stream.recovery?.abort();
|
||||
stream.socket?.close();
|
||||
stream.socket = undefined;
|
||||
if (notify && !stream.closedDelivered) {
|
||||
stream.closedDelivered = true;
|
||||
deliver(`session-closed-${id}`, null);
|
||||
}
|
||||
}
|
||||
|
||||
stopAll(): void {
|
||||
for (const id of this.streams.keys()) this.stop(id);
|
||||
}
|
||||
}
|
||||
+195
-36
@@ -1,5 +1,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
renderHook,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
import type { TerminalFitScheduler } from "@/components/terminal/terminalFitScheduler";
|
||||
|
||||
@@ -40,7 +47,9 @@ class FakeSocket {
|
||||
class FakeEvents {
|
||||
onmessage?: (event: { data: string }) => void;
|
||||
constructor() {
|
||||
queueMicrotask(() => this.onmessage?.({ data: JSON.stringify({ event: "ready" }) }));
|
||||
queueMicrotask(() =>
|
||||
this.onmessage?.({ data: JSON.stringify({ event: "ready" }) }),
|
||||
);
|
||||
}
|
||||
addEventListener() {}
|
||||
close() {}
|
||||
@@ -66,7 +75,13 @@ beforeEach(() => {
|
||||
? { csrf: "csrf-test" }
|
||||
: url.pathname.endsWith("/sessions")
|
||||
? { session_id: "session-1" }
|
||||
: null,
|
||||
: url.pathname.endsWith("/upload")
|
||||
? {
|
||||
bytes: 4,
|
||||
status: "completed",
|
||||
path: url.searchParams.get("path"),
|
||||
}
|
||||
: null,
|
||||
),
|
||||
{ headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
@@ -83,23 +98,38 @@ afterEach(() => {
|
||||
|
||||
describe("browser backend boundary", () => {
|
||||
it("uploads browser clipboard PNG bytes with session ownership and CSRF", async () => {
|
||||
const image = new Blob([new Uint8Array([137, 80, 78, 71])], { type: "image/png" });
|
||||
const image = new Blob([new Uint8Array([137, 80, 78, 71])], {
|
||||
type: "image/png",
|
||||
});
|
||||
const clipboard = {
|
||||
read: vi.fn().mockResolvedValue([{ types: ["image/png"], getType: async () => image }]),
|
||||
read: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ types: ["image/png"], getType: async () => image },
|
||||
]),
|
||||
};
|
||||
const previous = Object.getOwnPropertyDescriptor(navigator, "clipboard");
|
||||
Object.defineProperty(navigator, "clipboard", { configurable: true, value: clipboard });
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: clipboard,
|
||||
});
|
||||
try {
|
||||
const { authenticate, httpInvoke } = await import("./http");
|
||||
await authenticate();
|
||||
const result = await httpInvoke<{ remote_path: string }>("upload_clipboard_image_to_ssh", {
|
||||
sessionId: "owned-session",
|
||||
remoteDir: "/home/test",
|
||||
});
|
||||
const [url, options] = fetchMock.mock.calls[fetchMock.mock.calls.length - 1]!;
|
||||
const result = await httpInvoke<{ remote_path: string }>(
|
||||
"upload_clipboard_image_to_ssh",
|
||||
{
|
||||
sessionId: "owned-session",
|
||||
remoteDir: "/home/test",
|
||||
},
|
||||
);
|
||||
const [url, options] =
|
||||
fetchMock.mock.calls[fetchMock.mock.calls.length - 1]!;
|
||||
expect(url.pathname).toBe("/nyaterm/api/sessions/owned-session/upload");
|
||||
expect(url.searchParams.get("path")).toBe(result.remote_path);
|
||||
expect(result.remote_path).toMatch(/^\/home\/test\/nyaterm-clipboard-.*\.png$/);
|
||||
expect(result.remote_path).toMatch(
|
||||
/^\/home\/test\/nyaterm-clipboard-.*\.png$/,
|
||||
);
|
||||
expect(options).toMatchObject({
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
@@ -112,9 +142,89 @@ describe("browser backend boundary", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["completed", "skipped"])(
|
||||
"uses final clipboard upload path and handles %s",
|
||||
async (status) => {
|
||||
const image = new Blob(["PNG"], { type: "image/png" });
|
||||
const previous = Object.getOwnPropertyDescriptor(navigator, "clipboard");
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: {
|
||||
read: async () => [
|
||||
{ types: ["image/png"], getType: async () => image },
|
||||
],
|
||||
},
|
||||
});
|
||||
fetchMock.mockImplementation(
|
||||
async (url: URL) =>
|
||||
new Response(
|
||||
JSON.stringify(
|
||||
url.pathname.endsWith("/upload")
|
||||
? {
|
||||
bytes: status === "skipped" ? 0 : 3,
|
||||
status,
|
||||
path: "/renamed.png.uuid",
|
||||
}
|
||||
: { csrf: "csrf-test" },
|
||||
),
|
||||
),
|
||||
);
|
||||
try {
|
||||
const { authenticate, httpInvoke } = await import("./http");
|
||||
await authenticate();
|
||||
expect(
|
||||
await httpInvoke("upload_clipboard_image_to_ssh", {
|
||||
sessionId: "owned-session",
|
||||
remoteDir: "/",
|
||||
}),
|
||||
).toEqual(
|
||||
status === "skipped" ? null : { remote_path: "/renamed.png.uuid" },
|
||||
);
|
||||
} finally {
|
||||
if (previous) Object.defineProperty(navigator, "clipboard", previous);
|
||||
else Reflect.deleteProperty(navigator, "clipboard");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("returns browser upload results and rejects failed downloads", async () => {
|
||||
const { uploadBrowserFile, downloadBrowserFile } = await import("./files");
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({ bytes: 0, status: "skipped", path: "/same.txt" }),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
await uploadBrowserFile("s", "/same.txt", new Blob(["data"])),
|
||||
).toEqual({ bytes: 0, status: "skipped", path: "/same.txt" });
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
bytes: 4,
|
||||
status: "completed",
|
||||
path: "/same.txt.uuid",
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
(await uploadBrowserFile("s", "/same.txt", new Blob(["data"]))).path,
|
||||
).toBe("/same.txt.uuid");
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ error: "Permission denied" }), {
|
||||
status: 400,
|
||||
}),
|
||||
);
|
||||
await expect(downloadBrowserFile("s", "/denied.txt")).rejects.toThrow(
|
||||
"Permission denied",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses Web background data URLs without sending desktop paths to the server", async () => {
|
||||
const { loadBackgroundImageDataUrl } = await import("@/lib/backgroundImage");
|
||||
expect(await loadBackgroundImageDataUrl("C:\\Users\\test\\background.png")).toBe("");
|
||||
const { loadBackgroundImageDataUrl } =
|
||||
await import("@/lib/backgroundImage");
|
||||
expect(
|
||||
await loadBackgroundImageDataUrl("C:\\Users\\test\\background.png"),
|
||||
).toBe("");
|
||||
const image = "data:image/png;base64,iVBORw0KGgo=";
|
||||
expect(await loadBackgroundImageDataUrl(image)).toBe(image);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
@@ -160,9 +270,12 @@ describe("browser backend boundary", () => {
|
||||
|
||||
it("does not report Web active session changes to the desktop MCP host", async () => {
|
||||
const { useMcpActiveSession } = await import("@/hooks/useMcpActiveSession");
|
||||
const { rerender } = renderHook(({ sessionId }) => useMcpActiveSession(sessionId), {
|
||||
initialProps: { sessionId: "web-ssh" as string | null },
|
||||
});
|
||||
const { rerender } = renderHook(
|
||||
({ sessionId }) => useMcpActiveSession(sessionId),
|
||||
{
|
||||
initialProps: { sessionId: "web-ssh" as string | null },
|
||||
},
|
||||
);
|
||||
rerender({ sessionId: "web-telnet" });
|
||||
rerender({ sessionId: null });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
@@ -193,7 +306,9 @@ describe("browser backend boundary", () => {
|
||||
JSON.stringify(
|
||||
url.pathname.endsWith("get_quick_commands")
|
||||
? {
|
||||
commands: [{ id: "q1", label: "List files", command: "ls -la" }],
|
||||
commands: [
|
||||
{ id: "q1", label: "List files", command: "ls -la" },
|
||||
],
|
||||
categories: [],
|
||||
}
|
||||
: url.pathname.endsWith("fuzzy_search_commands")
|
||||
@@ -219,7 +334,15 @@ describe("browser backend boundary", () => {
|
||||
const apply = vi.fn();
|
||||
const canShow = () => true;
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useCommandHistory(terminalRef, inputStateRef, apply, canShow, true, 1, 100),
|
||||
useCommandHistory(
|
||||
terminalRef,
|
||||
inputStateRef,
|
||||
apply,
|
||||
canShow,
|
||||
true,
|
||||
1,
|
||||
100,
|
||||
),
|
||||
);
|
||||
act(() => {
|
||||
result.current.triggerSearch({ manual: true });
|
||||
@@ -232,10 +355,14 @@ describe("browser backend boundary", () => {
|
||||
});
|
||||
// Automatic searches normally debounce for 80ms.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await waitFor(() => expect(result.current.suggestions[0]?.command).toBe("ls -la"));
|
||||
expect(fetchMock.mock.calls.some(([url]) => url.pathname.endsWith("get_quick_commands"))).toBe(
|
||||
true,
|
||||
await waitFor(() =>
|
||||
expect(result.current.suggestions[0]?.command).toBe("ls -la"),
|
||||
);
|
||||
expect(
|
||||
fetchMock.mock.calls.some(([url]) =>
|
||||
url.pathname.endsWith("get_quick_commands"),
|
||||
),
|
||||
).toBe(true);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -291,13 +418,17 @@ describe("browser backend boundary", () => {
|
||||
it("denies native local execution and bounds pending output", async () => {
|
||||
const { httpInvoke } = await import("./http");
|
||||
const { deliver } = await import("./events");
|
||||
await expect(httpInvoke("create_local_session")).rejects.toThrow("Capability unavailable");
|
||||
await expect(httpInvoke("create_local_session")).rejects.toThrow(
|
||||
"Capability unavailable",
|
||||
);
|
||||
await expect(httpInvoke("get_default_local_shell")).rejects.toThrow(
|
||||
"Capability unavailable in Web mode: localShell",
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
deliver("terminal-output-absent", { data: "", bytes: 2 * 1024 * 1024 });
|
||||
expect(() => deliver("terminal-output-absent", { data: "x", bytes: 1 })).toThrow("listener");
|
||||
expect(() =>
|
||||
deliver("terminal-output-absent", { data: "x", bytes: 1 }),
|
||||
).toThrow("listener");
|
||||
});
|
||||
it("opens existing settings pages in a browser dialog and closes them", async () => {
|
||||
const { WebviewWindow } = await import("./platform/webviewWindow");
|
||||
@@ -365,7 +496,9 @@ describe("browser backend boundary", () => {
|
||||
title: "New session",
|
||||
url: "index.html?window=new-session&owner=main",
|
||||
});
|
||||
const failure = expect(opening).rejects.toThrow("Child window did not finish rendering");
|
||||
const failure = expect(opening).rejects.toThrow(
|
||||
"Child window did not finish rendering",
|
||||
);
|
||||
await waitFor(() => expect(document.querySelector("iframe")).toBeTruthy());
|
||||
const url = new URL(document.querySelector("iframe")!.src);
|
||||
await browserEmit(CHILD_WINDOW_LIFECYCLE_EVENT, {
|
||||
@@ -425,7 +558,9 @@ describe("browser backend boundary", () => {
|
||||
).toBeNull(),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "web.signIn" }));
|
||||
expect(await screen.findByText("Existing NyaTerm application")).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByText("Existing NyaTerm application"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -447,7 +582,9 @@ describe("Web Telnet and VNC", () => {
|
||||
recovered.onmessage?.({ data: bytes });
|
||||
expect(frame).toHaveBeenCalledWith(bytes);
|
||||
await sendBrowserVnc("retry", { type: "input", events: [] });
|
||||
expect(recovered.sent).toEqual([JSON.stringify({ type: "input", events: [] })]);
|
||||
expect(recovered.sent).toEqual([
|
||||
JSON.stringify({ type: "input", events: [] }),
|
||||
]);
|
||||
stop();
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
expect(FakeSocket.instances).toHaveLength(3);
|
||||
@@ -469,26 +606,39 @@ describe("Web Telnet and VNC", () => {
|
||||
host: "telnet.test",
|
||||
port: 23,
|
||||
});
|
||||
expect(JSON.parse(fetchMock.mock.calls[fetchMock.mock.calls.length - 1]?.[1].body)).toEqual({
|
||||
expect(
|
||||
JSON.parse(
|
||||
fetchMock.mock.calls[fetchMock.mock.calls.length - 1]?.[1].body,
|
||||
),
|
||||
).toEqual({
|
||||
host: "telnet.test",
|
||||
port: 23,
|
||||
type: "telnet",
|
||||
});
|
||||
await httpInvoke("create_vnc_session", { connectionId: "vnc-saved" });
|
||||
expect(JSON.parse(fetchMock.mock.calls[fetchMock.mock.calls.length - 1]?.[1].body)).toEqual({
|
||||
expect(
|
||||
JSON.parse(
|
||||
fetchMock.mock.calls[fetchMock.mock.calls.length - 1]?.[1].body,
|
||||
),
|
||||
).toEqual({
|
||||
connectionId: "vnc-saved",
|
||||
type: "vnc",
|
||||
});
|
||||
await httpInvoke("get_proxies");
|
||||
expect(fetchMock.mock.calls[fetchMock.mock.calls.length - 1]?.[0].pathname).toBe(
|
||||
"/nyaterm/api/commands/get_proxies",
|
||||
);
|
||||
expect(
|
||||
fetchMock.mock.calls[fetchMock.mock.calls.length - 1]?.[0].pathname,
|
||||
).toBe("/nyaterm/api/commands/get_proxies");
|
||||
});
|
||||
it("replays VNC messages and keeps a replacement safe from stale cleanup", async () => {
|
||||
const { subscribeBrowserVnc, sendBrowserVnc, closeAllBrowserVnc } = await import("./vnc");
|
||||
const { browserListen } = await import("./events");
|
||||
const oldController = new AbortController();
|
||||
const old = subscribeBrowserVnc("vnc", vi.fn(), vi.fn(), oldController.signal).catch(() => {});
|
||||
const old = subscribeBrowserVnc(
|
||||
"vnc",
|
||||
vi.fn(),
|
||||
vi.fn(),
|
||||
oldController.signal,
|
||||
).catch(() => {});
|
||||
const frame = vi.fn();
|
||||
const current = subscribeBrowserVnc("vnc", frame, vi.fn());
|
||||
await old;
|
||||
@@ -506,10 +656,14 @@ describe("Web Telnet and VNC", () => {
|
||||
}),
|
||||
});
|
||||
expect(frame).toHaveBeenCalledWith(bytes);
|
||||
expect(state).toHaveBeenCalledWith(expect.objectContaining({ payload: { state: "active" } }));
|
||||
expect(state).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ payload: { state: "active" } }),
|
||||
);
|
||||
oldController.abort();
|
||||
await sendBrowserVnc("vnc", { type: "input", events: [] });
|
||||
expect(socket.sent).toContain(JSON.stringify({ type: "input", events: [] }));
|
||||
expect(socket.sent).toContain(
|
||||
JSON.stringify({ type: "input", events: [] }),
|
||||
);
|
||||
expect(socket.url.search).toBe("");
|
||||
stop();
|
||||
expect(socket.readyState).toBe(3);
|
||||
@@ -519,7 +673,12 @@ describe("Web Telnet and VNC", () => {
|
||||
it("cancels a VNC attachment before its socket opens", async () => {
|
||||
const { subscribeBrowserVnc } = await import("./vnc");
|
||||
const controller = new AbortController();
|
||||
const result = subscribeBrowserVnc("cancelled", vi.fn(), vi.fn(), controller.signal);
|
||||
const result = subscribeBrowserVnc(
|
||||
"cancelled",
|
||||
vi.fn(),
|
||||
vi.fn(),
|
||||
controller.signal,
|
||||
);
|
||||
controller.abort();
|
||||
await expect(result).rejects.toThrow();
|
||||
expect(FakeSocket.instances[0].readyState).toBe(3);
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import RemoteFileEditorPage from "./RemoteFileEditorPage";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
translate: (key: string) => key,
|
||||
web: true,
|
||||
invoke: vi.fn(),
|
||||
download: vi.fn(),
|
||||
close: vi.fn(async () => {}),
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
log: vi.fn(),
|
||||
openPath: vi.fn(),
|
||||
tempDir: vi.fn(async () => "/temp"),
|
||||
}));
|
||||
vi.mock("@/lib/invoke", () => ({ invoke: state.invoke }));
|
||||
vi.mock("@/lib/backend/files", () => ({ downloadBrowserFile: state.download }));
|
||||
vi.mock("@/lib/backend/runtime", async (original) => ({
|
||||
...(await original<typeof import("@/lib/backend/runtime")>()),
|
||||
supports: () => !state.web,
|
||||
}));
|
||||
vi.mock("@/lib/backend/platform/path", () => ({
|
||||
tempDir: state.tempDir,
|
||||
join: async (...parts: string[]) => parts.join("/"),
|
||||
}));
|
||||
vi.mock("@/lib/backend/platform/opener", () => ({ openPath: state.openPath }));
|
||||
vi.mock("@/lib/backend/platform/window", () => ({
|
||||
getCurrentWindow: () => ({
|
||||
label: "editor",
|
||||
close: state.close,
|
||||
setTitle: async () => {},
|
||||
onCloseRequested: async () => () => {},
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/context/AppContext", () => ({
|
||||
useApp: () => ({
|
||||
appSettings: {
|
||||
transfer: { default_editor: "desktop", internal_editor_font_size: 14 },
|
||||
},
|
||||
updateAppSettings: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/useChildWindowCommand", () => ({
|
||||
useChildWindowCommand: () => {},
|
||||
}));
|
||||
vi.mock("@/hooks/useFileEditorZoom", () => ({ useFileEditorZoom: () => {} }));
|
||||
vi.mock("@/components/layout/ChildWindowHeader", () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
vi.mock("@/lib/logger", () => ({ logger: { error: state.log } }));
|
||||
vi.mock("sonner", () => ({ toast: { info: state.info, error: state.error } }));
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: state.translate }),
|
||||
}));
|
||||
vi.mock("@/lib/codeMirrorFileView", () => ({
|
||||
codeMirrorFileViewExtensions: () => [],
|
||||
getCursorPosition: () => ({ line: 1, column: 1 }),
|
||||
getDisplayLanguage: (language: string) => language,
|
||||
}));
|
||||
vi.mock("@codemirror/view", () => ({
|
||||
EditorView: class {
|
||||
static updateListener = { of: (listener: unknown) => listener };
|
||||
state: unknown;
|
||||
constructor({ state }: { state: unknown }) {
|
||||
this.state = state;
|
||||
}
|
||||
setState(state: unknown) {
|
||||
this.state = state;
|
||||
}
|
||||
focus() {}
|
||||
destroy() {}
|
||||
},
|
||||
}));
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.web = true;
|
||||
state.download.mockResolvedValue(undefined);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
`/?window=file-editor&data=${encodeURIComponent(JSON.stringify({ sessionId: "owned", remotePath: "/file.bin", name: "file.bin", size: 4, mtime: 0 }))}`,
|
||||
);
|
||||
});
|
||||
describe("remote editor capability fallback", () => {
|
||||
it.each(["binary", "encoding"])(
|
||||
"downloads unsupported %s files in Web child windows",
|
||||
async (reason) => {
|
||||
state.invoke.mockResolvedValue({ status: "unsupported", reason });
|
||||
render(<RemoteFileEditorPage />);
|
||||
await waitFor(() =>
|
||||
expect(state.download).toHaveBeenCalledWith("owned", "/file.bin"),
|
||||
);
|
||||
expect(state.info).toHaveBeenCalledWith(
|
||||
"fileExplorer.webUnsupportedDownload",
|
||||
);
|
||||
expect(screen.queryByText("fileEditor.openExternal")).toBeNull();
|
||||
expect(state.tempDir).not.toHaveBeenCalled();
|
||||
expect(state.openPath).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(state.close).toHaveBeenCalled());
|
||||
},
|
||||
);
|
||||
it("shows download failure while retaining the editor window and logs", async () => {
|
||||
state.invoke.mockResolvedValue({ status: "unsupported", reason: "binary" });
|
||||
state.download.mockRejectedValue(new Error("Permission denied"));
|
||||
render(<RemoteFileEditorPage />);
|
||||
await screen.findByText("Permission denied");
|
||||
expect(state.error).toHaveBeenCalledWith("Permission denied");
|
||||
expect(state.log).toHaveBeenCalled();
|
||||
expect(state.close).not.toHaveBeenCalled();
|
||||
});
|
||||
it("shows and logs file open failures", async () => {
|
||||
state.invoke.mockRejectedValue(new Error("SSH closed"));
|
||||
render(<RemoteFileEditorPage />);
|
||||
await screen.findByText("SSH closed");
|
||||
expect(state.error).toHaveBeenCalledWith("SSH closed");
|
||||
expect(state.log).toHaveBeenCalled();
|
||||
});
|
||||
it("retains desktop external editing and watcher fallback", async () => {
|
||||
state.web = false;
|
||||
state.invoke.mockImplementation(async (command: string) =>
|
||||
command === "open_remote_file_text"
|
||||
? { status: "unsupported", reason: "binary" }
|
||||
: command === "sanitize_download_file_name"
|
||||
? "file.bin"
|
||||
: undefined,
|
||||
);
|
||||
render(<RemoteFileEditorPage />);
|
||||
await waitFor(() => expect(state.openPath).toHaveBeenCalled());
|
||||
expect(state.invoke).toHaveBeenCalledWith(
|
||||
"start_file_watch",
|
||||
expect.objectContaining({ sessionId: "owned", remotePath: "/file.bin" }),
|
||||
);
|
||||
expect(state.download).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,6 @@
|
||||
import { supports } from "@/lib/backend/runtime";
|
||||
import { downloadBrowserFile } from "@/lib/backend/files";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { closeSearchPanel } from "@codemirror/search";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
@@ -33,7 +36,11 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useApp } from "@/context/AppContext";
|
||||
import { useChildWindowCommand } from "@/hooks/useChildWindowCommand";
|
||||
import { useFileEditorZoom } from "@/hooks/useFileEditorZoom";
|
||||
@@ -100,19 +107,30 @@ interface WriteRemoteFileTextResult {
|
||||
contentHash?: string;
|
||||
}
|
||||
|
||||
function getEditorDataPath(data: Pick<RemoteFileEditorData, "path" | "remotePath">) {
|
||||
function getEditorDataPath(
|
||||
data: Pick<RemoteFileEditorData, "path" | "remotePath">,
|
||||
) {
|
||||
return data.path ?? data.remotePath ?? "";
|
||||
}
|
||||
|
||||
function tabId(data: Pick<RemoteFileEditorData, "backend" | "sessionId" | "path" | "remotePath">) {
|
||||
function tabId(
|
||||
data: Pick<
|
||||
RemoteFileEditorData,
|
||||
"backend" | "sessionId" | "path" | "remotePath"
|
||||
>,
|
||||
) {
|
||||
const backend = data.backend ?? "remote";
|
||||
return `${backend}\n${data.sessionId}\n${getEditorDataPath(data)}`;
|
||||
}
|
||||
|
||||
function getParentDirectoryName(path: string) {
|
||||
const normalized = path.replace(/[\\/]+$/, "");
|
||||
if (!normalized || normalized === "/" || /^[a-zA-Z]:$/.test(normalized)) return normalized || "/";
|
||||
const index = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\"));
|
||||
if (!normalized || normalized === "/" || /^[a-zA-Z]:$/.test(normalized))
|
||||
return normalized || "/";
|
||||
const index = Math.max(
|
||||
normalized.lastIndexOf("/"),
|
||||
normalized.lastIndexOf("\\"),
|
||||
);
|
||||
const parent = index > 0 ? normalized.slice(0, index) : "";
|
||||
if (!parent || parent === "/" || /^[a-zA-Z]:$/.test(parent)) return parent || "/";
|
||||
return getLocalPathName(parent, parent);
|
||||
@@ -178,15 +196,24 @@ export default function RemoteFileEditorPage() {
|
||||
const forceCloseRef = useRef(false);
|
||||
const suppressEditorUpdateRef = useRef(false);
|
||||
const editorStatesRef = useRef<Record<string, EditorState>>({});
|
||||
const tabsRef = useRef<EditorTab[]>(initialData ? [createTab(initialData)] : []);
|
||||
const tabsRef = useRef<EditorTab[]>(
|
||||
initialData ? [createTab(initialData)] : [],
|
||||
);
|
||||
const activeTabIdRef = useRef(initialData ? tabId(initialData) : "");
|
||||
const [tabs, setTabs] = useState<EditorTab[]>(tabsRef.current);
|
||||
const [activeTabId, setActiveTabId] = useState(activeTabIdRef.current);
|
||||
const [conflictTabId, setConflictTabId] = useState<string | null>(null);
|
||||
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false);
|
||||
const [pendingCloseTabId, setPendingCloseTabId] = useState<string | null>(null);
|
||||
const [reloadConfirmTabId, setReloadConfirmTabId] = useState<string | null>(null);
|
||||
const [cursorPosition, setCursorPosition] = useState<CursorPosition>({ line: 1, column: 1 });
|
||||
const [pendingCloseTabId, setPendingCloseTabId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [reloadConfirmTabId, setReloadConfirmTabId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [cursorPosition, setCursorPosition] = useState<CursorPosition>({
|
||||
line: 1,
|
||||
column: 1,
|
||||
});
|
||||
|
||||
const activeTab = useMemo(
|
||||
() => tabs.find((tab) => tab.id === activeTabId) ?? tabs[0] ?? null,
|
||||
@@ -203,15 +230,20 @@ export default function RemoteFileEditorPage() {
|
||||
return counts;
|
||||
}, [tabs]);
|
||||
|
||||
const updateTabs = useCallback((updater: (tabs: EditorTab[]) => EditorTab[]) => {
|
||||
const next = updater(tabsRef.current);
|
||||
tabsRef.current = next;
|
||||
setTabs(next);
|
||||
}, []);
|
||||
const updateTabs = useCallback(
|
||||
(updater: (tabs: EditorTab[]) => EditorTab[]) => {
|
||||
const next = updater(tabsRef.current);
|
||||
tabsRef.current = next;
|
||||
setTabs(next);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const updateTab = useCallback(
|
||||
(id: string, updater: (tab: EditorTab) => EditorTab) => {
|
||||
updateTabs((current) => current.map((tab) => (tab.id === id ? updater(tab) : tab)));
|
||||
updateTabs((current) =>
|
||||
current.map((tab) => (tab.id === id ? updater(tab) : tab)),
|
||||
);
|
||||
},
|
||||
[updateTabs],
|
||||
);
|
||||
@@ -297,13 +329,22 @@ export default function RemoteFileEditorPage() {
|
||||
|
||||
const openExternalFile = useCallback(
|
||||
async (tab: EditorTab) => {
|
||||
if (!supports("nativeFiles")) {
|
||||
await downloadBrowserFile(tab.sessionId, tab.path);
|
||||
return;
|
||||
}
|
||||
if (tab.backend === "local") {
|
||||
await openPath(tab.path, appSettings.transfer.default_editor || undefined);
|
||||
await openPath(
|
||||
tab.path,
|
||||
appSettings.transfer.default_editor || undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const root = await tempDir();
|
||||
const safeName = await invoke<string>("sanitize_download_file_name", { name: tab.name });
|
||||
const safeName = await invoke<string>("sanitize_download_file_name", {
|
||||
name: tab.name,
|
||||
});
|
||||
const localPath = await join(
|
||||
root,
|
||||
"nyaterm",
|
||||
@@ -321,7 +362,10 @@ export default function RemoteFileEditorPage() {
|
||||
localPath,
|
||||
remotePath: tab.path,
|
||||
});
|
||||
await openPath(localPath, appSettings.transfer.default_editor || undefined);
|
||||
await openPath(
|
||||
localPath,
|
||||
appSettings.transfer.default_editor || undefined,
|
||||
);
|
||||
},
|
||||
[appSettings.transfer.default_editor],
|
||||
);
|
||||
@@ -344,15 +388,27 @@ export default function RemoteFileEditorPage() {
|
||||
if (result.status === "unsupported") {
|
||||
toast.info(
|
||||
t(
|
||||
result.reason === "binary"
|
||||
? "fileExplorer.binaryOpenExternal"
|
||||
: "fileExplorer.unsupportedEncodingOpenExternal",
|
||||
!supports("nativeFiles")
|
||||
? "fileExplorer.webUnsupportedDownload"
|
||||
: result.reason === "binary"
|
||||
? "fileExplorer.binaryOpenExternal"
|
||||
: "fileExplorer.unsupportedEncodingOpenExternal",
|
||||
),
|
||||
);
|
||||
try {
|
||||
await openExternalFile(tab);
|
||||
removeUnsupportedTab(id);
|
||||
} catch (externalError) {
|
||||
logger.error({
|
||||
domain: "transfer.lifecycle",
|
||||
event: "download.failed",
|
||||
message: "Browser download failed",
|
||||
error: externalError,
|
||||
});
|
||||
if (!supports("nativeFiles")) toast.error(
|
||||
getErrorMessage(externalError) ||
|
||||
t("fileEditor.openExternalFailed"),
|
||||
);
|
||||
updateTab(id, (current) => ({
|
||||
...current,
|
||||
loading: false,
|
||||
@@ -383,6 +439,13 @@ export default function RemoteFileEditorPage() {
|
||||
setEditorState(nextState);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({
|
||||
domain: "ui.error",
|
||||
event: "editor.open_failed",
|
||||
message: "File open failed",
|
||||
error: err,
|
||||
});
|
||||
if (!supports("nativeFiles")) toast.error(getErrorMessage(err) || t("fileEditor.loadFailed"));
|
||||
updateTab(id, (current) => ({
|
||||
...current,
|
||||
loading: false,
|
||||
@@ -390,7 +453,14 @@ export default function RemoteFileEditorPage() {
|
||||
}));
|
||||
}
|
||||
},
|
||||
[createEditorState, openExternalFile, removeUnsupportedTab, setEditorState, t, updateTab],
|
||||
[
|
||||
createEditorState,
|
||||
openExternalFile,
|
||||
removeUnsupportedTab,
|
||||
setEditorState,
|
||||
t,
|
||||
updateTab,
|
||||
],
|
||||
);
|
||||
|
||||
const addOrFocusTab = useCallback(
|
||||
@@ -437,7 +507,10 @@ export default function RemoteFileEditorPage() {
|
||||
: null;
|
||||
const initialState =
|
||||
(initialTab && editorStatesRef.current[initialTab.id]) ??
|
||||
createEditorState(initialTab?.content ?? "", initialTab?.language ?? "plaintext");
|
||||
createEditorState(
|
||||
initialTab?.content ?? "",
|
||||
initialTab?.language ?? "plaintext",
|
||||
);
|
||||
if (initialTab) {
|
||||
editorStatesRef.current[initialTab.id] = initialState;
|
||||
}
|
||||
@@ -762,7 +835,10 @@ export default function RemoteFileEditorPage() {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={tab.id}
|
||||
className={cn("items-start gap-2 py-2", isActive && "bg-accent/60")}
|
||||
className={cn(
|
||||
"items-start gap-2 py-2",
|
||||
isActive && "bg-accent/60",
|
||||
)}
|
||||
onClick={() => activateTab(tab.id)}
|
||||
>
|
||||
<span
|
||||
@@ -816,23 +892,27 @@ export default function RemoteFileEditorPage() {
|
||||
<MdRefresh className="text-sm" />
|
||||
{t("fileEditor.reload")}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
disabled={!activeTab || activeTab.loading}
|
||||
onClick={() => activeTab && openExternal(activeTab)}
|
||||
>
|
||||
<MdOpenInNew className="text-sm" />
|
||||
{t("fileEditor.openExternal")}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t("fileEditor.openExternalTooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
{supports("nativeFiles") && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
disabled={!activeTab || activeTab.loading}
|
||||
onClick={() => activeTab && openExternal(activeTab)}
|
||||
>
|
||||
<MdOpenInNew className="text-sm" />
|
||||
{t("fileEditor.openExternal")}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{t("fileEditor.openExternalTooltip")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
@@ -898,7 +978,9 @@ export default function RemoteFileEditorPage() {
|
||||
·
|
||||
</span>
|
||||
<span className="truncate">
|
||||
{t("fileEditor.unsavedFilesDesc", { count: dirtyTabs.length })}
|
||||
{t("fileEditor.unsavedFilesDesc", {
|
||||
count: dirtyTabs.length,
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
+10
-3
@@ -26,7 +26,9 @@ export default defineConfig(async () => ({
|
||||
css: {
|
||||
transformer: "lightningcss",
|
||||
lightningcss: {
|
||||
targets: browserslistToTargets(browserslist("safari >= 14, chrome >= 105")),
|
||||
targets: browserslistToTargets(
|
||||
browserslist("safari >= 14, chrome >= 105"),
|
||||
),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -39,7 +41,12 @@ export default defineConfig(async () => ({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: "./src/test/setup.ts",
|
||||
exclude: [...configDefaults.exclude, "**/src-tauri/vendor/**", "**/temp/**"],
|
||||
exclude: [
|
||||
...configDefaults.exclude,
|
||||
"**/src-tauri/vendor/**",
|
||||
"**/temp/**",
|
||||
"**/e2e/**",
|
||||
],
|
||||
},
|
||||
|
||||
clearScreen: false,
|
||||
@@ -79,4 +86,4 @@ export default defineConfig(async () => ({
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
}));
|
||||
Reference in new issue
Block a user