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:
Kang committed 2026-10-04 16:48:38 +08:00
1 parent e13f35c764
commit cf84f0764a
45 files changed
+4448 -1529

No files matched your search

+3
View File
@@ -19,3 +19,6 @@ src-tauri/resources
src-tauri/vendor/portable-pty
src-tauri/vendor/picky
src-tauri/vendor/sspi
artifacts
playwright-report
+93
View File
@@ -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
+4
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+12 -7
View File
@@ -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 中断丢弃时仍会移除待响应记录。数据格式保持兼容,新增响应字段见部署文档。
+3 -3
View File
@@ -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
View File
@@ -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 测试代替部署验收。
+53
View File
@@ -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 属于历史快照,其命令表和行号不再代表当前覆盖。当前功能范围见部署文档的能力矩阵。
+278
View File
@@ -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",
});
}
});
+20
View File
@@ -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",
},
});
+10
View File
@@ -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"]
+9
View File
@@ -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
View File
@@ -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",
+28
View File
@@ -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
+31
View File
@@ -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.`,
);
+142
View File
@@ -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));
+192
View File
@@ -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),
+93 -25
View File
@@ -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})))
}
+20 -1
View File
@@ -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);
});
}
}
+363 -3
View File
@@ -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")),
+1
View File
@@ -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}
+2 -1
View File
@@ -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();
});
});
+139 -108
View File
@@ -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>
);
}
+7 -2
View File
@@ -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",
+13 -2
View File
@@ -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 전송이 취소되었습니다",
+7 -2
View File
@@ -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 传输已取消",
+12 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
+178
View File
@@ -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();
});
});
+215
View File
@@ -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
View File
@@ -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);
+136
View File
@@ -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();
});
});
+124 -42
View File
@@ -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
View File
@@ -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 () => ({
},
},
},
}));
}));