实现真实 SFTP 上传与 WinSCP 式断点续传

This commit is contained in:
Xuz committed 2026-09-16 10:32:50 +08:00
1 parent 698b9ed925
commit 23da7a6724
34 files changed
+7137 -1651

No files matched your search

+7 -3
View File
@@ -4,7 +4,7 @@
shellr 是一个类似 Xshell / WinSCP 的 SSH 会话管理工具,基于 `gpui-kit` 0.6.1(GPUI + gpui-base + gpui-component)。界面文案用中文,标识符用英文。
会话和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellr/shellr.db`,`SHELLR_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。**仍然是 mock 的**:只剩 SFTP 文件浏览器(内存里的种子目录树)。
会话和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellr/shellr.db`,`SHELLR_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览与上传也已接入真实文件系统,支持断点续传;下载、目录同步和传输队列尚未实现。详见 `README.md`。
已确认的产品决定:WinSCP 式双栏文件浏览器是每个会话独立的「SFTP」Dock 标签页;暂不做传输队列和 Dock 布局持久化;首次启动是空库,不预置任何分组或会话;分组支持任意层级嵌套,会话也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的会话一起删,删除前确认并写明数量。
@@ -37,15 +37,19 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `SessionId` / `GroupId` 的 `session_action!` / `group_action!` 动作);`paths.rs` 解析数据目录(`SHELLR_DATA_DIR` → `dirs::data_dir()/shellr`)与数据库路径;`mod.rs` 绑定快捷键、初始化 gpui-kit 并 `set_locale("zh-CN")`;`assets.rs` 用 `icon_assets!` 把额外的 Lucide 图标并入默认图标包——额外图标用 `CatalogIcon::*`,默认包用 `IconName::*`;`OS_ICONS` 另外嵌入 `assets/icons/os/*.svg`(各操作系统的真实 logo,取自 Simple Icons,CC0;Windows 那个是自己画的四格,Simple Icons 已下架),用 `Icon::default().path(os.icon_path())` 渲染。
- `session/` — `model.rs`(Session / SessionGroup / SessionDraft / GroupDraft / `HostOs`;`Session.group` 与 `SessionGroup.parent` 都是 `Option<GroupId>`,`Session.os` 是 `Option<HostOs>`)、`database.rs`(`SessionDatabase`:rusqlite 连接、`PRAGMA user_version` 迁移、行与模型的映射)、`store.rs`(`SessionStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:<id>` / `s:<id>`;`group_options` 给表单提供按全路径标注的分组列表)、`session_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`session_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)。
- `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`。远程会话的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。`transport.rs` 还定义了整套 prompt 协议(`TerminalPrompt` / `TerminalPromptKind` / `TerminalSecret`),传输层靠它向界面要主机信任和认证答案。
- `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`。远程会话的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。共享 prompt 协议位于 `connection.rs`,`transport.rs` 通过 `TerminalPrompt` / `TerminalPromptKind` / `TerminalSecret` 重导出保持兼容。
- `ssh/` — `probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`:真实的 russh 客户端。known_hosts 校验写 `<data_dir>/known_hosts`(**不碰** `~/.ssh/known_hosts`),认证链是 agent → publickey → password → keyboard-interactive。密码和私钥口令先查 `secrets`,查不到或被服务器拒绝才走 prompt 弹框;被拒绝时**不删**钥匙串条目,只在弹框说明里讲清原因。
- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制会话都不丢,同一台机器的多个会话共用一条),私钥口令按文件路径(同一把钥匙只问一次)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。
- `explorer/` — `model.rs`(纯内存 `DirTree` / `Location` 导航)、`mock_fs.rs`(种子目录树)、`file_listing.rs`(`TableDelegate`)、`file_pane.rs`(单栏:地址行 + `DataTable` + 底部汇总)、`explorer_panel.rs`(`h_resizable` 里的两个栏)。
- `explorer/` — 目录与选择快照、`DataTable` 双栏、上传确认 / 冲突对话框、进度和取消 / 恢复展示。`ExplorerAction` 统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。
- `sftp/` — 可注入传输与本地目录接口、`RemotePath`、russh-sftp 3.0.0 协议适配、有界并发分块上传、UUID 临时文件、安全替换、原子 JSON 续传记录和三次自动重连。`ssh/connection.rs` 的 `SshConnector` 与终端共享认证、钥匙串服务及 known_hosts 写锁。
- `workspace/` — `workspace_view.rs` 持有 `SessionStore`、`DockArea`、按会话登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`、`status_bar.rs`、`recent_sessions.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。
- `shared/` — 多个功能共用的展示片段(`ClosableTabTitle`、`HostMark`)。
关键流程与不变量:
- **SFTP 独立于终端。** 会话状态综合所有终端与 SFTP 状态计算;关闭终端不停止上传,断开 / 删除会话会停止上传并保留续传数据。每会话一个运行批次,来源、端点和目标在确认时固定。认证问答按终端 / SFTP 来源与 SFTP 连接代次隔离。
- **SFTP UI 测试完全注入。** 使用 `Workspace::new_with_services` 注入目录与传输,不访问用户服务器和钥匙串。工作线程事件由 UI 定时轮询,禁止后台线程直接唤醒 GPUI 前台任务;实体回调中的 SFTP 动作通过 `ExplorerDispatch` 延后派发,避免重入实体。
- **一个命令一个处理器。** 按钮、菜单、右键菜单、快捷键都只派发 `app/actions.rs` 里的动作,由 `Workspace` 处理。新增命令加在那里,不要写临时闭包直接改状态。
- **持久化写穿,内存说了算。** `SessionStore` 带 `Context` 的 mutator 负责「改内存 → 写库 → `notify`」,`*_unnotified` 那一半是纯内存的(给单元测试和加载用)。写库同步发生在 UI 线程:要落盘的都是用户在对话框里确认的单行写入,不是流。写失败时内存里的改动照样生效,store 发 `SessionStoreEvent::PersistFailed`,工作区订阅后弹一个错误通知——**绝不静默吞掉**。`ConnectionState` 是运行时状态,不入库;`last_connected_at` 入库,开始页的「最近连接」因此能跨启动。删除分组靠数据库两个外键的 `ON DELETE CASCADE`,内存里的 `remove_group_unnotified` 必须给出一样的结果,并把被删会话的 id 返回给工作区去关标签页。
- **主机系统每次连上都重探一遍。** 认证过、shell 起来、`Started` 发出去之后,传输层另开一条 exec 通道跑 `uname -s; cat /etc/os-release`,在主 IO 循环里和 shell 的输出一起轮询,所以终端不会为了探测多等一个往返。POSIX 那条什么都没输出(Windows 的 shell 就是这样)时再问一次 `cmd /c ver`,还是不认就放弃,会话保留原来的标记。结果沿 `TerminalTransportEvent::HostOsDetected` → 引擎 → `TerminalView` → `TerminalPanel` → 工作区 → `SessionStore::set_host_os` 这条链走,和 prompt 是同一套管线。`os` 列由 `set_host_os` 单独写,`update_session` 不碰它(同 `last_connected_at`)。标记是 `shared::HostMark`,会话树和开始页共用:探到了就用该系统的品牌色打底、白色(浅色品牌用黑色)画 logo,没探到就退回 `muted` 底色加会话名第一个字。徽章沿用 `Avatar` 的圆形画法,尺寸走 `Sizable`——树里用 `small`(20px),开始页用默认的 `medium`(32px)。`AlmaLinux` 和 `macOS` 没有单一品牌色,改用 `foreground` / `background`,这也是 Apple 官方的画法。品牌色写在 `host_os!` 表里而不是渲染处,那是设计规范允许的「颜色本身即数据」那一档。会话树里分组行和其他行用 `plain_mark` 占同样宽度的槽位,否则标签对不齐。
+7 -3
View File
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
shellr 是一个类似 Xshell / WinSCP 的 SSH 会话管理工具,基于 `gpui-kit` 0.6.1(GPUI + gpui-base + gpui-component)。界面文案用中文,标识符用英文。
会话和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellr/shellr.db`,`SHELLR_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。**仍然是 mock 的**:只剩 SFTP 文件浏览器(内存里的种子目录树)。
会话和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellr/shellr.db`,`SHELLR_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览与上传也已接入真实文件系统,支持断点续传;下载、目录同步和传输队列尚未实现。详见 `README.md`。
已确认的产品决定:WinSCP 式双栏文件浏览器是每个会话独立的「SFTP」Dock 标签页;暂不做传输队列和 Dock 布局持久化;首次启动是空库,不预置任何分组或会话;分组支持任意层级嵌套,会话也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的会话一起删,删除前确认并写明数量。
@@ -39,15 +39,19 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `SessionId` / `GroupId` 的 `session_action!` / `group_action!` 动作);`paths.rs` 解析数据目录(`SHELLR_DATA_DIR` → `dirs::data_dir()/shellr`)与数据库路径;`mod.rs` 绑定快捷键、初始化 gpui-kit 并 `set_locale("zh-CN")`;`assets.rs` 用 `icon_assets!` 把额外的 Lucide 图标并入默认图标包——额外图标用 `CatalogIcon::*`,默认包用 `IconName::*`;`OS_ICONS` 另外嵌入 `assets/icons/os/*.svg`(各操作系统的真实 logo,取自 Simple Icons,CC0;Windows 那个是自己画的四格,Simple Icons 已下架),用 `Icon::default().path(os.icon_path())` 渲染。
- `session/` — `model.rs`(Session / SessionGroup / SessionDraft / GroupDraft / `HostOs`;`Session.group` 与 `SessionGroup.parent` 都是 `Option<GroupId>`,`Session.os` 是 `Option<HostOs>`)、`database.rs`(`SessionDatabase`:rusqlite 连接、`PRAGMA user_version` 迁移、行与模型的映射)、`store.rs`(`SessionStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:<id>` / `s:<id>`;`group_options` 给表单提供按全路径标注的分组列表)、`session_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`session_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)。
- `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`。远程会话的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。`transport.rs` 还定义了整套 prompt 协议(`TerminalPrompt` / `TerminalPromptKind` / `TerminalSecret`),传输层靠它向界面要主机信任和认证答案。
- `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`。远程会话的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。共享 prompt 协议位于 `connection.rs`,`transport.rs` 通过 `TerminalPrompt` / `TerminalPromptKind` / `TerminalSecret` 重导出保持兼容。
- `ssh/` — `probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`:真实的 russh 客户端。known_hosts 校验写 `<data_dir>/known_hosts`(**不碰** `~/.ssh/known_hosts`),认证链是 agent → publickey → password → keyboard-interactive。密码和私钥口令先查 `secrets`,查不到或被服务器拒绝才走 prompt 弹框;被拒绝时**不删**钥匙串条目,只在弹框说明里讲清原因。
- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制会话都不丢,同一台机器的多个会话共用一条),私钥口令按文件路径(同一把钥匙只问一次)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。
- `explorer/` — `model.rs`(纯内存 `DirTree` / `Location` 导航)、`mock_fs.rs`(种子目录树)、`file_listing.rs`(`TableDelegate`)、`file_pane.rs`(单栏:地址行 + `DataTable` + 底部汇总)、`explorer_panel.rs`(`h_resizable` 里的两个栏)。
- `explorer/` — 目录与选择快照、`DataTable` 双栏、上传确认 / 冲突对话框、进度和取消 / 恢复展示。`ExplorerAction` 统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。
- `sftp/` — 可注入传输与本地目录接口、`RemotePath`、russh-sftp 3.0.0 协议适配、有界并发分块上传、UUID 临时文件、安全替换、原子 JSON 续传记录和三次自动重连。`ssh/connection.rs` 的 `SshConnector` 与终端共享认证、钥匙串服务及 known_hosts 写锁。
- `workspace/` — `workspace_view.rs` 持有 `SessionStore`、`DockArea`、按会话登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`、`status_bar.rs`、`recent_sessions.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。
- `shared/` — 多个功能共用的展示片段(`ClosableTabTitle`、`HostMark`)。
关键流程与不变量:
- **SFTP 独立于终端。** 会话状态综合所有终端与 SFTP 状态计算;关闭终端不停止上传,断开 / 删除会话会停止上传并保留续传数据。每会话一个运行批次,来源、端点和目标在确认时固定。认证问答按终端 / SFTP 来源与 SFTP 连接代次隔离。
- **SFTP UI 测试完全注入。** 使用 `Workspace::new_with_services` 注入目录与传输,不访问用户服务器和钥匙串。工作线程事件由 UI 定时轮询,禁止后台线程直接唤醒 GPUI 前台任务;实体回调中的 SFTP 动作通过 `ExplorerDispatch` 延后派发,避免重入实体。
- **一个命令一个处理器。** 按钮、菜单、右键菜单、快捷键都只派发 `app/actions.rs` 里的动作,由 `Workspace` 处理。新增命令加在那里,不要写临时闭包直接改状态。
- **持久化写穿,内存说了算。** `SessionStore` 带 `Context` 的 mutator 负责「改内存 → 写库 → `notify`」,`*_unnotified` 那一半是纯内存的(给单元测试和加载用)。写库同步发生在 UI 线程:要落盘的都是用户在对话框里确认的单行写入,不是流。写失败时内存里的改动照样生效,store 发 `SessionStoreEvent::PersistFailed`,工作区订阅后弹一个错误通知——**绝不静默吞掉**。`ConnectionState` 是运行时状态,不入库;`last_connected_at` 入库,开始页的「最近连接」因此能跨启动。删除分组靠数据库两个外键的 `ON DELETE CASCADE`,内存里的 `remove_group_unnotified` 必须给出一样的结果,并把被删会话的 id 返回给工作区去关标签页。
- **主机系统每次连上都重探一遍。** 认证过、shell 起来、`Started` 发出去之后,传输层另开一条 exec 通道跑 `uname -s; cat /etc/os-release`,在主 IO 循环里和 shell 的输出一起轮询,所以终端不会为了探测多等一个往返。POSIX 那条什么都没输出(Windows 的 shell 就是这样)时再问一次 `cmd /c ver`,还是不认就放弃,会话保留原来的标记。结果沿 `TerminalTransportEvent::HostOsDetected` → 引擎 → `TerminalView` → `TerminalPanel` → 工作区 → `SessionStore::set_host_os` 这条链走,和 prompt 是同一套管线。`os` 列由 `set_host_os` 单独写,`update_session` 不碰它(同 `last_connected_at`)。标记是 `shared::HostMark`,会话树和开始页共用:探到了就用该系统的品牌色打底、白色(浅色品牌用黑色)画 logo,没探到就退回 `muted` 底色加会话名第一个字。徽章沿用 `Avatar` 的圆形画法,尺寸走 `Sizable`——树里用 `small`(20px),开始页用默认的 `medium`(32px)。`AlmaLinux` 和 `macOS` 没有单一品牌色,改用 `foreground` / `background`,这也是 Apple 官方的画法。品牌色写在 `host_os!` 表里而不是渲染处,那是设计规范允许的「颜色本身即数据」那一档。会话树里分组行和其他行用 `plain_mark` 占同样宽度的槽位,否则标签对不齐。
Generated
+54
View File
@@ -1644,6 +1644,20 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "dashmap"
version = "6.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
dependencies = [
"cfg-if",
"crossbeam-utils",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
@@ -2667,6 +2681,18 @@ dependencies = [
"walkdir",
]
[[package]]
name = "gloo-timers"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d"
dependencies = [
"futures-channel",
"futures-core",
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "glow"
version = "0.17.0"
@@ -6685,6 +6711,26 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "russh-sftp"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "093197e526668d92bba562e2bbbe98d1af9831bf080b619c736316ca1fa35101"
dependencies = [
"bitflags 2.13.2",
"bytes",
"chrono",
"dashmap",
"gloo-timers",
"log",
"serde",
"serde_bytes",
"thiserror 2.0.20",
"tokio",
"tokio-util",
"wasm-bindgen-futures",
]
[[package]]
name = "russh-util"
version = "0.52.0"
@@ -7363,16 +7409,22 @@ dependencies = [
"alacritty_terminal",
"anyhow",
"async-channel",
"chrono",
"dirs 6.0.0",
"futures",
"gpui-kit",
"keyring",
"portable-pty",
"rusqlite",
"russh",
"russh-sftp",
"serde",
"serde_json",
"sha2 0.10.9",
"tempfile",
"tokio",
"unicode-width",
"uuid",
"zeroize",
]
@@ -8147,6 +8199,7 @@ dependencies = [
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
@@ -8194,6 +8247,7 @@ dependencies = [
"bytes",
"futures-core",
"futures-sink",
"futures-util",
"libc",
"pin-project-lite",
"tokio",
+8 -1
View File
@@ -11,16 +11,23 @@ alacritty_terminal = "0.26"
anyhow = "1"
async-channel = "2"
dirs = "6"
futures = "0.3"
portable-pty = "0.9"
russh = "0.63"
russh-sftp = "=3.0.0"
serde_json = "1"
sha2 = "0.10"
uuid = { version = "1", features = ["v4"] }
chrono = "0.4"
rusqlite = { version = "0.40", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["io-util", "net", "rt", "sync", "time"] }
tokio = { version = "1", features = ["fs", "io-util", "net", "rt", "sync", "time"] }
unicode-width = "0.2"
zeroize = "1"
keyring = "4"
[dev-dependencies]
tokio = { version = "1", features = ["process", "test-util"] }
gpui-kit = { version = "0.6.1", features = ["test-support"] }
tempfile = "3"
+47
View File
@@ -0,0 +1,47 @@
# shellr
基于 GPUI Kit 的中文 SSH 会话管理工具,提供持久化会话与分组、本地终端、SSH 多终端和真实 SFTP 双栏浏览器。
## SFTP 上传
打开会话的「SFTP」标签即可浏览本地与远程目录。SFTP 使用独立 SSH 连接,仅请求 SFTP 子系统,不要求服务器提供 Shell,关闭终端不会中止上传。
- 地址输入、上级目录、刷新、列排序和目录双击导航;本地从系统用户目录开始,远程初始目录由服务器查询。
- 本地复选框选择上传项目,空格切换活动行、⌘/Ctrl+A 全选、Shift 点击复选框范围选择。选中集合按路径保存,排序不改变所选项目。
- 使用「上传所选…」、本地列表中的 F5、「选择文件上传…」,或从本地面板 / Finder 拖入远程列表。拖到目录行时使用该目录,其余列表区域使用当前目录。
- 所有入口均显示确认框,可修改目标目录。确认后固定来源、会话端点和目标;每会话同时运行一个批次,批内依次处理,其他会话和终端可继续操作。
- 递归上传包含空目录;符号链接原样创建,不跟随。默认二进制传输,保留普通文件修改时间;新建项使用服务器默认权限,覆盖保留目标基本权限。不复制所有者、ACL 或扩展属性。
- 同名目录合并,文件 / 链接冲突询问覆盖、跳过或取消,可应用于本批剩余冲突。目录与非目录冲突不会自动删除。错误可重试、跳过或取消,完成汇总分别显示成功、跳过和失败。
## 断点续传和替换
文件先写入 `<目标文件名>.filepart`。写入请求采用有界流水线,收到每个写入确认并关闭成功后才发布正式文件;服务器支持时先调用 `fsync@openssh.com`。支持 `posix-rename@openssh.com` 的服务器使用原子替换;其他服务器先将旧文件移到 `.shellr-<UUID>.backup`,再发布新文件,此兼容路径不保证原子性。发布响应丢失时根据受管理临时路径、阶段、类型和大小恢复,无法安全判断则保留恢复文件并报告。
续传记录保存在应用数据目录的 `upload-resume/`,采用版本化 JSON 和原子写入,不写入密码、口令或私钥,不改变会话数据库结构。记录用于绑定端点、主机指纹、来源、目标和安全替换阶段。续传方式与 WinSCP 相同:读取远端 `.filepart` 的当前大小,在本地文件中跳过相同长度并继续传输;使用者需确保来源仍是同一版本。上传过程不会为了校验而回读远端文件内容。
网络故障每批最多自动重连三次,间隔 1、3、10 秒。耗尽后使用「继续上传」,手动恢复会重置次数。认证失败、主机密钥变化和主动取消需要用户处理。重连沿用原连接配置,会话编辑不会改变正在上传的目标。
取消、关闭标签、断开会话或退出时保留续传记录;关闭正在上传的标签会先确认。重启后不会自动上传,再次选择相同来源和目标时会询问续传。停止状态下可使用「丢弃续传进度」清理该批受管理的临时文件;存在无法判断的恢复备份时会保留文件并说明原因。成功发布的项目自动清理记录和备份。
文件名不能准确表示时会明确报错。当前 `russh-sftp 3.0.0` 的远端字符串解码会替换无效 UTF-8,浏览器因此拒绝包含替换字符 `U+FFFD` 的远端名称,避免访问错误路径。符号链接沿用 OpenSSH 的 SFTP v3 参数顺序。
**尚未实现:下载、目录同步、传输队列和 Dock 布局持久化。** 界面不会对这些操作显示虚假的传输成功提示。
## 运行和验证
工具链固定 Rust 1.98.1,`russh-sftp` 固定 3.0.0;保持仓库锁定的 GPUI 与 russh 版本。
```sh
cargo run
cargo test
cargo clippy --all-targets -- -D warnings
cargo fmt --check
```
数据目录默认是系统应用数据目录中的 `shellr`,可通过 `SHELLR_DATA_DIR` 覆盖。会话保存在 `shellr.db`,主机信任记录保存在该目录的 `known_hosts`,凭据使用系统钥匙串。
`src/sftp/tests.rs` 使用临时目录与故障注入覆盖文件内容、空目录、链接、覆盖、权限、取消、源文件变化、前缀验证及替换各阶段恢复。Unix 协议测试使用本机 OpenSSH `sftp-server`(macOS `/usr/libexec/sftp-server`,Linux `/usr/lib/openssh/sftp-server`);工作线程测试仅监听 `127.0.0.1` 的随机端口,使用测试凭据。限制本地套接字的沙箱会跳过该部分,需要在允许回环套接字的环境运行。
`tests/ui.rs` 驱动真实 `Workspace`,通过 `Workspace::new_with_services` 注入 SFTP、本地目录和终端服务,不连接用户服务器或真实钥匙串。Finder 的系统原生拖放仍需在真实 macOS 窗口中补充人工验收;无头测试覆盖原生文件拖入事件和面板内部拖放。
上传交互参考 [WinSCP 上传流程](https://winscp.net/eng/docs/task_upload),协议适配参考 [russh-sftp 请求接口](https://docs.rs/russh-sftp/3.0.0/russh_sftp/client/struct.RawSftpSession.html) 与 [OpenSSH 扩展规范](https://raw.githubusercontent.com/openssh/openssh-portable/master/PROTOCOL)。
+103
View File
@@ -142,3 +142,106 @@ local_terminal_action!(
/// Restart a local terminal with a fresh emulator and PTY.
RestartLocalTerminal
);
/// Commands shared by explorer controls, drops, dialogs and keyboard bindings.
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
pub enum ExplorerCommand {
Navigate {
remote: bool,
path: String,
},
Up {
remote: bool,
},
Refresh {
remote: bool,
},
Check {
name: String,
checked: bool,
extend: bool,
},
ToggleSelection,
SelectAll,
UploadSelected,
ChooseFiles,
UploadPaths {
paths: Vec<std::path::PathBuf>,
target: String,
},
BeginUpload {
paths: Vec<std::path::PathBuf>,
target: String,
},
Answer {
request_id: u64,
answer: crate::sftp::UploadAnswer,
},
CancelUpload,
ResumeUpload,
DiscardUpload,
ToggleDetails,
CloseConfirmed,
}
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = shellr, no_json)]
pub struct ExplorerAction {
session: SessionId,
command: ExplorerCommand,
generation: Option<u64>,
}
impl ExplorerAction {
pub fn new(session: SessionId, command: ExplorerCommand) -> Self {
Self {
session,
command,
generation: None,
}
}
pub fn with_generation(mut self, generation: u64) -> Self {
self.generation = Some(generation);
self
}
pub fn generation(&self) -> Option<u64> {
self.generation
}
pub fn session(&self) -> SessionId {
self.session
}
pub fn command(&self) -> &ExplorerCommand {
&self.command
}
}
gpui_kit::actions!(
shellr,
[
UploadSelectedFiles,
ToggleUploadSelection,
SelectAllUploadFiles
]
);
/// UI entities dispatch after their update has finished so Workspace can read
/// their latest snapshot without re-entering a leased entity.
pub(crate) trait ExplorerDispatch {
fn dispatch_explorer_action(
&self,
action: &ExplorerAction,
window: &mut gpui_kit::Window,
cx: &mut gpui_kit::App,
);
}
impl ExplorerDispatch for gpui_kit::FocusHandle {
fn dispatch_explorer_action(
&self,
action: &ExplorerAction,
window: &mut gpui_kit::Window,
cx: &mut gpui_kit::App,
) {
let focus = self.clone();
let action = action.clone();
window.defer(cx, move |window, cx| {
focus.dispatch_action(&action, window, cx)
});
}
}
+3
View File
@@ -43,6 +43,9 @@ fn key_bindings() -> Vec<KeyBinding> {
KeyBinding::new(&primary("0"), ZoomReset, None),
KeyBinding::new(&primary("q"), Quit, None),
KeyBinding::new("shift-escape", ToggleZoom, None),
KeyBinding::new("f5", UploadSelectedFiles, Some("LocalFileList")),
KeyBinding::new("space", ToggleUploadSelection, Some("LocalFileList")),
KeyBinding::new(&primary("a"), SelectAllUploadFiles, Some("LocalFileList")),
KeyBinding::new("enter", ConnectSelected, Some(SESSION_PANEL_CONTEXT)),
#[cfg(target_os = "macos")]
KeyBinding::new("cmd-c", CopyTerminal, Some(TERMINAL_KEY_CONTEXT)),
+214
View File
@@ -0,0 +1,214 @@
//! Shared connection questions. Secrets are never serialized or printed.
use zeroize::Zeroizing;
/// One field requested by an SSH keyboard-interactive challenge.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConnectionPromptField {
label: String,
echo: bool,
}
impl ConnectionPromptField {
pub fn new(label: impl Into<String>, echo: bool) -> Self {
Self {
label: label.into(),
echo,
}
}
pub fn label(&self) -> &str {
&self.label
}
pub fn echo(&self) -> bool {
self.echo
}
}
/// A transport question that must be answered by the UI before SSH setup can
/// continue. The id is scoped to a single transport generation.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConnectionPrompt {
request_id: u64,
kind: ConnectionPromptKind,
}
impl ConnectionPrompt {
pub fn new(request_id: u64, kind: ConnectionPromptKind) -> Self {
Self { request_id, kind }
}
pub fn request_id(&self) -> u64 {
self.request_id
}
pub fn kind(&self) -> &ConnectionPromptKind {
&self.kind
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ConnectionPromptKind {
UnknownHost(UnknownHostPrompt),
HostKeyChanged(HostKeyChangedPrompt),
Authentication(AuthenticationPrompt),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UnknownHostPrompt {
host: String,
port: u16,
algorithm: String,
fingerprint: String,
}
impl UnknownHostPrompt {
pub fn host(&self) -> &str {
&self.host
}
pub fn port(&self) -> u16 {
self.port
}
pub fn algorithm(&self) -> &str {
&self.algorithm
}
pub fn fingerprint(&self) -> &str {
&self.fingerprint
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HostKeyChangedPrompt {
host: String,
port: u16,
algorithm: String,
old_fingerprints: Vec<String>,
fingerprint: String,
known_hosts_path: std::path::PathBuf,
}
impl HostKeyChangedPrompt {
pub fn host(&self) -> &str {
&self.host
}
pub fn port(&self) -> u16 {
self.port
}
pub fn algorithm(&self) -> &str {
&self.algorithm
}
pub fn old_fingerprints(&self) -> &[String] {
&self.old_fingerprints
}
pub fn fingerprint(&self) -> &str {
&self.fingerprint
}
pub fn known_hosts_path(&self) -> &std::path::Path {
&self.known_hosts_path
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuthenticationPrompt {
title: String,
instructions: String,
fields: Vec<ConnectionPromptField>,
}
impl AuthenticationPrompt {
pub fn title(&self) -> &str {
&self.title
}
pub fn instructions(&self) -> &str {
&self.instructions
}
pub fn fields(&self) -> &[ConnectionPromptField] {
&self.fields
}
}
impl ConnectionPromptKind {
pub fn unknown_host(
host: impl Into<String>,
port: u16,
algorithm: impl Into<String>,
fingerprint: impl Into<String>,
) -> Self {
Self::UnknownHost(UnknownHostPrompt {
host: host.into(),
port,
algorithm: algorithm.into(),
fingerprint: fingerprint.into(),
})
}
pub fn host_key_changed(
host: impl Into<String>,
port: u16,
algorithm: impl Into<String>,
old_fingerprints: Vec<String>,
fingerprint: impl Into<String>,
known_hosts_path: impl Into<std::path::PathBuf>,
) -> Self {
Self::HostKeyChanged(HostKeyChangedPrompt {
host: host.into(),
port,
algorithm: algorithm.into(),
old_fingerprints,
fingerprint: fingerprint.into(),
known_hosts_path: known_hosts_path.into(),
})
}
pub fn authentication(
title: impl Into<String>,
instructions: impl Into<String>,
fields: Vec<ConnectionPromptField>,
) -> Self {
Self::Authentication(AuthenticationPrompt {
title: title.into(),
instructions: instructions.into(),
fields,
})
}
}
/// A secret that is erased when dropped and never prints its contents.
pub struct ConnectionSecret(Zeroizing<String>);
impl ConnectionSecret {
pub fn new(value: impl Into<String>) -> Self {
Self(Zeroizing::new(value.into()))
}
pub(crate) fn expose(&self) -> &str {
self.0.as_str()
}
pub(crate) fn into_inner(mut self) -> String {
std::mem::take(&mut *self.0)
}
}
impl std::fmt::Debug for ConnectionSecret {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("ConnectionSecret([已隐藏])")
}
}
/// UI response to a transport prompt. Deliberately not `Clone`: answers may
/// contain passwords, private-key passphrases or one-time codes.
pub enum ConnectionPromptReply {
TrustAndSave,
Answers(Vec<ConnectionSecret>),
Cancel,
}
impl std::fmt::Debug for ConnectionPromptReply {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::TrustAndSave => formatter.write_str("TrustAndSave"),
Self::Answers(answers) => formatter
.debug_tuple("Answers")
.field(&format_args!("{} 个已隐藏答案", answers.len()))
.finish(),
Self::Cancel => formatter.write_str("Cancel"),
}
}
}
+567 -65
View File
@@ -1,149 +1,508 @@
use gpui_kit::component::{
dock::{BasePanel, Panel, PanelEvent, TabGroup},
menu::PopupMenu,
resizable::{h_resizable, resizable_panel},
use super::{FilePane, PaneSide};
use crate::app::ExplorerDispatch as _;
use crate::{
app::{CatalogIcon, CloseExplorer, EditSession, ExplorerAction, ExplorerCommand},
connection::{ConnectionPrompt, ConnectionPromptReply},
session::{ConnectionState, SessionId, SessionStore},
sftp::{
RemotePath, SftpCommand, SftpEvent, SharedLocalDirectoryProvider,
SharedSftpTransportProvider, UploadPhase, UploadProgress, UploadQuestion, UploadRequest,
},
shared::ClosableTabTitle,
};
use gpui_kit::component::{
ActiveTheme as _, Sizable as _, WindowExt as _,
button::{Button, ButtonVariants as _},
dock::{BasePanel, Panel, PanelEvent, TabGroup},
h_flex,
menu::PopupMenu,
notification::Notification,
progress::Progress,
resizable::{h_resizable, resizable_panel},
v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use crate::app::{CatalogIcon, CloseExplorer, EditSession};
use crate::session::{SessionId, SessionStore};
use crate::shared::ClosableTabTitle;
use super::{DirTree, FilePane, Location, PaneSide, local_tree, remote_home, remote_tree};
/// Emitted to the workspace, which keeps its panel registry and its record of
/// the displayed center tab in step.
#[derive(Clone, Debug, PartialEq, Eq)]
use std::{
sync::atomic::{AtomicU64, Ordering},
time::Duration,
};
static NEXT_GENERATION: AtomicU64 = AtomicU64::new(1);
#[derive(Clone, Debug)]
pub enum ExplorerPanelEvent {
/// The panel became the displayed tab of its group.
Activated(SessionId),
Closed(SessionId),
StateChanged(SessionId),
PromptRequested(SessionId, u64, ConnectionPrompt),
}
/// A center tab with the WinSCP-style two-pane file browser for a session.
pub struct ExplorerPanel {
session_id: SessionId,
generation: u64,
endpoint: String,
store: Entity<SessionStore>,
local: Entity<FilePane>,
remote: Entity<FilePane>,
local_provider: SharedLocalDirectoryProvider,
commands: async_channel::Sender<SftpCommand>,
state: ConnectionState,
message: String,
progress: Option<UploadProgress>,
details: bool,
upload_pending: bool,
pub(super) question: Option<UploadQuestion>,
pub(super) dialog_open: bool,
pub(super) dispatch: FocusHandle,
focus_handle: FocusHandle,
tab_group: Option<WeakEntity<TabGroup>>,
_subscriptions: Vec<Subscription>,
_events: Task<()>,
}
impl ExplorerPanel {
pub fn new(
session_id: SessionId,
store: Entity<SessionStore>,
provider: SharedSftpTransportProvider,
local_provider: SharedLocalDirectoryProvider,
dispatch: FocusHandle,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let session = store.read(cx).session(session_id).cloned();
let local_home = "/Users/xuz";
let session = store
.read(cx)
.session(session_id)
.cloned()
.expect("workspace checked session");
let endpoint = format!("{}@{}:{}", session.user, session.host, session.port);
let home = local_provider.home().to_string_lossy().into_owned();
let local = cx.new(|cx| {
FilePane::new(
PaneSide::Local,
session_id,
Location::new(local_tree(), local_home, Some(local_home)),
home.clone(),
dispatch.clone(),
window,
cx,
)
});
let (remote_dirs, home) = session
.as_ref()
.map(|session| (remote_tree(session), remote_home(session)))
.unwrap_or_else(|| (DirTree::new(), "/".to_string()));
let remote = cx.new(|cx| {
FilePane::new(
PaneSide::Remote,
session_id,
Location::new(remote_dirs, &home, Some(&home)),
String::new(),
dispatch.clone(),
window,
cx,
)
});
let subscriptions = vec![cx.observe(&store, |_, _, cx| cx.notify())];
local.update(cx, |pane, cx| {
pane.load_local(home, local_provider.clone(), window, cx)
});
let (commands, receiver) = async_channel::unbounded();
let (sender, events) = async_channel::unbounded();
let transport = provider.create(&session);
let failed = sender.clone();
if let Err(error) = std::thread::Builder::new()
.name("shellr-sftp".into())
.spawn(move || {
if let Err(error) = transport.run(receiver, sender.clone()) {
let _ = sender.send_blocking(SftpEvent::Disconnected(error.to_string()));
}
})
{
let _ = failed.try_send(SftpEvent::Disconnected(format!("无法启动 SFTP:{error}")));
}
// Poll snapshots as the terminal engine does. Worker threads never wake
// a foreground-only GPUI task directly.
let events_task = cx.spawn_in(window, async move |this, cx| {
loop {
cx.background_executor()
.timer(Duration::from_millis(16))
.await;
let pending: Vec<_> = (0..128).map_while(|_| events.try_recv().ok()).collect();
if !pending.is_empty()
&& this
.update_in(cx, |this, window, cx| {
for event in pending {
this.on_event(event, window, cx);
}
})
.is_err()
{
break;
}
if events.is_closed() && events.is_empty() {
break;
}
}
});
let subscriptions = vec![
cx.observe(&store, |_, _, cx| cx.notify()),
cx.on_app_quit(|this, _| {
this.send(SftpCommand::Shutdown);
async {}
}),
];
Self {
session_id,
generation: NEXT_GENERATION.fetch_add(1, Ordering::Relaxed),
endpoint,
store,
local,
remote,
local_provider,
commands,
state: ConnectionState::Connecting,
message: "正在连接 SFTP…".into(),
progress: None,
details: false,
upload_pending: false,
question: None,
dialog_open: false,
dispatch,
focus_handle: cx.focus_handle(),
tab_group: None,
_subscriptions: subscriptions,
_events: events_task,
}
}
pub fn session_id(&self) -> SessionId {
self.session_id
}
pub fn generation(&self) -> u64 {
self.generation
}
pub fn endpoint(&self) -> &str {
&self.endpoint
}
pub fn tab_group(&self) -> Option<WeakEntity<TabGroup>> {
self.tab_group.clone()
}
pub fn local(&self) -> &Entity<FilePane> {
&self.local
}
pub fn remote(&self) -> &Entity<FilePane> {
&self.remote
}
pub fn connection_state(&self) -> ConnectionState {
self.state
}
pub fn progress(&self) -> Option<&UploadProgress> {
self.progress.as_ref()
}
pub fn is_uploading(&self) -> bool {
self.upload_pending
|| self
.progress
.as_ref()
.is_some_and(UploadProgress::is_active)
}
pub fn send(&self, command: SftpCommand) {
let _ = self.commands.try_send(command);
}
pub fn reply_to_prompt(&self, request_id: u64, reply: ConnectionPromptReply) {
self.send(SftpCommand::PromptReply { request_id, reply });
}
pub fn disconnect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.send(SftpCommand::Disconnect);
self.state = ConnectionState::Disconnected;
self.message = "SFTP 已断开".into();
self.remote
.update(cx, |pane, cx| pane.disconnected("SFTP 已断开".into(), cx));
self.close_upload_dialog(window, cx);
self.sync_available(cx);
cx.emit(ExplorerPanelEvent::StateChanged(self.session_id));
cx.notify();
}
pub fn close_upload_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.dialog_open {
self.dialog_open = false;
self.question = None;
if window.has_active_dialog(cx) {
window.close_dialog(cx);
}
}
}
fn sync_available(&mut self, cx: &mut Context<Self>) {
let enabled = self.state == ConnectionState::Connected && !self.is_uploading();
self.local
.update(cx, |pane, cx| pane.set_available(enabled, cx));
self.remote
.update(cx, |pane, cx| pane.set_available(enabled, cx));
}
fn on_event(&mut self, event: SftpEvent, window: &mut Window, cx: &mut Context<Self>) {
match event {
SftpEvent::Connecting => {
self.generation = NEXT_GENERATION.fetch_add(1, Ordering::Relaxed);
self.state = ConnectionState::Connecting;
self.message = "正在连接 SFTP…".into();
cx.emit(ExplorerPanelEvent::StateChanged(self.session_id));
}
SftpEvent::Connected { home } => {
self.state = ConnectionState::Connected;
self.message = self.endpoint.clone();
self.remote
.update(cx, |pane, _| pane.set_home(home.to_string()));
let path = self.remote.read(cx).path();
self.navigate(true, path, window, cx);
cx.emit(ExplorerPanelEvent::StateChanged(self.session_id));
}
SftpEvent::Disconnected(message) => {
self.state = ConnectionState::Disconnected;
self.remote
.update(cx, |pane, cx| pane.disconnected(message.clone(), cx));
self.message = message;
cx.emit(ExplorerPanelEvent::StateChanged(self.session_id));
}
SftpEvent::Prompt(prompt) => cx.emit(ExplorerPanelEvent::PromptRequested(
self.session_id,
self.generation,
prompt,
)),
SftpEvent::Listed { request_id, result } => self.remote.update(cx, |pane, cx| {
pane.apply_listing(request_id, result, window, cx)
}),
SftpEvent::Progress(progress) => {
let complete = progress.phase() == UploadPhase::Completed;
self.upload_pending = false;
self.progress = Some(progress);
if !self.is_uploading() {
self.close_upload_dialog(window, cx);
}
if complete && self.state == ConnectionState::Connected {
let path = self.remote.read(cx).path();
self.navigate(true, path, window, cx);
}
}
SftpEvent::Question(question) => {
self.question = Some(question);
cx.spawn_in(window, async move |this, cx| {
loop {
let done = this
.update_in(cx, |this, window, cx| {
if this.question.is_none() {
return true;
}
if window.has_active_dialog(cx) {
return false;
}
this.open_question(window, cx);
true
})
.unwrap_or(true);
if done {
break;
}
cx.background_executor()
.timer(Duration::from_millis(50))
.await;
}
})
.detach();
}
SftpEvent::Idle => {
self.upload_pending = false;
}
SftpEvent::Notice(message) => {
window.push_notification(Notification::error(message), cx);
}
}
self.sync_available(cx);
cx.notify();
}
pub fn navigate(
&mut self,
remote: bool,
path: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
let pane = if remote {
self.remote.clone()
} else {
self.local.clone()
};
let path = pane.read(cx).expanded_path(&path);
if remote {
let id = pane.update(cx, |pane, cx| pane.begin_load(cx));
match RemotePath::new(path) {
Ok(path) => self.send(SftpCommand::List {
request_id: id,
path,
}),
Err(e) => pane.update(cx, |pane, cx| {
pane.apply_listing(id, Err(e.to_string()), window, cx)
}),
}
} else {
pane.update(cx, |pane, cx| {
pane.load_local(path, self.local_provider.clone(), window, cx)
});
}
}
pub fn execute(
&mut self,
command: &ExplorerCommand,
window: &mut Window,
cx: &mut Context<Self>,
) {
match command {
ExplorerCommand::Navigate { remote, path } => {
self.navigate(*remote, path.clone(), window, cx)
}
ExplorerCommand::Up { remote } => {
let pane = if *remote { &self.remote } else { &self.local };
let path = pane.read(cx).parent_path();
self.navigate(*remote, path, window, cx);
}
ExplorerCommand::Refresh { remote } => {
let pane = if *remote { &self.remote } else { &self.local };
let path = pane.read(cx).path();
self.navigate(*remote, path, window, cx);
}
ExplorerCommand::Check {
name,
checked,
extend,
} => self
.local
.update(cx, |pane, cx| pane.check(name, *checked, *extend, cx)),
ExplorerCommand::ToggleSelection => {
self.local.update(cx, |pane, cx| pane.toggle_selected(cx))
}
ExplorerCommand::SelectAll => self.local.update(cx, |pane, cx| pane.select_all(cx)),
ExplorerCommand::UploadSelected => {
let paths = self.local.read(cx).upload_sources();
let target = self.remote.read(cx).path();
self.open_upload(paths, target, window, cx);
}
ExplorerCommand::ChooseFiles => {
if self.is_uploading() || self.state != ConnectionState::Connected {
return;
}
let choice = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: true,
multiple: true,
prompt: Some("选择要上传的文件或目录".into()),
});
let target = self.remote.read(cx).path();
cx.spawn_in(window, async move |this, cx| {
if let Ok(Ok(Some(paths))) = choice.await {
let _ = this.update_in(cx, |this, window, cx| {
this.dispatch.dispatch_explorer_action(
&ExplorerAction::new(
this.session_id,
ExplorerCommand::UploadPaths { paths, target },
),
window,
cx,
);
});
}
})
.detach();
}
ExplorerCommand::UploadPaths { paths, target } => {
self.open_upload(paths.clone(), target.clone(), window, cx)
}
ExplorerCommand::BeginUpload { paths, target } => {
if self.is_uploading() || self.state != ConnectionState::Connected {
return;
}
match RemotePath::new(target.clone())
.and_then(|path| UploadRequest::new(paths.clone(), path))
{
Ok(request) => {
self.upload_pending = true;
self.details = false;
self.send(SftpCommand::Upload(request));
}
Err(error) => {
window.push_notification(Notification::error(error.to_string()), cx)
}
}
}
ExplorerCommand::Answer { request_id, answer } => {
if self
.question
.as_ref()
.is_some_and(|q| q.id() == *request_id)
{
self.question = None;
self.dialog_open = false;
self.send(SftpCommand::Answer {
request_id: *request_id,
answer: *answer,
});
}
}
ExplorerCommand::CancelUpload => self.send(SftpCommand::Cancel),
ExplorerCommand::ResumeUpload => {
if !self.is_uploading() {
self.upload_pending = true;
self.send(SftpCommand::Resume);
}
}
ExplorerCommand::DiscardUpload => {
if !self.is_uploading() {
self.send(SftpCommand::Discard);
}
}
ExplorerCommand::ToggleDetails => self.details = !self.details,
ExplorerCommand::CloseConfirmed => {}
}
self.sync_available(cx);
cx.notify();
}
}
impl Drop for ExplorerPanel {
fn drop(&mut self) {
self.send(SftpCommand::Shutdown);
}
}
impl EventEmitter<PanelEvent> for ExplorerPanel {}
impl EventEmitter<ExplorerPanelEvent> for ExplorerPanel {}
impl Focusable for ExplorerPanel {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl BasePanel for ExplorerPanel {
fn panel_name(&self) -> &'static str {
"ExplorerPanel"
}
fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context<Self>) {
if active {
let id = self.session_id;
self.store
.update(cx, |store, cx| store.set_active(Some(id), cx));
// The tab that just went inactive stops rendering, so the focus
// it still holds leaves the dispatch tree and every
// `window.dispatch_action` (the tab's 「×」, ⌘W, the shortcuts)
// lands nowhere. Whoever becomes active has to take focus.
window.focus(&self.focus_handle, cx);
cx.emit(ExplorerPanelEvent::Activated(id));
}
}
fn on_added_to(&mut self, group: WeakEntity<TabGroup>, _: &mut Window, _: &mut Context<Self>) {
self.tab_group = Some(group);
}
fn on_removed(&mut self, _: &mut Window, cx: &mut Context<Self>) {
fn on_removed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.disconnect(window, cx);
self.send(SftpCommand::Shutdown);
self.tab_group = None;
cx.emit(ExplorerPanelEvent::Closed(self.session_id));
}
}
impl Panel for ExplorerPanel {
fn title(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let name = self
.store
.read(cx)
.session(self.session_id)
.map(|session| session.name.clone())
.map(|s| s.name.clone())
.unwrap_or_else(|| "SFTP".into());
ClosableTabTitle::new(CatalogIcon::FolderTree, format!("{name} · SFTP")).closable(
("close-explorer", self.session_id.0),
Box::new(CloseExplorer(self.session_id)),
)
}
fn dropdown_menu(
&mut self,
menu: PopupMenu,
@@ -152,35 +511,178 @@ impl Panel for ExplorerPanel {
) -> PopupMenu {
menu.menu("编辑会话…", Box::new(EditSession(self.session_id)))
}
fn inner_padding(&self, _: &App) -> bool {
false
}
}
impl Render for ExplorerPanel {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let sid = self.session_id.0;
div()
.id(("explorer", sid))
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let sid = self.session_id;
let command_button = |id: &'static str, label: &'static str, command: ExplorerCommand| {
let dispatch = self.dispatch.clone();
Button::new(id)
.ghost()
.small()
.label(label)
.on_click(move |_, w, cx| {
dispatch.dispatch_explorer_action(
&ExplorerAction::new(sid, command.clone()),
w,
cx,
)
})
};
v_flex()
.id(("explorer", sid.0))
.test_support()
.track_focus(&self.focus_handle)
.size_full()
.min_w_0()
.child(
// Pane sizes are an API boundary that takes `Pixels`; the
// resize handle owns the hairline between the panes.
h_resizable(("explorer-panes", sid))
.child(
resizable_panel()
.size(px(480.))
.size_range(px(240.)..Pixels::MAX)
.child(self.local.clone()),
)
.child(
resizable_panel()
.size_range(px(240.)..Pixels::MAX)
.child(self.remote.clone()),
h_flex()
.gap_2()
.px_2()
.py_1()
.text_sm()
.border_b_1()
.border_color(cx.theme().border)
.child(div().flex_1().min_w_0().child(self.message.clone()))
.when(
self.state == ConnectionState::Disconnected && !self.is_uploading(),
|this| {
this.child(command_button(
"reconnect-sftp",
"重新连接",
ExplorerCommand::ResumeUpload,
))
},
),
)
.child(
div().flex_1().min_h_0().child(
h_resizable(("explorer-panes", sid.0))
.child(
resizable_panel()
.size(px(480.))
.size_range(px(320.)..Pixels::MAX)
.child(self.local.clone()),
)
.child(
resizable_panel()
.size_range(px(280.)..Pixels::MAX)
.child(self.remote.clone()),
),
),
)
.when_some(self.progress.as_ref(), |this, progress| {
let status = match progress.phase() {
UploadPhase::Scanning => "正在扫描",
UploadPhase::Uploading => "正在上传",
UploadPhase::Waiting => "等待处理",
UploadPhase::Reconnecting => "正在重连",
UploadPhase::Stopped => "已停止 · 可继续上传",
UploadPhase::Completed => {
if progress.failed() > 0 {
"上传结束 · 部分失败"
} else {
"上传结束"
}
}
};
let value = if progress.phase() == UploadPhase::Completed {
100.
} else if progress.total_bytes() > 0 {
progress.completed_bytes() as f32 / progress.total_bytes() as f32 * 100.
} else {
0.
};
this.child(
v_flex()
.id("upload-progress")
.test_support()
.gap_2()
.px_3()
.py_2()
.border_t_1()
.border_color(cx.theme().border)
.child(
h_flex()
.gap_2()
.child(
div()
.id("upload-status")
.test_support()
.text_sm()
.child(status),
)
.child(div().flex_1())
.when(progress.is_active(), |this| {
this.child(command_button(
"cancel-upload",
"取消",
ExplorerCommand::CancelUpload,
))
})
.when(progress.phase() == UploadPhase::Stopped, |this| {
this.child(command_button(
"resume-upload",
"继续上传",
ExplorerCommand::ResumeUpload,
))
.child(command_button(
"discard-upload",
"丢弃续传进度",
ExplorerCommand::DiscardUpload,
))
})
.child(command_button(
"upload-details",
"详情",
ExplorerCommand::ToggleDetails,
)),
)
.child(
div()
.text_sm()
.text_ellipsis()
.child(progress.current().to_string()),
)
.child(
Progress::new("upload-bytes")
.value(value)
.accessibility_label("上传进度")
.small(),
)
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(format!(
"{} / {} · {}/秒 · 成功 {} · 跳过 {} · 失败 {}",
super::format_size(progress.completed_bytes()),
super::format_size(progress.total_bytes()),
super::format_size(progress.bytes_per_second()),
progress.succeeded(),
progress.skipped(),
progress.failed()
)),
)
.when(self.details, |this| {
this.child(
div()
.id("upload-detail-list")
.max_h_32()
.overflow_y_scroll()
.text_xs()
.children(
progress
.details()
.iter()
.map(|detail| div().child(detail.clone())),
),
)
}),
)
})
}
}
+172 -6
View File
@@ -1,8 +1,16 @@
use crate::app::ExplorerDispatch as _;
use crate::{
app::{ExplorerAction, ExplorerCommand},
session::SessionId,
};
use gpui_kit::component::{
ActiveTheme as _, Icon, IconName, Sizable as _, h_flex,
table::{Column, ColumnSort, TableDelegate, TableState},
};
use gpui_kit::*;
use std::{collections::HashSet, path::PathBuf, rc::Rc};
use gpui_kit::component::checkbox::Checkbox;
use super::{FileEntry, FileKind, PaneSide, format_size};
@@ -11,6 +19,13 @@ pub struct FileListing {
columns: Vec<Column>,
rows: Vec<FileEntry>,
sort: Option<(usize, ColumnSort)>,
active_name: Option<String>,
side: PaneSide,
session: SessionId,
path: String,
checked: Rc<HashSet<String>>,
enabled: bool,
dispatch: Option<FocusHandle>,
}
impl FileListing {
@@ -27,6 +42,9 @@ impl FileListing {
.width(px(150.))
.sortable(),
];
if side == PaneSide::Local {
columns.insert(0, Column::new("check", "").width(px(36.)));
}
if side == PaneSide::Remote {
columns.push(Column::new("permissions", "权限").width(px(110.)));
}
@@ -34,9 +52,34 @@ impl FileListing {
columns,
rows,
sort: None,
active_name: None,
side,
session: SessionId(0),
path: String::new(),
checked: Rc::new(HashSet::new()),
enabled: false,
dispatch: None,
}
}
pub fn set_active_name(&mut self, name: Option<String>) {
self.active_name = name;
}
pub fn configure(
&mut self,
session: SessionId,
path: String,
checked: Rc<HashSet<String>>,
enabled: bool,
dispatch: FocusHandle,
) {
self.session = session;
self.path = path;
self.checked = checked;
self.enabled = enabled;
self.dispatch = Some(dispatch);
}
pub fn rows(&self) -> &[FileEntry] {
&self.rows
}
@@ -103,6 +146,34 @@ impl TableDelegate for FileListing {
return div().into_any_element();
};
match self.columns[col_ix].key.as_ref() {
"check" => {
if entry.is_parent() {
return div().into_any_element();
}
let name = entry.name.to_string();
let checked = self.checked.contains(&name);
let dispatch = self.dispatch.clone();
let sid = self.session;
Checkbox::new(ElementId::Name(format!("check:{name}").into()))
.checked(checked)
.on_change(move |checked, window, cx| {
if let Some(dispatch) = &dispatch {
dispatch.dispatch_explorer_action(
&ExplorerAction::new(
sid,
ExplorerCommand::Check {
name: name.clone(),
checked: *checked,
extend: window.modifiers().shift,
},
),
window,
cx,
);
}
})
.into_any_element()
}
"name" => {
let icon = match entry.kind {
FileKind::Dir => IconName::Folder,
@@ -146,12 +217,72 @@ impl TableDelegate for FileListing {
_: &mut Window,
_: &mut Context<TableState<Self>>,
) -> Stateful<Div> {
let name = self
.rows
.get(row_ix)
.map(|entry| entry.name.to_string())
.unwrap_or_default();
div().id(ElementId::Name(format!("file:{name}").into()))
let Some(entry) = self.rows.get(row_ix) else {
return div().id(("empty-file-row", row_ix));
};
let name = entry.name.to_string();
let row = div().id(ElementId::Name(format!("file:{name}").into()));
let Some(dispatch) = self.dispatch.clone() else {
return row;
};
let sid = self.session;
if self.side == PaneSide::Local && !entry.is_parent() {
let names = if self.checked.contains(&name) {
self.checked.iter().cloned().collect::<Vec<_>>()
} else {
vec![name.clone()]
};
let paths = names
.iter()
.map(|name| PathBuf::from(&self.path).join(name))
.collect();
row.on_drag(LocalFilesDrag { paths }, |drag, _, _, cx| {
cx.new(|_| FileDragPreview {
count: drag.paths.len(),
})
})
} else if self.side == PaneSide::Remote && entry.is_dir() && !entry.is_parent() {
let target = format!("{}/{name}", self.path.trim_end_matches('/'));
let enabled = self.enabled;
row.on_drop({
let dispatch = dispatch.clone();
let target = target.clone();
move |paths: &ExternalPaths, window, cx| {
if enabled {
dispatch.dispatch_explorer_action(
&ExplorerAction::new(
sid,
ExplorerCommand::UploadPaths {
paths: paths.paths().to_vec(),
target: target.clone(),
},
),
window,
cx,
);
cx.stop_propagation();
}
}
})
.on_drop(move |drag: &LocalFilesDrag, window, cx| {
if enabled {
dispatch.dispatch_explorer_action(
&ExplorerAction::new(
sid,
ExplorerCommand::UploadPaths {
paths: drag.paths.clone(),
target: target.clone(),
},
),
window,
cx,
);
cx.stop_propagation();
}
})
} else {
row
}
}
fn perform_sort(
@@ -166,6 +297,20 @@ impl TableDelegate for FileListing {
sort => Some((col_ix, sort)),
};
self.apply_sort();
let index = self
.active_name
.as_ref()
.and_then(|name| self.rows.iter().position(|e| e.name.as_ref() == name));
let entity = cx.entity().downgrade();
cx.defer(move |cx| {
let _ = entity.update(cx, |table, cx| {
if let Some(index) = index {
table.set_selected_row(index, cx);
} else {
table.clear_selection(cx);
}
});
});
cx.notify();
}
@@ -184,3 +329,24 @@ impl TableDelegate for FileListing {
.child("空目录")
}
}
#[derive(Clone)]
pub(super) struct LocalFilesDrag {
pub paths: Vec<PathBuf>,
}
struct FileDragPreview {
count: usize,
}
impl Render for FileDragPreview {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.px_3()
.py_2()
.bg(cx.theme().popover)
.text_color(cx.theme().popover_foreground)
.border_1()
.border_color(cx.theme().border)
.rounded(cx.theme().radius)
.child(format!("上传 {} 个项目", self.count))
}
}
+537 -157
View File
@@ -1,204 +1,470 @@
use super::{FileEntry, FileListing};
use crate::app::ExplorerDispatch as _;
use crate::{
app::{CatalogIcon, ExplorerAction, ExplorerCommand},
session::SessionId,
sftp::{DirectoryListing, EntryKind, SharedLocalDirectoryProvider},
};
use gpui_kit::component::{
ActiveTheme as _, Disableable as _, Icon, IconName, Sizable as _, WindowExt as _,
ActiveTheme as _, Disableable as _, Icon, IconName, Sizable as _,
button::{Button, ButtonVariants as _},
h_flex,
input::{Input, InputEvent, InputState},
table::{DataTable, TableEvent, TableState},
v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use std::{collections::HashSet, path::PathBuf, rc::Rc};
use crate::app::CatalogIcon;
use crate::session::SessionId;
use super::{FileEntry, FileListing, Location};
/// Which side of the explorer a pane is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PaneSide {
Local,
Remote,
}
impl PaneSide {
pub fn label(self) -> &'static str {
match self {
PaneSide::Local => "本地",
PaneSide::Remote => "远程",
if self == Self::Local {
"本地"
} else {
"远程"
}
}
fn pane_id(self) -> &'static str {
match self {
PaneSide::Local => "local-pane",
PaneSide::Remote => "remote-pane",
if self == Self::Local {
"local-pane"
} else {
"remote-pane"
}
}
fn path_id(self) -> &'static str {
match self {
PaneSide::Local => "local-path",
PaneSide::Remote => "remote-path",
if self == Self::Local {
"local-path"
} else {
"remote-path"
}
}
}
/// One side of the explorer: address row, file table, summary footer.
pub struct FilePane {
side: PaneSide,
session_id: SessionId,
location: Location,
path: String,
home: String,
table: Entity<TableState<FileListing>>,
path_input: Entity<InputState>,
selected: Option<usize>,
selected: Option<String>,
checked: HashSet<String>,
anchor: Option<String>,
request_id: u64,
loading: bool,
error: Option<String>,
transfer_enabled: bool,
dispatch: FocusHandle,
_subscriptions: Vec<Subscription>,
load_task: Option<Task<()>>,
}
impl FilePane {
pub fn new(
side: PaneSide,
session_id: SessionId,
location: Location,
home: String,
dispatch: FocusHandle,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let rows = location.rows();
let mut listing = FileListing::new(side, Vec::new());
listing.configure(
session_id,
home.clone(),
Rc::new(HashSet::new()),
false,
dispatch.clone(),
);
let table = cx.new(|cx| {
TableState::new(FileListing::new(side, rows), window, cx)
TableState::new(listing, window, cx)
.row_selectable(true)
.col_selectable(false)
.cell_selectable(false)
});
let path_input = cx.new(|cx| InputState::new(window, cx).default_value(location.display()));
let subscriptions =
vec![
cx.subscribe_in(&table, window, |this, _, event: &TableEvent, window, cx| {
match event {
TableEvent::SelectRow(ix) => {
this.selected = Some(*ix);
cx.notify();
}
TableEvent::DoubleClickedRow(ix) => this.open_row(*ix, window, cx),
TableEvent::ClearSelection => {
this.selected = None;
cx.notify();
}
_ => {}
let path_input = cx.new(|cx| InputState::new(window, cx).default_value(&home));
let subscriptions = vec![
cx.subscribe_in(
&table,
window,
|this, table, event: &TableEvent, window, cx| match event {
TableEvent::SelectRow(ix) => {
this.selected = table
.read(cx)
.delegate()
.entry(*ix)
.map(|e| e.name.to_string());
table.update(cx, |table, _| {
table.delegate_mut().set_active_name(this.selected.clone())
});
cx.notify();
}
}),
cx.subscribe_in(
&path_input,
window,
|this, state, event: &InputEvent, window, cx| {
if let InputEvent::PressEnter { .. } = event {
let path = state.read(cx).value().to_string();
this.navigate_to(&path, window, cx);
TableEvent::DoubleClickedRow(ix) => {
let entry = table.read(cx).delegate().entry(*ix).cloned();
if let Some(entry) = entry
&& entry.is_dir()
{
let command = if entry.is_parent() {
ExplorerCommand::Up {
remote: this.side == PaneSide::Remote,
}
} else {
ExplorerCommand::Navigate {
remote: this.side == PaneSide::Remote,
path: this.child_path(&entry.name),
}
};
this.dispatch.dispatch_explorer_action(
&ExplorerAction::new(this.session_id, command),
window,
cx,
);
}
},
),
];
}
TableEvent::ClearSelection => {
this.selected = None;
cx.notify();
}
_ => {}
},
),
cx.subscribe_in(
&path_input,
window,
|this, state, event: &InputEvent, window, cx| {
if matches!(event, InputEvent::PressEnter { .. }) {
this.dispatch.dispatch_explorer_action(
&ExplorerAction::new(
this.session_id,
ExplorerCommand::Navigate {
remote: this.side == PaneSide::Remote,
path: state.read(cx).value().to_string(),
},
),
window,
cx,
);
}
},
),
];
Self {
side,
session_id,
location,
path: home.clone(),
home,
table,
path_input,
selected: None,
checked: HashSet::new(),
anchor: None,
request_id: 0,
loading: false,
error: None,
transfer_enabled: false,
dispatch,
_subscriptions: subscriptions,
load_task: None,
}
}
pub fn side(&self) -> PaneSide {
self.side
}
/// Absolute path of the directory shown.
pub fn path(&self) -> String {
self.location.path()
self.path.clone()
}
pub fn selected_entry(&self, cx: &App) -> Option<FileEntry> {
self.selected
.and_then(|ix| self.table.read(cx).delegate().entry(ix).cloned())
self.selected.as_ref().and_then(|name| {
self.table
.read(cx)
.delegate()
.rows()
.iter()
.find(|e| e.name.as_ref() == name)
.cloned()
})
}
fn open_row(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
let Some(entry) = self.table.read(cx).delegate().entry(ix).cloned() else {
return;
};
if !entry.is_dir() {
return;
}
let moved = if entry.is_parent() {
self.location.up()
fn child_path(&self, name: &str) -> String {
if self.side == PaneSide::Local {
PathBuf::from(&self.path)
.join(name)
.to_string_lossy()
.into_owned()
} else {
self.location.enter(&entry.name)
};
if moved {
self.reload(window, cx);
format!("{}/{name}", self.path.trim_end_matches('/'))
}
}
fn go_up(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.location.up() {
self.reload(window, cx);
}
}
fn navigate_to(&mut self, path: &str, window: &mut Window, cx: &mut Context<Self>) {
if self.location.set_path(path) {
self.reload(window, cx);
pub fn parent_path(&self) -> String {
if self.side == PaneSide::Local {
PathBuf::from(&self.path)
.parent()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| self.path.clone())
} else {
window.push_notification(format!("路径不存在:{path}"), cx);
crate::sftp::RemotePath::new(&self.path)
.map(|p| p.parent().to_string())
.unwrap_or_else(|_| "/".into())
}
}
/// Re-read the current directory into the table and address field.
pub fn reload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let rows = self.location.rows();
self.table.update(cx, |table, cx| {
table.delegate_mut().set_rows(rows);
table.clear_selection(cx);
table.refresh(cx);
});
let display = self.location.display();
self.path_input
.update(cx, |input, cx| input.set_value(display, window, cx));
self.selected = None;
pub fn expanded_path(&self, path: &str) -> String {
if path == "~" {
self.home.clone()
} else if let Some(rest) = path.strip_prefix("~/") {
format!("{}/{rest}", self.home.trim_end_matches('/'))
} else if (self.side == PaneSide::Local && !PathBuf::from(path).is_absolute())
|| (self.side == PaneSide::Remote && !path.starts_with('/'))
{
self.child_path(path)
} else {
path.to_string()
}
}
pub fn set_home(&mut self, home: String) {
if self.home.is_empty() {
self.path = home.clone();
}
self.home = home;
}
pub fn set_available(&mut self, available: bool, cx: &mut Context<Self>) {
self.transfer_enabled = available;
self.sync_listing(cx);
cx.notify();
}
/// Mock transfer: the real product would enqueue an upload/download.
fn transfer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(entry) = self.selected_entry(cx) {
window.push_notification(format!("已加入传输队列:{}", entry.name), cx);
}
fn sync_listing(&mut self, cx: &mut Context<Self>) {
let checked = Rc::new(self.checked.clone());
self.table.update(cx, |table, cx| {
table.delegate_mut().configure(
self.session_id,
self.path.clone(),
checked,
self.transfer_enabled,
self.dispatch.clone(),
);
cx.notify();
});
}
}
impl Render for FilePane {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let sid = self.session_id.0;
let selected = self.selected_entry(cx);
let transferable = selected.as_ref().is_some_and(|entry| !entry.is_parent());
let count = self
pub fn check(&mut self, name: &str, checked: bool, extend: bool, cx: &mut Context<Self>) {
let names: Vec<_> = self
.table
.read(cx)
.delegate()
.rows()
.iter()
.filter(|entry| !entry.is_parent())
.filter(|e| !e.is_parent())
.map(|e| e.name.to_string())
.collect();
if !names.iter().any(|n| n == name) {
return;
}
let mut chosen = vec![name.to_string()];
if extend
&& let (Some(start), Some(end)) = (
self.anchor
.as_ref()
.and_then(|anchor| names.iter().position(|n| n == anchor)),
names.iter().position(|n| n == name),
)
{
chosen = names[start.min(end)..=start.max(end)].to_vec();
}
for name in chosen {
if checked {
self.checked.insert(name);
} else {
self.checked.remove(&name);
}
}
self.anchor = Some(name.into());
self.sync_listing(cx);
cx.notify();
}
pub fn toggle_selected(&mut self, cx: &mut Context<Self>) {
if let Some(name) = self.selected.clone() {
self.check(&name, !self.checked.contains(&name), false, cx);
}
}
pub fn select_all(&mut self, cx: &mut Context<Self>) {
self.checked = self
.table
.read(cx)
.delegate()
.rows()
.iter()
.filter(|e| !e.is_parent())
.map(|e| e.name.to_string())
.collect();
self.sync_listing(cx);
cx.notify();
}
pub fn upload_sources(&self) -> Vec<PathBuf> {
let mut sources: Vec<_> = self
.checked
.iter()
.map(|name| PathBuf::from(&self.path).join(name))
.collect();
sources.sort();
sources
}
pub fn disconnected(&mut self, message: String, cx: &mut Context<Self>) {
self.request_id += 1;
self.loading = false;
self.error = Some(message);
cx.notify();
}
pub fn begin_load(&mut self, cx: &mut Context<Self>) -> u64 {
self.request_id += 1;
self.loading = true;
self.error = None;
cx.notify();
self.request_id
}
pub fn apply_listing(
&mut self,
request_id: u64,
result: Result<DirectoryListing, String>,
window: &mut Window,
cx: &mut Context<Self>,
) {
if request_id != self.request_id {
return;
}
self.loading = false;
match result {
Ok(listing) => {
if listing.path() != self.path {
self.checked.clear();
self.selected = None;
self.anchor = None;
}
self.path = listing.path().into();
self.error = None;
let mut rows: Vec<_> = listing
.entries()
.iter()
.map(|entry| {
let metadata = entry.metadata();
let modified = metadata
.modified()
.and_then(|t| chrono::DateTime::from_timestamp(t as i64, 0))
.map(|t| {
t.with_timezone(&chrono::Local)
.format("%Y-%m-%d %H:%M")
.to_string()
})
.unwrap_or_else(|| "—".into());
let permissions = metadata
.permissions()
.map(|p| format!("{:03o}", p & 0o777))
.unwrap_or_else(|| "—".into());
match metadata.kind() {
EntryKind::Directory => {
FileEntry::dir(entry.name(), &modified, &permissions)
}
EntryKind::Symlink => {
FileEntry::symlink(entry.name(), &modified, &permissions)
}
_ => FileEntry::file(
entry.name(),
metadata.size(),
&modified,
&permissions,
),
}
})
.collect();
rows.sort_by(|a, b| {
b.is_dir()
.cmp(&a.is_dir())
.then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
self.checked
.retain(|name| rows.iter().any(|e| e.name.as_ref() == name));
if self.parent_path() != self.path {
rows.insert(0, FileEntry::parent());
}
self.table.update(cx, |table, cx| {
table.delegate_mut().set_rows(rows);
table.clear_selection(cx);
table.refresh(cx);
});
self.sync_listing(cx);
let display = if self.path == self.home {
"~".into()
} else if let Some(rest) = self
.path
.strip_prefix(&format!("{}/", self.home.trim_end_matches('/')))
{
format!("~/{rest}")
} else {
self.path.clone()
};
self.path_input
.update(cx, |input, cx| input.set_value(display, window, cx));
}
Err(error) => self.error = Some(error),
}
cx.notify();
}
pub fn load_local(
&mut self,
path: String,
provider: SharedLocalDirectoryProvider,
window: &mut Window,
cx: &mut Context<Self>,
) {
let id = self.begin_load(cx);
self.load_task = Some(cx.spawn_in(window, async move |this, cx| {
let result = cx
.background_spawn(async move {
provider
.list(&PathBuf::from(path))
.map_err(|e| format!("无法读取目录:{e}"))
})
.await;
let _ = this.update_in(cx, |this, window, cx| {
this.apply_listing(id, result, window, cx)
});
}));
}
}
impl Render for FilePane {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let remote = self.side == PaneSide::Remote;
let sid = self.session_id;
let button =
|id: &'static str, icon: IconName, tip: &'static str, command: ExplorerCommand| {
let dispatch = self.dispatch.clone();
Button::new(id)
.ghost()
.small()
.icon(icon)
.tooltip(tip)
.on_click(move |_, window, cx| {
dispatch.dispatch_explorer_action(
&ExplorerAction::new(sid, command.clone()),
window,
cx,
)
})
};
let dispatch = self.dispatch.clone();
let file_count = self
.table
.read(cx)
.delegate()
.rows()
.iter()
.filter(|e| !e.is_parent())
.count();
let (transfer_id, transfer_icon, transfer_label) = match self.side {
PaneSide::Local => ("upload", CatalogIcon::Upload, "上传"),
PaneSide::Remote => ("download", CatalogIcon::Download, "下载"),
};
v_flex()
.id((self.side.pane_id(), sid))
.id((self.side.pane_id(), sid.0))
.test_support()
.size_full()
.min_w_0()
.child(
h_flex()
.gap_1()
@@ -218,56 +484,170 @@ impl Render for FilePane {
Input::new(&self.path_input)
.id(self.side.path_id())
.small()
.flex_1(),
.flex_1()
.min_w_0(),
)
.child(
Button::new("up")
.ghost()
.xsmall()
.icon(IconName::ArrowUp)
.tooltip("上级目录")
.disabled(self.location.is_root())
.on_click(cx.listener(|this, _, window, cx| this.go_up(window, cx))),
button(
"up",
IconName::ArrowUp,
"上级目录",
ExplorerCommand::Up { remote },
)
.disabled(self.parent_path() == self.path),
)
.child(button(
"refresh",
IconName::Redo,
"刷新",
ExplorerCommand::Refresh { remote },
)),
)
.when(!remote, |this| {
this.child(
h_flex()
.gap_2()
.px_2()
.py_1()
.border_b_1()
.border_color(cx.theme().border)
.child(
Button::new("upload")
.outline()
.small()
.icon(Icon::new(CatalogIcon::Upload))
.label("上传所选…")
.tooltip("上传所选文件(F5)")
.disabled(!self.transfer_enabled || self.checked.is_empty())
.on_click({
let dispatch = dispatch.clone();
move |_, w, cx| {
dispatch.dispatch_explorer_action(
&ExplorerAction::new(
sid,
ExplorerCommand::UploadSelected,
),
w,
cx,
)
}
}),
)
.child(
Button::new("choose-upload")
.ghost()
.small()
.label("选择文件上传…")
.disabled(!self.transfer_enabled)
.on_click(move |_, w, cx| {
dispatch.dispatch_explorer_action(
&ExplorerAction::new(sid, ExplorerCommand::ChooseFiles),
w,
cx,
)
}),
),
)
})
.when(remote, |this| {
this.child(
h_flex()
.px_2()
.py_1()
.h_8()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("拖入文件或目录以上传"),
)
})
.when(self.loading, |this| {
this.child(h_flex().px_2().py_1().text_sm().child("正在读取目录…"))
})
.when_some(self.error.clone(), |this, error| {
this.child(
div()
.id("directory-error")
.test_support()
.px_2()
.py_1()
.text_sm()
.text_color(cx.theme().danger)
.child(error),
)
})
.child(
div()
.flex_1()
.min_h_0()
.key_context(if remote {
"RemoteFileList"
} else {
"LocalFileList"
})
.when(remote, |this| {
let dispatch = self.dispatch.clone();
let path = self.path.clone();
let enabled = self.transfer_enabled;
this.drag_over::<ExternalPaths>(|style, _, _, cx| {
style.bg(cx.theme().muted)
})
.on_drop(move |paths: &ExternalPaths, window, cx| {
if enabled {
dispatch.dispatch_explorer_action(
&ExplorerAction::new(
sid,
ExplorerCommand::UploadPaths {
paths: paths.paths().to_vec(),
target: path.clone(),
},
),
window,
cx,
);
cx.stop_propagation();
}
})
.on_drop({
let dispatch = self.dispatch.clone();
let path = self.path.clone();
move |drag: &super::file_listing::LocalFilesDrag, window, cx| {
if enabled {
dispatch.dispatch_explorer_action(
&ExplorerAction::new(
sid,
ExplorerCommand::UploadPaths {
paths: drag.paths.clone(),
target: path.clone(),
},
),
window,
cx,
);
cx.stop_propagation();
}
}
})
})
.child(
Button::new("refresh")
.ghost()
.xsmall()
.icon(Icon::new(CatalogIcon::RefreshCw))
.tooltip("刷新")
.on_click(cx.listener(|this, _, window, cx| this.reload(window, cx))),
)
.child(
Button::new(transfer_id)
.outline()
.xsmall()
.icon(Icon::new(transfer_icon))
.label(transfer_label)
.disabled(!transferable)
.on_click(cx.listener(|this, _, window, cx| this.transfer(window, cx))),
DataTable::new(&self.table)
.stripe(false)
.bordered(false)
.small(),
),
)
.child(
div().flex_1().min_h_0().child(
DataTable::new(&self.table)
.stripe(false)
.bordered(false)
.small(),
),
)
.child(
h_flex()
.justify_between()
.px_2()
.py_1()
.border_t_1()
.border_color(cx.theme().border)
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(format!(
"共 {count} 项,已选 {} 项",
usize::from(transferable)
)),
.child(if remote {
format!("{file_count} 个项目")
} else {
format!("{file_count} 个项目 · 已选择 {} 项", self.checked.len())
}),
)
}
}
-224
View File
@@ -1,224 +0,0 @@
//! Seed directory trees for the mock local and remote file systems.
use crate::session::Session;
use super::{DirTree, FileEntry};
/// The mock local machine, rooted at `/Users/xuz`.
pub fn local_tree() -> DirTree {
let mut tree = DirTree::new();
tree.add_dir(
"/",
vec![FileEntry::dir("Users", "2026-01-12 09:10", "drwxr-xr-x")],
)
.add_dir(
"/Users",
vec![FileEntry::dir("xuz", "2026-01-12 09:10", "drwxr-xr-x")],
)
.add_dir(
"/Users/xuz",
vec![
FileEntry::dir("Downloads", "2026-09-09 18:42", "drwxr-xr-x"),
FileEntry::dir("Projects", "2026-09-10 11:05", "drwxr-xr-x"),
FileEntry::dir("Documents", "2026-08-30 20:14", "drwxr-xr-x"),
FileEntry::file(".zshrc", 2_310, "2026-07-02 08:15", "-rw-r--r--"),
FileEntry::file("notes.md", 8_412, "2026-09-08 14:32", "-rw-r--r--"),
],
)
.add_dir(
"/Users/xuz/Downloads",
vec![
FileEntry::file(
"shellr-0.1.0.dmg",
41_943_040,
"2026-09-09 18:42",
"-rw-r--r--",
),
FileEntry::file("report.pdf", 1_258_291, "2026-09-05 16:20", "-rw-r--r--"),
FileEntry::file("nginx.conf", 3_072, "2026-09-01 10:03", "-rw-r--r--"),
],
)
.add_dir(
"/Users/xuz/Projects",
vec![
FileEntry::dir("shellr", "2026-09-10 11:05", "drwxr-xr-x"),
FileEntry::dir("gpui-kit", "2026-09-10 10:40", "drwxr-xr-x"),
],
)
.add_dir(
"/Users/xuz/Projects/shellr",
vec![
FileEntry::dir("src", "2026-09-10 11:05", "drwxr-xr-x"),
FileEntry::file("Cargo.toml", 612, "2026-09-10 11:05", "-rw-r--r--"),
FileEntry::file("README.md", 1_480, "2026-09-10 11:05", "-rw-r--r--"),
],
)
.add_dir(
"/Users/xuz/Projects/shellr/src",
vec![
FileEntry::file("main.rs", 902, "2026-09-10 11:05", "-rw-r--r--"),
FileEntry::file("lib.rs", 388, "2026-09-10 11:05", "-rw-r--r--"),
],
)
.add_dir(
"/Users/xuz/Projects/gpui-kit",
vec![FileEntry::file(
"Cargo.toml",
4_120,
"2026-09-10 10:40",
"-rw-r--r--",
)],
)
.add_dir(
"/Users/xuz/Documents",
vec![
FileEntry::file("架构设计.docx", 356_352, "2026-08-30 20:14", "-rw-r--r--"),
FileEntry::file("服务器清单.xlsx", 24_576, "2026-08-21 09:00", "-rw-r--r--"),
],
);
tree
}
/// The mock remote host for a session, rooted at `/`.
pub fn remote_tree(session: &Session) -> DirTree {
let home = format!("/home/{}", session.user);
let mut tree = DirTree::new();
tree.add_dir(
"/",
vec![
FileEntry::dir("etc", "2026-06-15 07:00", "drwxr-xr-x"),
FileEntry::dir("var", "2026-06-15 07:00", "drwxr-xr-x"),
FileEntry::dir("home", "2026-06-15 07:00", "drwxr-xr-x"),
FileEntry::dir("opt", "2026-06-15 07:00", "drwxr-xr-x"),
FileEntry::symlink("bin", "2026-06-15 07:00", "lrwxrwxrwx"),
],
)
.add_dir(
"/etc",
vec![
FileEntry::dir("nginx", "2026-08-02 12:30", "drwxr-xr-x"),
FileEntry::dir("ssh", "2026-06-15 07:00", "drwxr-xr-x"),
FileEntry::file("hosts", 221, "2026-06-15 07:00", "-rw-r--r--"),
FileEntry::file("hostname", 7, "2026-06-15 07:00", "-rw-r--r--"),
],
)
.add_dir(
"/etc/nginx",
vec![
FileEntry::file("nginx.conf", 1_490, "2026-08-02 12:30", "-rw-r--r--"),
FileEntry::dir("sites-enabled", "2026-08-02 12:30", "drwxr-xr-x"),
],
)
.add_dir(
"/etc/nginx/sites-enabled",
vec![FileEntry::file(
"default",
2_412,
"2026-08-02 12:30",
"-rw-r--r--",
)],
)
.add_dir(
"/etc/ssh",
vec![FileEntry::file(
"sshd_config",
3_264,
"2026-06-15 07:00",
"-rw-------",
)],
)
.add_dir(
"/var",
vec![
FileEntry::dir("www", "2026-09-01 22:10", "drwxr-xr-x"),
FileEntry::dir("log", "2026-09-10 00:00", "drwxr-xr-x"),
],
)
.add_dir(
"/var/www",
vec![
FileEntry::dir("html", "2026-09-01 22:10", "drwxr-xr-x"),
FileEntry::file("app.log", 5_242_880, "2026-09-10 08:41", "-rw-r--r--"),
],
)
.add_dir(
"/var/www/html",
vec![
FileEntry::file("index.html", 612, "2026-09-01 22:10", "-rw-r--r--"),
FileEntry::file("favicon.ico", 4_286, "2026-09-01 22:10", "-rw-r--r--"),
],
)
.add_dir(
"/var/log",
vec![
FileEntry::file("syslog", 12_582_912, "2026-09-10 08:41", "-rw-r-----"),
FileEntry::file("auth.log", 786_432, "2026-09-10 08:41", "-rw-r-----"),
],
)
.add_dir(
"/home",
vec![FileEntry::dir(
&session.user,
"2026-06-15 07:00",
"drwxr-x---",
)],
)
.add_dir(
&home,
vec![
FileEntry::file(".bashrc", 3_771, "2026-06-15 07:00", "-rw-r--r--"),
FileEntry::file("deploy.sh", 1_024, "2026-09-03 19:22", "-rwxr-xr-x"),
FileEntry::dir("backups", "2026-09-08 02:00", "drwxr-xr-x"),
],
)
.add_dir(
&format!("{home}/backups"),
vec![FileEntry::file(
"db-2026-09-08.sql.gz",
73_400_320,
"2026-09-08 02:00",
"-rw-r--r--",
)],
)
.add_dir(
"/opt",
vec![FileEntry::dir("app", "2026-07-20 15:00", "drwxr-xr-x")],
)
.add_dir(
"/opt/app",
vec![
FileEntry::file("server", 18_874_368, "2026-07-20 15:00", "-rwxr-xr-x"),
FileEntry::file("config.toml", 1_212, "2026-07-20 15:00", "-rw-r--r--"),
],
);
tree
}
/// The directory a remote pane starts in.
pub fn remote_home(session: &Session) -> String {
format!("/home/{}", session.user)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::explorer::Location;
use crate::session::SessionStore;
#[test]
fn remote_tree_has_a_home_for_the_session_user() {
let store = SessionStore::seed();
let session = store.sessions().iter().find(|s| s.name == "db-01").unwrap();
let tree = remote_tree(session);
assert!(tree.contains("/home/postgres"));
let home = remote_home(session);
let location = Location::new(tree, &home, Some(&home));
assert_eq!(location.display(), "~");
}
#[test]
fn local_tree_starts_in_home() {
let location = Location::new(local_tree(), "/Users/xuz", Some("/Users/xuz"));
assert!(location.rows().iter().any(|e| e.name == "Downloads"));
}
}
+4 -3
View File
@@ -1,13 +1,14 @@
//! SFTP 文件浏览: the mock file system model and the two-pane explorer.
//! Real local/remote file browsing and SFTP upload UI.
mod explorer_panel;
mod file_listing;
mod file_pane;
mod mock_fs;
mod model;
pub use explorer_panel::{ExplorerPanel, ExplorerPanelEvent};
pub use file_listing::FileListing;
pub use file_pane::{FilePane, PaneSide};
pub use mock_fs::{local_tree, remote_home, remote_tree};
pub use model::*;
mod upload_dialog;
pub use upload_dialog::confirm_close_upload;
+1 -214
View File
@@ -1,6 +1,4 @@
//! Pure in-memory file system model used by the explorer panes.
use std::collections::BTreeMap;
//! Directory row snapshots and display formatting.
use gpui_kit::SharedString;
@@ -96,130 +94,6 @@ impl FileEntry {
}
}
/// An in-memory directory tree keyed by absolute path (`/`, `/etc`, ...).
#[derive(Clone, Debug, Default)]
pub struct DirTree {
dirs: BTreeMap<String, Vec<FileEntry>>,
}
impl DirTree {
pub fn new() -> Self {
Self::default()
}
pub fn add_dir(&mut self, path: &str, entries: Vec<FileEntry>) -> &mut Self {
self.dirs.insert(normalize(path), entries);
self
}
pub fn contains(&self, path: &str) -> bool {
self.dirs.contains_key(&normalize(path))
}
pub fn entries(&self, path: &str) -> &[FileEntry] {
self.dirs
.get(&normalize(path))
.map(Vec::as_slice)
.unwrap_or(&[])
}
}
/// A pane's current directory inside a `DirTree`, with navigation.
#[derive(Clone, Debug)]
pub struct Location {
tree: DirTree,
cwd: Vec<String>,
home: Option<Vec<String>>,
}
impl Location {
/// Start at `start`; when `home` is given, paths under it display as `~/…`.
pub fn new(tree: DirTree, start: &str, home: Option<&str>) -> Self {
let mut location = Self {
tree,
cwd: Vec::new(),
home: home.map(split_path),
};
if !location.set_path(start) {
location.cwd.clear();
}
location
}
/// Absolute path of the current directory.
pub fn path(&self) -> String {
join_path(&self.cwd)
}
/// The path as shown in the address field (`~/Downloads` under home).
pub fn display(&self) -> String {
match &self.home {
Some(home) if self.cwd.starts_with(home) => {
let rest = &self.cwd[home.len()..];
if rest.is_empty() {
"~".to_string()
} else {
format!("~/{}", rest.join("/"))
}
}
_ => self.path(),
}
}
pub fn is_root(&self) -> bool {
self.cwd.is_empty()
}
/// Enter a child directory by name. Returns `false` if it does not exist.
pub fn enter(&mut self, name: &str) -> bool {
if name == ".." {
return self.up();
}
let mut next = self.cwd.clone();
next.push(name.to_string());
let path = join_path(&next);
if !self.tree.contains(&path) {
return false;
}
self.cwd = next;
true
}
/// Go to the parent directory. Returns `false` at the root.
pub fn up(&mut self) -> bool {
self.cwd.pop().is_some()
}
/// Jump to an absolute path, or a `~`-relative one when a home is set.
pub fn set_path(&mut self, path: &str) -> bool {
let path = path.trim();
let expanded = match (&self.home, path.strip_prefix('~')) {
(Some(home), Some(rest)) => format!("{}{}", join_path(home), rest),
_ => path.to_string(),
};
if !self.tree.contains(&expanded) {
return false;
}
self.cwd = split_path(&expanded);
true
}
/// Rows for the table: `..` first (unless at the root), then directories,
/// then files, each group sorted by name.
pub fn rows(&self) -> Vec<FileEntry> {
let mut entries: Vec<FileEntry> = self.tree.entries(&self.path()).to_vec();
entries.sort_by(|a, b| {
b.is_dir()
.cmp(&a.is_dir())
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
if !self.is_root() {
entries.insert(0, FileEntry::parent());
}
entries
}
}
/// Human-readable size: `12 B`, `348 KB`, `1.2 MB`.
pub fn format_size(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
@@ -239,97 +113,10 @@ pub fn format_size(bytes: u64) -> String {
}
}
fn normalize(path: &str) -> String {
join_path(&split_path(path))
}
fn split_path(path: &str) -> Vec<String> {
path.split('/')
.filter(|part| !part.is_empty() && *part != ".")
.map(str::to_string)
.collect()
}
fn join_path(parts: &[String]) -> String {
if parts.is_empty() {
"/".to_string()
} else {
format!("/{}", parts.join("/"))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tree() -> DirTree {
let mut tree = DirTree::new();
tree.add_dir(
"/",
vec![
FileEntry::dir("home", "", ""),
FileEntry::file("readme.txt", 12, "", ""),
],
)
.add_dir("/home", vec![FileEntry::dir("xuz", "", "")])
.add_dir(
"/home/xuz",
vec![
FileEntry::file("b.txt", 1, "", ""),
FileEntry::dir("Downloads", "", ""),
FileEntry::file("A.txt", 2, "", ""),
],
)
.add_dir("/home/xuz/Downloads", vec![]);
tree
}
#[test]
fn enter_known_dir_updates_cwd() {
let mut location = Location::new(tree(), "/", None);
assert!(location.enter("home"));
assert!(location.enter("xuz"));
assert_eq!(location.path(), "/home/xuz");
}
#[test]
fn enter_unknown_name_is_rejected() {
let mut location = Location::new(tree(), "/home", None);
assert!(!location.enter("nope"));
assert_eq!(location.path(), "/home");
}
#[test]
fn up_at_root_stays_root() {
let mut location = Location::new(tree(), "/", None);
assert!(!location.up());
assert!(location.is_root());
assert!(location.enter("home"));
assert!(location.up());
assert!(location.is_root());
}
#[test]
fn set_path_rejects_missing_path_and_expands_home() {
let mut location = Location::new(tree(), "/", Some("/home/xuz"));
assert!(!location.set_path("/nowhere"));
assert_eq!(location.path(), "/");
assert!(location.set_path("~/Downloads"));
assert_eq!(location.path(), "/home/xuz/Downloads");
assert_eq!(location.display(), "~/Downloads");
assert!(location.set_path("/home"));
assert_eq!(location.display(), "/home");
}
#[test]
fn rows_put_dotdot_first_then_dirs_then_files_by_name() {
let location = Location::new(tree(), "/home/xuz", None);
let names: Vec<_> = location.rows().iter().map(|e| e.name.to_string()).collect();
assert_eq!(names, ["..", "Downloads", "A.txt", "b.txt"]);
let root = Location::new(tree(), "/", None);
assert_eq!(root.rows()[0].name.as_ref(), "home");
}
#[test]
fn format_size_units() {
assert_eq!(format_size(12), "12 B");
+341
View File
@@ -0,0 +1,341 @@
use super::ExplorerPanel;
use crate::app::ExplorerDispatch as _;
use crate::{
app::{ExplorerAction, ExplorerCommand},
session::SessionId,
sftp::{UploadAnswer, UploadChoice, UploadQuestionKind},
};
use gpui_kit::component::{
ActiveTheme as _, Sizable as _, WindowExt as _,
button::{Button, ButtonVariants as _},
checkbox::Checkbox,
dialog::{DialogAction, DialogClose, DialogFooter},
form::{Field, Form},
input::{Input, InputState},
v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use std::{cell::Cell, path::PathBuf, rc::Rc};
impl ExplorerPanel {
pub(super) fn open_upload(
&mut self,
paths: Vec<PathBuf>,
target: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
if paths.is_empty()
|| self.is_uploading()
|| self.connection_state() != crate::session::ConnectionState::Connected
|| window.has_active_dialog(cx)
{
return;
}
let target_input = cx.new(|cx| InputState::new(window, cx).default_value(&target));
let form = cx.new(|_| UploadForm {
paths: paths.clone(),
target: target_input.clone(),
endpoint: self.endpoint().into(),
error: None,
});
let dispatch = self.dispatch.clone();
let sid = self.session_id();
let generation = self.generation();
let focus = window.focused(cx).unwrap_or_else(|| self.focus_handle(cx));
let owner = cx.entity().downgrade();
self.dialog_open = true;
window.open_dialog(cx, move |dialog, _, _| {
dialog
.title("上传文件")
.child(form.clone())
.overlay_closable(false)
.footer(
DialogFooter::new()
.child(DialogClose::new().trigger(|b| b.label("取消")))
.child(
DialogAction::new()
.child(Button::new("upload-confirm").primary().label("上传")),
),
)
.on_ok({
let form = form.clone();
let dispatch = dispatch.clone();
let paths = paths.clone();
move |_, window, cx| {
let target = form.read(cx).target.read(cx).value().to_string();
if target.is_empty() {
form.update(cx, |form, cx| {
form.error = Some("请输入目标目录".into());
cx.notify();
});
return false;
}
dispatch.dispatch_explorer_action(
&ExplorerAction::new(
sid,
ExplorerCommand::BeginUpload {
paths: paths.clone(),
target,
},
)
.with_generation(generation),
window,
cx,
);
true
}
})
.on_close({
let focus = focus.clone();
let owner = owner.clone();
move |_, window, cx| {
let owner = owner.clone();
window.defer(cx, move |_, cx| {
let _ = owner.update(cx, |panel, _| panel.dialog_open = false);
});
window.focus(&focus, cx);
}
})
});
}
pub(super) fn open_question(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(question) = self.question.clone() else {
return;
};
let form = cx.new(|_| ConflictForm {
apply_all: false,
kind: question.kind(),
});
let answered = Rc::new(Cell::new(false));
let dispatch = self.dispatch.clone();
let sid = self.session_id();
let generation = self.generation();
let focus = window.focused(cx).unwrap_or_else(|| self.focus_handle(cx));
self.dialog_open = true;
let (title, primary_label, primary_choice) = match question.kind() {
UploadQuestionKind::Conflict => ("同名文件已存在", "覆盖", UploadChoice::Overwrite),
UploadQuestionKind::Resume => ("发现未完成的上传", "继续上传", UploadChoice::Resume),
UploadQuestionKind::InvalidResume => {
("无法继续此文件", "重新上传", UploadChoice::Restart)
}
UploadQuestionKind::Error => ("无法上传此项目", "重试", UploadChoice::Retry),
};
window.open_dialog(cx, move |dialog, _, _| {
let answer_button = |id: &'static str, label: &'static str, choice: UploadChoice| {
let answered = answered.clone();
let dispatch = dispatch.clone();
let form = form.clone();
let request_id = question.id();
Button::new(id).label(label).on_click(move |_, window, cx| {
answered.set(true);
let all = form.read(cx).apply_all;
dispatch.dispatch_explorer_action(
&ExplorerAction::new(
sid,
ExplorerCommand::Answer {
request_id,
answer: UploadAnswer::new(choice, all),
},
)
.with_generation(generation),
window,
cx,
);
window.close_dialog(cx);
})
};
dialog
.title(title)
.overlay_closable(false)
.child(
v_flex()
.gap_3()
.child(div().text_sm().child(question.path().to_string()))
.child(div().text_sm().child(question.message().to_string()))
.child(form.clone()),
)
.footer(
DialogFooter::new()
.child(answer_button(
"upload-question-cancel",
"取消上传",
UploadChoice::Cancel,
))
.child(answer_button(
"upload-question-skip",
"跳过",
UploadChoice::Skip,
))
.when(question.kind() == UploadQuestionKind::Resume, |footer| {
footer.child(answer_button(
"upload-question-restart",
"重新上传",
UploadChoice::Restart,
))
})
.child(
DialogAction::new().child(
Button::new("upload-question-confirm")
.primary()
.label(primary_label),
),
),
)
.on_ok({
let answered = answered.clone();
let dispatch = dispatch.clone();
let form = form.clone();
let request_id = question.id();
move |_, window, cx| {
answered.set(true);
let all = form.read(cx).apply_all;
dispatch.dispatch_explorer_action(
&ExplorerAction::new(
sid,
ExplorerCommand::Answer {
request_id,
answer: UploadAnswer::new(primary_choice, all),
},
)
.with_generation(generation),
window,
cx,
);
true
}
})
.on_close({
let answered = answered.clone();
let dispatch = dispatch.clone();
let request_id = question.id();
let focus = focus.clone();
move |_, window, cx| {
if !answered.replace(true) {
dispatch.dispatch_explorer_action(
&ExplorerAction::new(
sid,
ExplorerCommand::Answer {
request_id,
answer: UploadAnswer::new(UploadChoice::Cancel, false),
},
)
.with_generation(generation),
window,
cx,
);
}
window.focus(&focus, cx);
}
})
});
}
}
struct UploadForm {
paths: Vec<PathBuf>,
target: Entity<InputState>,
endpoint: String,
error: Option<String>,
}
impl Render for UploadForm {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap_3()
.child(div().text_sm().child(format!(
"上传 {} 个项目到 {}",
self.paths.len(),
self.endpoint
)))
.child(
div()
.id("upload-source-list")
.max_h_32()
.overflow_y_scroll()
.text_sm()
.children(
self.paths
.iter()
.map(|p| div().child(p.display().to_string())),
),
)
.child(
Form::new().child(
Field::new()
.label("目标目录")
.required(true)
.child(Input::new(&self.target).id("upload-target").small()),
),
)
.when_some(self.error.clone(), |this, error| {
this.child(
div()
.id("upload-form-error")
.test_support()
.text_color(cx.theme().danger)
.child(error),
)
})
}
}
struct ConflictForm {
apply_all: bool,
kind: UploadQuestionKind,
}
impl Render for ConflictForm {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div().when(self.kind == UploadQuestionKind::Conflict, |this| {
this.child(
Checkbox::new("upload-apply-all")
.label("应用于本批剩余冲突")
.checked(self.apply_all)
.on_change(cx.listener(|this, value, _, cx| {
this.apply_all = *value;
cx.notify();
})),
)
})
}
}
/// Session-scoped close confirmation dispatches through the same workspace handler.
pub fn confirm_close_upload(
session: SessionId,
generation: u64,
dispatch: FocusHandle,
window: &mut Window,
cx: &mut App,
) {
let focus = window.focused(cx);
window.open_alert_dialog(cx, move |dialog, _, _| {
dialog
.title("停止上传并关闭?")
.description("上传进度会保留,下次选择相同文件和目标目录时可以继续上传。")
.button_props(
gpui_kit::component::dialog::DialogButtonProps::default()
.ok_text("停止上传并关闭")
.cancel_text("继续上传"),
)
.show_cancel(true)
.on_cancel({
let focus = focus.clone();
move |_, window, cx| {
if let Some(focus) = &focus {
window.focus(focus, cx);
}
true
}
})
.on_ok({
let dispatch = dispatch.clone();
move |_, window, cx| {
dispatch.dispatch_explorer_action(
&ExplorerAction::new(session, ExplorerCommand::CloseConfirmed)
.with_generation(generation),
window,
cx,
);
true
}
})
});
}
+2
View File
@@ -5,9 +5,11 @@
//! `secrets` (系统钥匙串), `workspace` (窗口壳), `app` (动作、快捷键、资源).
pub mod app;
pub mod connection;
pub mod explorer;
pub mod secrets;
pub mod session;
pub mod sftp;
pub mod shared;
pub mod ssh;
pub mod terminal;
+12 -2
View File
@@ -206,13 +206,23 @@ pub fn confirm_delete_group(
name: &str,
sessions: usize,
subgroups: usize,
closes_tabs: bool,
affected: (bool, usize),
on_delete: DeleteHandler,
window: &mut Window,
cx: &mut App,
) {
let (closes_tabs, uploads) = affected;
let title: SharedString = format!("删除“{name}”?").into();
let description = describe_contents(sessions, subgroups, closes_tabs);
let mut description = describe_contents(sessions, subgroups, closes_tabs);
if uploads > 0 {
description = Some(
format!(
"{}将停止 {uploads} 个上传批次并保留续传进度。",
description.unwrap_or_default()
)
.into(),
);
}
window.open_alert_dialog(cx, move |alert, _, _| {
alert
.title(title.clone())
+10 -2
View File
@@ -579,17 +579,25 @@ pub fn open_session_dialog(
/// Ask before deleting a session. `on_delete` runs when the user confirms.
pub fn confirm_delete_session(
session: &Session,
closes_tabs: bool,
affected: (bool, usize),
on_delete: DeleteHandler,
window: &mut Window,
cx: &mut App,
) {
let (closes_tabs, uploads) = affected;
let title: SharedString = format!("删除“{}”?", session.name).into();
window.open_alert_dialog(cx, move |alert, _, _| {
alert
.title(title.clone())
.when(closes_tabs, |alert| {
alert.description("会一并关闭该会话已打开的终端和 SFTP 标签。")
alert.description(format!(
"会一并关闭该会话已打开的终端和 SFTP 标签。{}",
if uploads > 0 {
format!("将停止 {uploads} 个上传批次并保留续传进度。")
} else {
String::new()
}
))
})
.button_props(
DialogButtonProps::default()
+322
View File
@@ -0,0 +1,322 @@
use super::{DirectoryEntry, DirectoryListing, EntryKind, FileMetadata, RemotePath};
use crate::ssh::{SshConnectionConfig, SshConnector, SshHandle, SshPrompts};
use anyhow::{Result, anyhow, bail};
use russh_sftp::{
client::{RawSftpSession, error::Error},
protocol::{FileAttributes, OpenFlags, Packet, StatusCode},
};
use std::sync::Arc;
/// Small protocol seam, also implemented by deterministic fault-injection tests.
pub(crate) trait RemoteFs {
async fn metadata(&self, path: &RemotePath) -> Result<Option<FileMetadata>>;
async fn open(&self, path: &RemotePath, create: bool) -> Result<String>;
async fn sync(&self, handle: &str) -> Result<()>;
async fn write(&self, handle: &str, offset: u64, bytes: Vec<u8>) -> Result<()>;
async fn close(&self, handle: &str) -> Result<()>;
async fn attributes(
&self,
handle: &str,
modified: Option<u32>,
permissions: Option<u32>,
) -> Result<()>;
async fn mkdir(&self, path: &RemotePath) -> Result<()>;
async fn symlink(&self, target: &str, path: &RemotePath) -> Result<()>;
async fn readlink(&self, path: &RemotePath) -> Result<String>;
async fn rename(&self, from: &RemotePath, to: &RemotePath, replace: bool) -> Result<()>;
async fn remove(&self, path: &RemotePath) -> Result<()>;
fn atomic_replace(&self) -> bool;
}
pub(crate) struct SftpClient {
raw: RawSftpSession,
_ssh: Option<SshHandle>,
fingerprint: String,
atomic_replace: bool,
fsync: bool,
}
impl SftpClient {
pub async fn connect(
connector: &SshConnector,
config: &SshConnectionConfig,
prompts: Arc<SshPrompts>,
) -> Result<Self> {
let (ssh, fingerprint) = connector.connect(config, prompts).await?;
tokio::time::timeout(std::time::Duration::from_secs(15), async {
let channel = ssh.channel_open_session().await?;
channel.request_subsystem(true, "sftp").await?;
Self::initialize(
RawSftpSession::new(channel.into_stream()),
Some(ssh),
fingerprint,
)
.await
})
.await
.map_err(|_| anyhow!("启动 SFTP 超时"))?
}
async fn initialize(
raw: RawSftpSession,
ssh: Option<SshHandle>,
fingerprint: String,
) -> Result<Self> {
let version = raw.init().await?;
if version.version != 3 {
bail!("服务器未提供 SFTP v3");
}
let atomic_replace = version
.extensions
.get("posix-rename@openssh.com")
.is_some_and(|v| v == "1");
let fsync = version
.extensions
.get("fsync@openssh.com")
.is_some_and(|v| v == "1");
Ok(Self {
raw,
_ssh: ssh,
fingerprint,
atomic_replace,
fsync,
})
}
#[cfg(test)]
pub(super) async fn local_test_server(
directory: &std::path::Path,
) -> Result<(Self, tokio::process::Child)> {
let executable = ["/usr/libexec/sftp-server", "/usr/lib/openssh/sftp-server"]
.into_iter()
.find(|p| std::path::Path::new(p).exists())
.ok_or_else(|| anyhow!("本机没有 OpenSSH sftp-server"))?;
let mut child = tokio::process::Command::new(executable)
.current_dir(directory)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.kill_on_drop(true)
.spawn()?;
let stream = tokio::io::join(child.stdout.take().unwrap(), child.stdin.take().unwrap());
let client =
Self::initialize(RawSftpSession::new(stream), None, "local-test-key".into()).await?;
Ok((client, child))
}
pub fn fingerprint(&self) -> &str {
&self.fingerprint
}
pub async fn canonicalize(&self, path: &RemotePath) -> Result<RemotePath> {
let response = self.raw.realpath(path.as_str()).await?;
RemotePath::new(checked_text(
&response
.files
.first()
.ok_or_else(|| anyhow!("服务器未返回目录路径"))?
.filename,
)?)
}
pub async fn list(&self, path: &RemotePath) -> Result<DirectoryListing> {
let path = self.canonicalize(path).await?;
let handle = self.raw.opendir(path.as_str()).await?.handle;
let result: Result<DirectoryListing> = async {
let mut entries = Vec::new();
loop {
match self.raw.readdir(&handle).await {
Ok(page) => {
for entry in page.files {
if matches!(entry.filename.as_str(), "." | "..") {
continue;
}
checked_text(&entry.filename)?;
path.join(&entry.filename)?;
entries
.push(DirectoryEntry::new(entry.filename, metadata(&entry.attrs)));
}
}
Err(Error::Status(status)) if status.status_code == StatusCode::Eof => break,
Err(error) => return Err(error.into()),
}
}
Ok(DirectoryListing::new(path.as_str(), entries))
}
.await;
let closed = self.raw.close(handle).await;
let listing = result?;
closed?;
Ok(listing)
}
}
impl RemoteFs for SftpClient {
async fn metadata(&self, path: &RemotePath) -> Result<Option<FileMetadata>> {
match self.raw.lstat(path.as_str()).await {
Ok(attrs) => Ok(Some(metadata(&attrs.attrs))),
Err(Error::Status(status)) if status.status_code == StatusCode::NoSuchFile => Ok(None),
Err(error) => Err(error.into()),
}
}
async fn open(&self, path: &RemotePath, create: bool) -> Result<String> {
let flags = if create {
OpenFlags::CREATE | OpenFlags::EXCLUDE | OpenFlags::WRITE
} else {
OpenFlags::WRITE
};
Ok(self
.raw
.open(path.as_str(), flags, FileAttributes::empty())
.await?
.handle)
}
async fn sync(&self, handle: &str) -> Result<()> {
if self.fsync {
self.raw.fsync(handle).await?;
}
Ok(())
}
async fn write(&self, handle: &str, offset: u64, bytes: Vec<u8>) -> Result<()> {
self.raw.write(handle, offset, bytes).await?;
Ok(())
}
async fn close(&self, handle: &str) -> Result<()> {
self.raw.close(handle).await?;
Ok(())
}
async fn attributes(
&self,
handle: &str,
modified: Option<u32>,
permissions: Option<u32>,
) -> Result<()> {
self.raw
.fsetstat(
handle,
FileAttributes {
mtime: modified,
atime: modified,
permissions: permissions.map(|p| p & 0o777),
..FileAttributes::empty()
},
)
.await?;
Ok(())
}
async fn mkdir(&self, path: &RemotePath) -> Result<()> {
self.raw
.mkdir(path.as_str(), FileAttributes::empty())
.await?;
Ok(())
}
async fn symlink(&self, target: &str, path: &RemotePath) -> Result<()> {
self.raw.symlink(target, path.as_str()).await?;
Ok(())
}
async fn readlink(&self, path: &RemotePath) -> Result<String> {
checked_text(
&self
.raw
.readlink(path.as_str())
.await?
.files
.first()
.ok_or_else(|| anyhow!("无法读取链接目标"))?
.filename,
)
}
async fn rename(&self, from: &RemotePath, to: &RemotePath, replace: bool) -> Result<()> {
if replace {
let mut payload = Vec::new();
for value in [from.as_str(), to.as_str()] {
payload.extend_from_slice(&(value.len() as u32).to_be_bytes());
payload.extend_from_slice(value.as_bytes());
}
match self
.raw
.extended("posix-rename@openssh.com", payload)
.await?
{
Packet::Status(status) if status.status_code == StatusCode::Ok => {}
Packet::Status(status) => return Err(Error::Status(status).into()),
_ => bail!("服务器返回了无效的重命名响应"),
}
} else {
self.raw.rename(from.as_str(), to.as_str()).await?;
}
Ok(())
}
async fn remove(&self, path: &RemotePath) -> Result<()> {
match self.raw.remove(path.as_str()).await {
Ok(_) => Ok(()),
Err(Error::Status(s)) if s.status_code == StatusCode::NoSuchFile => Ok(()),
Err(error) => Err(error.into()),
}
}
fn atomic_replace(&self) -> bool {
self.atomic_replace
}
}
fn metadata(attrs: &FileAttributes) -> FileMetadata {
let kind = if attrs.is_dir() {
EntryKind::Directory
} else if attrs.is_symlink() {
EntryKind::Symlink
} else if attrs.is_regular() {
EntryKind::File
} else {
EntryKind::Other
};
FileMetadata::new(
kind,
attrs.size.unwrap_or(0),
attrs.mtime,
attrs.permissions,
)
}
pub(crate) fn is_network_error(error: &anyhow::Error) -> bool {
error.chain().any(|cause| {
if cause.downcast_ref::<russh::Error>().is_some_and(|e| {
matches!(
e,
russh::Error::Disconnect
| russh::Error::HUP
| russh::Error::ConnectionTimeout
| russh::Error::KeepaliveTimeout
| russh::Error::InactivityTimeout
)
}) {
return true;
}
cause.downcast_ref::<std::io::Error>().is_some_and(|e| {
matches!(
e.kind(),
std::io::ErrorKind::NotConnected
| std::io::ErrorKind::ConnectionRefused
| std::io::ErrorKind::ConnectionReset
| std::io::ErrorKind::ConnectionAborted
| std::io::ErrorKind::TimedOut
| std::io::ErrorKind::UnexpectedEof
| std::io::ErrorKind::BrokenPipe
)
}) || cause
.downcast_ref::<Error>()
.is_some_and(|error| match error {
Error::IO(_) | Error::Timeout => true,
Error::UnexpectedBehavior(message) => {
message == "sender dropped"
|| message.contains("SendError")
|| message.contains("RecvError")
}
Error::Status(status) => matches!(
status.status_code,
StatusCode::ConnectionLost | StatusCode::NoConnection
),
_ => false,
})
})
}
// russh-sftp v3 decodes wire strings lossily. Refuse ambiguous names before
// they can become a path for navigation or mutation.
fn checked_text(value: &str) -> Result<String> {
if value.contains('\u{fffd}') {
bail!("远端名称包含无法可靠表示的字符,已停止操作");
}
Ok(value.to_string())
}
+72
View File
@@ -0,0 +1,72 @@
use super::{SftpEvent, UploadAnswer, UploadChoice, UploadQuestion, UploadQuestionKind};
use anyhow::Result;
use async_channel::{Receiver, Sender};
use std::{
future::Future,
sync::atomic::{AtomicU64, Ordering},
};
use tokio::sync::watch;
static NEXT_QUESTION: AtomicU64 = AtomicU64::new(1);
#[derive(Debug)]
pub(crate) struct Cancelled;
impl std::fmt::Display for Cancelled {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("上传已停止,进度已保留")
}
}
impl std::error::Error for Cancelled {}
pub(crate) struct UploadControl {
pub events: Sender<SftpEvent>,
pub cancel: watch::Receiver<bool>,
pub answers: Receiver<(u64, UploadAnswer)>,
}
impl UploadControl {
pub fn check(&self) -> Result<()> {
if *self.cancel.borrow() {
Err(Cancelled.into())
} else {
Ok(())
}
}
pub async fn run<T>(&self, future: impl Future<Output = Result<T>>) -> Result<T> {
self.check()?;
let mut cancel = self.cancel.clone();
tokio::select! {
result = future => result,
_ = async {
loop {
if *cancel.borrow_and_update() { break; }
if cancel.changed().await.is_err() { break; }
}
} => Err(Cancelled.into())
}
}
pub async fn ask(
&self,
kind: UploadQuestionKind,
path: &str,
message: &str,
) -> Result<UploadAnswer> {
self.check()?;
let id = NEXT_QUESTION.fetch_add(1, Ordering::Relaxed);
self.events
.send(SftpEvent::Question(UploadQuestion::new(
id, kind, path, message,
)))
.await?;
self.run(async {
loop {
let (reply_id, answer) = self.answers.recv().await?;
if reply_id == id {
return if answer.choice() == UploadChoice::Cancel {
Err(Cancelled.into())
} else {
Ok(answer)
};
}
}
})
.await
}
}
+184
View File
@@ -0,0 +1,184 @@
use super::{EntryKind, FileMetadata, RemotePath};
use anyhow::{Context as _, Result, bail};
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256};
use std::{
path::{Path, PathBuf},
time::UNIX_EPOCH,
};
use tokio::io::AsyncWriteExt as _;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct SourceMetadata {
pub size: u64,
pub modified_ns: u128,
}
impl SourceMetadata {
pub async fn read(path: &Path) -> Result<Self> {
let metadata = tokio::fs::symlink_metadata(path).await?;
if !metadata.is_file() {
bail!("本地来源已不再是普通文件");
}
Ok(Self {
size: metadata.len(),
modified_ns: metadata.modified()?.duration_since(UNIX_EPOCH)?.as_nanos(),
})
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) enum PublishPhase {
Writing,
Ready,
BackingUp,
BackedUp,
Publishing,
Published,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct ResumeRecord {
pub version: u32,
pub endpoint: String,
pub host_key: String,
pub source: PathBuf,
#[serde(alias = "fingerprint")]
pub source_metadata: Option<SourceMetadata>,
pub link_target: Option<String>,
pub target: RemotePath,
pub temporary: RemotePath,
pub backup: RemotePath,
pub original: Option<FileMetadata>,
pub phase: PublishPhase,
}
impl ResumeRecord {
pub fn new(
endpoint: &str,
host_key: &str,
source: &Path,
target: RemotePath,
original: Option<FileMetadata>,
) -> Result<Self> {
let id = uuid::Uuid::new_v4();
let parent = target.parent();
let temporary = RemotePath::new(format!("{}.filepart", target.as_str()))?;
Ok(Self {
version: 2,
endpoint: endpoint.into(),
host_key: host_key.into(),
source: source.into(),
source_metadata: None,
link_target: None,
temporary,
backup: parent.join(&format!(".shellr-{id}.backup"))?,
target,
original,
phase: PublishPhase::Writing,
})
}
pub fn validate(
&self,
endpoint: &str,
host_key: &str,
source: &Path,
target: &RemotePath,
) -> Result<()> {
if !matches!(self.version, 1 | 2)
|| self.endpoint != endpoint
|| self.host_key != host_key
|| self.source != source
|| &self.target != target
{
bail!("续传记录与当前来源或服务器不匹配");
}
let temporary_is_valid = if self.version == 1 {
managed_uuid_path(&self.temporary, target, ".filepart")
} else {
self.temporary == RemotePath::new(format!("{}.filepart", target.as_str()))?
};
if !temporary_is_valid || !managed_uuid_path(&self.backup, target, ".backup") {
bail!("无效的续传临时路径");
}
if self
.original
.as_ref()
.is_some_and(|m| m.kind() == EntryKind::Directory)
{
bail!("续传记录不能替换目录");
}
Ok(())
}
}
fn managed_uuid_path(path: &RemotePath, target: &RemotePath, suffix: &str) -> bool {
let name = path.as_str().rsplit('/').next().unwrap_or_default();
name.strip_prefix(".shellr-")
.and_then(|value| value.strip_suffix(suffix))
.and_then(|value| uuid::Uuid::parse_str(value).ok())
.is_some()
&& path.parent() == target.parent()
&& path != target
}
#[derive(Clone)]
pub(crate) struct Journal {
root: PathBuf,
}
impl Journal {
pub fn new(root: PathBuf) -> Self {
Self { root }
}
fn path(&self, endpoint: &str, source: &Path, target: &RemotePath) -> PathBuf {
let mut hash = Sha256::new();
for part in [
endpoint.as_bytes(),
source.as_os_str().as_encoded_bytes(),
target.as_str().as_bytes(),
] {
hash.update((part.len() as u64).to_be_bytes());
hash.update(part);
}
self.root.join(format!("{:x}.json", hash.finalize()))
}
pub async fn load(
&self,
endpoint: &str,
source: &Path,
target: &RemotePath,
) -> Result<Option<ResumeRecord>> {
match tokio::fs::read(self.path(endpoint, source, target)).await {
Ok(bytes) => Ok(Some(
serde_json::from_slice(&bytes).context("续传记录损坏,原文件和临时文件均已保留")?,
)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error).context("无法读取续传记录"),
}
}
pub async fn save(&self, record: &ResumeRecord) -> Result<()> {
tokio::fs::create_dir_all(&self.root).await?;
let path = self.path(&record.endpoint, &record.source, &record.target);
let temp = path.with_extension("pending");
let mut options = tokio::fs::OpenOptions::new();
options.create(true).truncate(true).write(true);
#[cfg(unix)]
options.mode(0o600);
let mut file = options.open(&temp).await?;
file.write_all(&serde_json::to_vec(record)?).await?;
file.sync_all().await?;
drop(file);
tokio::fs::rename(&temp, &path)
.await
.context("无法保存续传进度")?;
#[cfg(unix)]
{
tokio::fs::File::open(&self.root).await?.sync_all().await?;
}
Ok(())
}
pub async fn remove(&self, record: &ResumeRecord) -> Result<()> {
match tokio::fs::remove_file(self.path(&record.endpoint, &record.source, &record.target))
.await
{
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
}
+15
View File
@@ -0,0 +1,15 @@
//! Real file browsing and resumable SFTP uploads. No GPUI or workspace dependencies.
mod model;
mod transport;
pub use model::*;
pub use transport::*;
mod client;
mod control;
mod journal;
mod upload;
mod worker;
pub use worker::SshSftpTransportProvider;
#[cfg(all(test, unix))]
mod protocol_tests;
#[cfg(test)]
mod tests;
+350
View File
@@ -0,0 +1,350 @@
use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
/// A server path. Never interpreted using the client operating system's rules.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct RemotePath(String);
impl RemotePath {
pub fn new(path: impl Into<String>) -> Result<Self> {
let path = path.into();
if path.is_empty() || path.contains('\0') {
bail!("远程路径不能为空或包含空字符");
}
Ok(Self(path))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn join(&self, name: &str) -> Result<Self> {
if name.is_empty() || matches!(name, "." | "..") || name.contains(['/', '\0']) {
bail!("无效的文件名:{name}");
}
Self::new(format!("{}/{name}", self.0.trim_end_matches('/')))
}
pub fn parent(&self) -> Self {
let path = self.0.trim_end_matches('/');
Self(
path.rsplit_once('/')
.map(|(parent, _)| if parent.is_empty() { "/" } else { parent })
.unwrap_or("/")
.to_owned(),
)
}
pub fn is_root(&self) -> bool {
self.0 == "/"
}
}
impl std::fmt::Display for RemotePath {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum EntryKind {
File,
Directory,
Symlink,
Other,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileMetadata {
kind: EntryKind,
size: u64,
modified: Option<u32>,
permissions: Option<u32>,
}
impl FileMetadata {
pub fn new(
kind: EntryKind,
size: u64,
modified: Option<u32>,
permissions: Option<u32>,
) -> Self {
Self {
kind,
size,
modified,
permissions,
}
}
pub fn kind(&self) -> EntryKind {
self.kind
}
pub fn size(&self) -> u64 {
self.size
}
pub fn modified(&self) -> Option<u32> {
self.modified
}
pub fn permissions(&self) -> Option<u32> {
self.permissions
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DirectoryEntry {
name: String,
metadata: FileMetadata,
}
impl DirectoryEntry {
pub fn new(name: impl Into<String>, metadata: FileMetadata) -> Self {
Self {
name: name.into(),
metadata,
}
}
pub fn name(&self) -> &str {
&self.name
}
pub fn metadata(&self) -> &FileMetadata {
&self.metadata
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DirectoryListing {
path: String,
entries: Vec<DirectoryEntry>,
}
impl DirectoryListing {
pub fn new(path: impl Into<String>, entries: Vec<DirectoryEntry>) -> Self {
Self {
path: path.into(),
entries,
}
}
pub fn path(&self) -> &str {
&self.path
}
pub fn entries(&self) -> &[DirectoryEntry] {
&self.entries
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UploadRequest {
sources: Vec<PathBuf>,
destination: RemotePath,
}
impl UploadRequest {
pub fn new(sources: Vec<PathBuf>, destination: RemotePath) -> Result<Self> {
if sources.is_empty() {
bail!("请选择要上传的文件或目录");
}
Ok(Self {
sources,
destination,
})
}
pub fn sources(&self) -> &[PathBuf] {
&self.sources
}
pub fn destination(&self) -> &RemotePath {
&self.destination
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UploadPhase {
Scanning,
Uploading,
Waiting,
Reconnecting,
Stopped,
Completed,
}
#[derive(Clone, Debug, PartialEq)]
pub struct UploadProgress {
pub(crate) phase: UploadPhase,
pub(crate) current: String,
pub(crate) completed_bytes: u64,
pub(crate) total_bytes: u64,
pub(crate) total: usize,
pub(crate) succeeded: usize,
pub(crate) skipped: usize,
pub(crate) failed: usize,
pub(crate) bytes_per_second: u64,
pub(crate) details: Vec<String>,
}
impl Default for UploadProgress {
fn default() -> Self {
Self {
phase: UploadPhase::Scanning,
current: String::new(),
completed_bytes: 0,
total_bytes: 0,
total: 0,
succeeded: 0,
skipped: 0,
failed: 0,
bytes_per_second: 0,
details: Vec::new(),
}
}
}
impl UploadProgress {
pub fn new(phase: UploadPhase) -> Self {
Self {
phase,
..Self::default()
}
}
pub fn phase(&self) -> UploadPhase {
self.phase
}
pub fn current(&self) -> &str {
&self.current
}
pub fn completed_bytes(&self) -> u64 {
self.completed_bytes
}
pub fn total_bytes(&self) -> u64 {
self.total_bytes
}
pub fn total(&self) -> usize {
self.total
}
pub fn succeeded(&self) -> usize {
self.succeeded
}
pub fn skipped(&self) -> usize {
self.skipped
}
pub fn failed(&self) -> usize {
self.failed
}
pub fn bytes_per_second(&self) -> u64 {
self.bytes_per_second
}
pub fn details(&self) -> &[String] {
&self.details
}
pub fn is_active(&self) -> bool {
!matches!(self.phase, UploadPhase::Stopped | UploadPhase::Completed)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UploadQuestionKind {
Conflict,
Resume,
InvalidResume,
Error,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UploadQuestion {
id: u64,
kind: UploadQuestionKind,
path: String,
message: String,
}
impl UploadQuestion {
pub fn new(
id: u64,
kind: UploadQuestionKind,
path: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
id,
kind,
path: path.into(),
message: message.into(),
}
}
pub fn id(&self) -> u64 {
self.id
}
pub fn kind(&self) -> UploadQuestionKind {
self.kind
}
pub fn path(&self) -> &str {
&self.path
}
pub fn message(&self) -> &str {
&self.message
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
pub enum UploadChoice {
Overwrite,
Skip,
Cancel,
Retry,
Resume,
Restart,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
pub struct UploadAnswer {
choice: UploadChoice,
apply_to_all: bool,
}
impl UploadAnswer {
pub fn new(choice: UploadChoice, apply_to_all: bool) -> Self {
Self {
choice,
apply_to_all,
}
}
pub fn choice(&self) -> UploadChoice {
self.choice
}
pub fn apply_to_all(&self) -> bool {
self.apply_to_all
}
}
/// Local I/O is blocking and must be called on a background executor.
pub trait LocalDirectoryProvider: Send + Sync + 'static {
fn home(&self) -> PathBuf;
fn list(&self, path: &Path) -> Result<DirectoryListing>;
}
pub type SharedLocalDirectoryProvider = std::sync::Arc<dyn LocalDirectoryProvider>;
#[derive(Default)]
pub struct SystemLocalDirectoryProvider;
impl LocalDirectoryProvider for SystemLocalDirectoryProvider {
fn home(&self) -> PathBuf {
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
}
fn list(&self, path: &Path) -> Result<DirectoryListing> {
let path = std::fs::canonicalize(path)?;
let mut entries = Vec::new();
for entry in std::fs::read_dir(&path)? {
let entry = entry?;
let name = entry
.file_name()
.into_string()
.map_err(|_| anyhow::anyhow!("目录包含无法用 UTF-8 表示的文件名"))?;
let metadata = std::fs::symlink_metadata(entry.path())?;
entries.push(DirectoryEntry::new(name, local_metadata(&metadata)));
}
Ok(DirectoryListing::new(
path.to_str()
.ok_or_else(|| anyhow::anyhow!("路径不是有效的 UTF-8"))?,
entries,
))
}
}
pub(crate) fn local_metadata(metadata: &std::fs::Metadata) -> FileMetadata {
let kind = if metadata.is_symlink() {
EntryKind::Symlink
} else if metadata.is_dir() {
EntryKind::Directory
} else if metadata.is_file() {
EntryKind::File
} else {
EntryKind::Other
};
let modified = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.and_then(|d| u32::try_from(d.as_secs()).ok());
#[cfg(unix)]
let permissions = {
use std::os::unix::fs::PermissionsExt;
Some(metadata.permissions().mode())
};
#[cfg(not(unix))]
let permissions = None;
FileMetadata::new(kind, metadata.len(), modified, permissions)
}
+404
View File
@@ -0,0 +1,404 @@
//! Loopback-only SSH/SFTP worker tests. No user hosts, credentials or shell.
use super::*;
use crate::{
connection::{ConnectionPromptKind, ConnectionPromptReply, ConnectionSecret},
secrets::InMemorySecretStore,
session::{AuthKind, Session, SessionDraft, SessionId},
ssh::SshConnector,
};
use russh::{
ChannelId,
server::{self, Server as _},
};
use std::{
collections::HashMap,
path::PathBuf,
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
time::Duration,
};
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
struct TestServer {
directory: PathBuf,
connections: Arc<AtomicUsize>,
shells: Arc<AtomicUsize>,
interrupts: Arc<AtomicUsize>,
unsupported: bool,
}
struct Handler {
directory: PathBuf,
shells: Arc<AtomicUsize>,
interrupts: Arc<AtomicUsize>,
unsupported: bool,
channels: HashMap<ChannelId, russh::Channel<server::Msg>>,
}
impl server::Server for TestServer {
type Handler = Handler;
fn new_client(&mut self, _: Option<std::net::SocketAddr>) -> Handler {
self.connections.fetch_add(1, Ordering::SeqCst);
Handler {
directory: self.directory.clone(),
shells: self.shells.clone(),
interrupts: self.interrupts.clone(),
unsupported: self.unsupported,
channels: HashMap::new(),
}
}
}
impl server::Handler for Handler {
type Error = anyhow::Error;
async fn auth_password(
&mut self,
user: &str,
password: &str,
) -> Result<server::Auth, Self::Error> {
Ok(if user == "tester" && password == "fixture-password" {
server::Auth::Accept
} else {
server::Auth::reject()
})
}
async fn channel_open_session(
&mut self,
channel: russh::Channel<server::Msg>,
reply: server::ChannelOpenHandle,
_: &mut server::Session,
) -> Result<(), Self::Error> {
self.channels.insert(channel.id(), channel);
reply.accept().await;
Ok(())
}
async fn shell_request(
&mut self,
channel: ChannelId,
session: &mut server::Session,
) -> Result<(), Self::Error> {
self.shells.fetch_add(1, Ordering::SeqCst);
session.channel_failure(channel)?;
Ok(())
}
async fn subsystem_request(
&mut self,
channel: ChannelId,
name: &str,
session: &mut server::Session,
) -> Result<(), Self::Error> {
if self.unsupported || name != "sftp" {
session.channel_failure(channel)?;
session.close(channel)?;
return Ok(());
}
session.channel_success(channel)?;
let stream = self.channels.remove(&channel).unwrap().into_stream();
let directory = self.directory.clone();
let interrupts = self.interrupts.clone();
tokio::spawn(async move {
let executable = if cfg!(target_os = "macos") {
"/usr/libexec/sftp-server"
} else {
"/usr/lib/openssh/sftp-server"
};
let mut child = tokio::process::Command::new(executable)
.current_dir(directory)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.kill_on_drop(true)
.spawn()
.unwrap();
let mut input = child.stdin.take().unwrap();
let mut output = child.stdout.take().unwrap();
let (mut reader, mut writer) = tokio::io::split(stream);
let forward = async {
let mut writes = 0;
loop {
let size = reader.read_u32().await?;
if size > 256 * 1024 {
return Err::<(), _>(std::io::Error::other("invalid frame"));
}
let mut bytes = vec![0; size as usize];
reader.read_exact(&mut bytes).await?;
if bytes.first() == Some(&6) {
writes += 1;
if writes == 2
&& interrupts
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| {
n.checked_sub(1)
})
.is_ok()
{
return Err::<(), _>(std::io::Error::other("injected disconnect"));
}
}
input.write_u32(size).await?;
input.write_all(&bytes).await?;
input.flush().await?;
}
};
tokio::select! { _ = forward=>{}, _=tokio::io::copy(&mut output,&mut writer)=>{} }
child.kill().await.ok();
child.wait().await.ok();
});
Ok(())
}
}
struct Running {
port: u16,
connections: Arc<AtomicUsize>,
shells: Arc<AtomicUsize>,
interrupts: Arc<AtomicUsize>,
task: tokio::task::JoinHandle<()>,
}
impl Drop for Running {
fn drop(&mut self) {
self.task.abort();
}
}
async fn server(directory: PathBuf, interrupts: usize, unsupported: bool) -> Option<Running> {
let listener = match tokio::net::TcpListener::bind(("127.0.0.1", 0)).await {
Ok(l) => l,
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
eprintln!("loopback unavailable; run this test with local socket permission");
return None;
}
Err(e) => panic!("{e}"),
};
let port = listener.local_addr().unwrap().port();
let connections = Arc::new(AtomicUsize::new(0));
let shells = Arc::new(AtomicUsize::new(0));
let interrupts = Arc::new(AtomicUsize::new(interrupts));
let mut server = TestServer {
directory,
connections: connections.clone(),
shells: shells.clone(),
interrupts: interrupts.clone(),
unsupported,
};
let key = russh::keys::PrivateKey::random(
&mut russh::keys::key::safe_rng(),
russh::keys::Algorithm::Ed25519,
)
.unwrap();
let config = Arc::new(server::Config {
methods: russh::MethodSet::from(&[russh::MethodKind::Password][..]),
keys: vec![key],
auth_rejection_time: Duration::ZERO,
auth_rejection_time_initial: Some(Duration::ZERO),
..Default::default()
});
let task = tokio::spawn(async move {
server.run_on_socket(config, &listener).await.unwrap();
});
Some(Running {
port,
connections,
shells,
interrupts,
task,
})
}
struct Worker {
commands: async_channel::Sender<SftpCommand>,
events: async_channel::Receiver<SftpEvent>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl Drop for Worker {
fn drop(&mut self) {
let _ = self.commands.try_send(SftpCommand::Shutdown);
if let Some(thread) = self.thread.take() {
thread.join().unwrap();
}
}
}
fn worker(port: u16, data: &std::path::Path) -> Worker {
let provider = SshSftpTransportProvider::new(
SshConnector::new(
data.join("known_hosts"),
Arc::new(InMemorySecretStore::default()),
),
data.join("upload-resume"),
);
let session = Session::new(
SessionId(1),
SessionDraft::new(
"fixture",
"127.0.0.1",
port,
"tester",
AuthKind::Password,
None,
),
);
let transport = provider.create(&session);
let (commands, rx) = async_channel::unbounded();
let (tx, events) = async_channel::unbounded();
let thread = std::thread::spawn(move || transport.run(rx, tx).unwrap());
Worker {
commands,
events,
thread: Some(thread),
}
}
async fn next(worker: &Worker) -> SftpEvent {
loop {
let event = tokio::time::timeout(Duration::from_secs(25), worker.events.recv())
.await
.expect("worker timed out")
.unwrap();
if let SftpEvent::Prompt(p) = event {
let reply = match p.kind() {
ConnectionPromptKind::UnknownHost(_) => ConnectionPromptReply::TrustAndSave,
ConnectionPromptKind::Authentication(_) => {
ConnectionPromptReply::Answers(vec![ConnectionSecret::new("fixture-password")])
}
_ => panic!("unexpected trust change"),
};
worker
.commands
.send(SftpCommand::PromptReply {
request_id: p.request_id(),
reply,
})
.await
.unwrap();
} else {
return event;
}
}
}
fn runtime() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
}
#[test]
fn worker_uses_sftp_without_shell_and_reconnects_after_interrupted_write() {
runtime().block_on(async {
let temp = tempfile::tempdir().unwrap();
let remote = temp.path().join("remote");
std::fs::create_dir(&remote).unwrap();
let Some(server) = server(remote.clone(), 1, false).await else {
return;
};
let worker = worker(server.port, temp.path());
let home = loop {
match next(&worker).await {
SftpEvent::Connected { home } => break home,
SftpEvent::Disconnected(e) => panic!("connect failed: {e}"),
_ => {}
}
};
let source = temp.path().join("文件.bin");
let data = vec![42; 300_007];
std::fs::write(&source, &data).unwrap();
worker
.commands
.send(SftpCommand::Upload(
UploadRequest::new(vec![source], home).unwrap(),
))
.await
.unwrap();
let mut reconnecting = false;
loop {
match next(&worker).await {
SftpEvent::Progress(p) if p.phase() == UploadPhase::Completed => {
assert_eq!(p.succeeded(), 1);
break;
}
SftpEvent::Progress(p) if p.phase() == UploadPhase::Stopped => {
panic!("stopped unexpectedly: {p:?}")
}
SftpEvent::Progress(p) if p.phase() == UploadPhase::Reconnecting => {
reconnecting = true
}
SftpEvent::Question(q) => panic!("unexpected question: {q:?}"),
_ => {}
}
}
assert!(reconnecting);
assert_eq!(server.connections.load(Ordering::SeqCst), 2);
assert_eq!(server.shells.load(Ordering::SeqCst), 0);
assert_eq!(std::fs::read(remote.join("文件.bin")).unwrap(), data);
});
}
#[test]
fn unsupported_sftp_does_not_launch_shell_or_loop_authentication() {
runtime().block_on(async {
let temp = tempfile::tempdir().unwrap();
let Some(server) = server(temp.path().into(), 0, true).await else {
return;
};
let worker = worker(server.port, temp.path());
loop {
match next(&worker).await {
SftpEvent::Disconnected(_) => break,
SftpEvent::Connected { .. } => panic!("unsupported subsystem connected"),
_ => {}
}
}
assert_eq!(server.connections.load(Ordering::SeqCst), 1);
assert_eq!(server.shells.load(Ordering::SeqCst), 0);
});
}
#[test]
fn reconnect_exhaustion_waits_for_manual_resume_and_resets_budget() {
runtime().block_on(async {
let temp = tempfile::tempdir().unwrap();
let remote = temp.path().join("remote");
std::fs::create_dir(&remote).unwrap();
let Some(server) = server(remote.clone(), 4, false).await else {
return;
};
let worker = worker(server.port, temp.path());
let home = loop {
if let SftpEvent::Connected { home } = next(&worker).await {
break home;
}
};
let source = temp.path().join("file");
std::fs::write(&source, vec![1; 200_000]).unwrap();
worker
.commands
.send(SftpCommand::Upload(
UploadRequest::new(vec![source], home).unwrap(),
))
.await
.unwrap();
loop {
match next(&worker).await {
SftpEvent::Progress(p) if p.phase() == UploadPhase::Stopped => break,
SftpEvent::Progress(p) if p.phase() == UploadPhase::Completed => {
panic!("exhaustion should stop")
}
SftpEvent::Question(q) => panic!("unexpected question {q:?}"),
_ => {}
}
}
assert_eq!(server.connections.load(Ordering::SeqCst), 4);
// The next manual run has its own three-retry budget; interrupt once again.
server.interrupts.store(1, Ordering::SeqCst);
worker.commands.send(SftpCommand::Resume).await.unwrap();
loop {
match next(&worker).await {
SftpEvent::Progress(p) if p.phase() == UploadPhase::Completed => break,
SftpEvent::Progress(p) if p.phase() == UploadPhase::Stopped => {
panic!("manual retry failed")
}
SftpEvent::Question(q) => panic!("unexpected question {q:?}"),
_ => {}
}
}
assert_eq!(server.connections.load(Ordering::SeqCst), 6);
assert_eq!(
std::fs::read(remote.join("file")).unwrap(),
vec![1; 200_000]
);
});
}
+857
View File
@@ -0,0 +1,857 @@
use super::{
client::RemoteFs,
control::UploadControl,
journal::{Journal, SourceMetadata},
upload::UploadBatch,
*,
};
use anyhow::{Result, bail};
use std::{
cell::{Cell, RefCell},
collections::BTreeMap,
path::Path,
sync::{Arc, Mutex},
};
use tokio::sync::watch;
#[derive(Clone)]
struct Node {
metadata: FileMetadata,
data: Vec<u8>,
link: String,
}
#[derive(Clone, Copy, PartialEq)]
enum Fault {
Write(usize),
Rename(usize),
Close,
RejectRename(usize),
ChangeTargetOnClose,
RemoveTargetOnClose,
}
struct Remote {
nodes: RefCell<BTreeMap<String, Node>>,
fault: Cell<Option<Fault>>,
in_flight: Cell<usize>,
max_in_flight: Cell<usize>,
writes: Cell<usize>,
renames: Cell<usize>,
atomic: bool,
denied: RefCell<Option<String>>,
}
impl Remote {
fn new(atomic: bool) -> Self {
Self {
nodes: RefCell::new(BTreeMap::new()),
fault: Cell::new(None),
in_flight: Cell::new(0),
max_in_flight: Cell::new(0),
writes: Cell::new(0),
renames: Cell::new(0),
atomic,
denied: RefCell::new(None),
}
}
fn file(&self, path: &str, bytes: &[u8]) {
self.nodes.borrow_mut().insert(
path.into(),
Node {
metadata: FileMetadata::new(
EntryKind::File,
bytes.len() as u64,
Some(100),
Some(0o640),
),
data: bytes.to_vec(),
link: String::new(),
},
);
}
fn bytes(&self, path: &str) -> Vec<u8> {
self.nodes.borrow()[path].data.clone()
}
fn fail(&self, fault: Fault) -> Result<()> {
if self.fault.get() == Some(fault) {
self.fault.set(None);
return Err(std::io::Error::new(
std::io::ErrorKind::ConnectionReset,
"injected lost reply",
)
.into());
}
Ok(())
}
}
struct WriteGuard<'a>(&'a Cell<usize>);
impl Drop for WriteGuard<'_> {
fn drop(&mut self) {
self.0.set(self.0.get().saturating_sub(1));
}
}
impl RemoteFs for Remote {
async fn metadata(&self, path: &RemotePath) -> Result<Option<FileMetadata>> {
Ok(self
.nodes
.borrow()
.get(path.as_str())
.map(|n| n.metadata.clone()))
}
async fn open(&self, path: &RemotePath, create: bool) -> Result<String> {
if create {
if self.nodes.borrow().contains_key(path.as_str()) {
bail!("exclusive create failed");
}
self.file(path.as_str(), &[]);
} else if !self.nodes.borrow().contains_key(path.as_str()) {
bail!("file absent");
}
Ok(path.to_string())
}
async fn write(&self, handle: &str, offset: u64, bytes: Vec<u8>) -> Result<()> {
let in_flight = self.in_flight.get() + 1;
self.in_flight.set(in_flight);
self.max_in_flight
.set(self.max_in_flight.get().max(in_flight));
let _guard = WriteGuard(&self.in_flight);
tokio::task::yield_now().await;
let mut nodes = self.nodes.borrow_mut();
let node = nodes.get_mut(handle).unwrap();
let end = offset as usize + bytes.len();
node.data.resize(end.max(node.data.len()), 0);
node.data[offset as usize..end].copy_from_slice(&bytes);
node.metadata = FileMetadata::new(
EntryKind::File,
node.data.len() as u64,
node.metadata.modified(),
node.metadata.permissions(),
);
self.writes.set(self.writes.get() + 1);
self.fail(Fault::Write(self.writes.get()))
}
async fn sync(&self, _: &str) -> Result<()> {
Ok(())
}
async fn close(&self, _: &str) -> Result<()> {
if self.fault.get() == Some(Fault::ChangeTargetOnClose) {
self.fault.set(None);
self.file("/dest/file", b"external mutation");
}
if self.fault.get() == Some(Fault::RemoveTargetOnClose) {
self.fault.set(None);
self.nodes.borrow_mut().remove("/dest/file");
}
self.fail(Fault::Close)
}
async fn attributes(
&self,
handle: &str,
modified: Option<u32>,
permissions: Option<u32>,
) -> Result<()> {
let mut nodes = self.nodes.borrow_mut();
let n = nodes.get_mut(handle).unwrap();
n.metadata = FileMetadata::new(
EntryKind::File,
n.data.len() as u64,
modified,
permissions.or(Some(0o644)),
);
Ok(())
}
async fn mkdir(&self, path: &RemotePath) -> Result<()> {
if self.denied.borrow().as_deref() == Some(path.as_str()) {
bail!("permission denied");
}
self.nodes.borrow_mut().insert(
path.to_string(),
Node {
metadata: FileMetadata::new(EntryKind::Directory, 0, None, Some(0o755)),
data: vec![],
link: String::new(),
},
);
Ok(())
}
async fn symlink(&self, target: &str, path: &RemotePath) -> Result<()> {
self.nodes.borrow_mut().insert(
path.to_string(),
Node {
metadata: FileMetadata::new(EntryKind::Symlink, 0, None, None),
data: vec![],
link: target.into(),
},
);
Ok(())
}
async fn readlink(&self, path: &RemotePath) -> Result<String> {
Ok(self.nodes.borrow()[path.as_str()].link.clone())
}
async fn rename(&self, from: &RemotePath, to: &RemotePath, replace: bool) -> Result<()> {
if self.fault.get() == Some(Fault::RejectRename(self.renames.get() + 1)) {
self.fault.set(None);
bail!("publish denied");
}
let mut nodes = self.nodes.borrow_mut();
if !replace && nodes.contains_key(to.as_str()) {
bail!("target exists");
}
let node = nodes
.remove(from.as_str())
.ok_or_else(|| anyhow::anyhow!("rename source absent"))?;
nodes.insert(to.to_string(), node);
self.renames.set(self.renames.get() + 1);
self.fail(Fault::Rename(self.renames.get()))
}
async fn remove(&self, path: &RemotePath) -> Result<()> {
self.nodes.borrow_mut().remove(path.as_str());
Ok(())
}
fn atomic_replace(&self) -> bool {
self.atomic
}
}
struct Answers {
control: UploadControl,
cancel: watch::Sender<bool>,
questions: Arc<Mutex<Vec<UploadQuestionKind>>>,
progress: Arc<Mutex<Vec<UploadProgress>>>,
task: tokio::task::JoinHandle<()>,
}
impl Answers {
fn new(choices: Vec<UploadChoice>) -> Self {
let (events, receiver) = async_channel::unbounded();
let (answers, rx) = async_channel::unbounded();
let (cancel, watch) = watch::channel(false);
let questions = Arc::new(Mutex::new(vec![]));
let seen_questions = questions.clone();
let progress = Arc::new(Mutex::new(vec![]));
let seen_progress = progress.clone();
let task = tokio::spawn(async move {
let mut choices = choices.into_iter();
while let Ok(event) = receiver.recv().await {
match event {
SftpEvent::Question(q) => {
seen_questions.lock().unwrap().push(q.kind());
let _ = answers
.send((
q.id(),
UploadAnswer::new(
choices.next().unwrap_or(UploadChoice::Cancel),
false,
),
))
.await;
}
SftpEvent::Progress(snapshot) => {
seen_progress.lock().unwrap().push(snapshot);
}
_ => {}
}
}
});
Self {
control: UploadControl {
events,
cancel: watch,
answers: rx,
},
cancel,
questions,
progress,
task,
}
}
}
impl Drop for Answers {
fn drop(&mut self) {
self.task.abort();
}
}
fn runtime() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
}
async fn batch(source: &Path, journal: Journal, control: &UploadControl) -> UploadBatch {
let request =
UploadRequest::new(vec![source.into()], RemotePath::new("/dest").unwrap()).unwrap();
UploadBatch::scan(
&request,
&format!("test@{}", source.display()),
"host-key",
journal,
control,
)
.await
.unwrap()
}
async fn run(
batch: &mut UploadBatch,
remote: &impl RemoteFs,
control: &UploadControl,
) -> Result<()> {
tokio::time::timeout(
std::time::Duration::from_secs(10),
batch.run(remote, control),
)
.await
.expect("upload did not finish")
}
#[test]
fn recursive_upload_deduplicates_sources_and_preserves_empty_directories_and_links() {
runtime().block_on(async {
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let root = tmp.path().join("中文 目录");
std::fs::create_dir_all(root.join("空/嵌套")).unwrap();
std::fs::write(root.join("零 字节"), []).unwrap();
let data = vec![47; 2 * 1024 * 1024 + 13];
std::fs::write(root.join("large.bin"), &data).unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink("../不存在", root.join("链接")).unwrap();
let answers = Answers::new(vec![]);
let request = UploadRequest::new(
vec![root.clone(), root.join("large.bin"), root.clone()],
RemotePath::new("/dest").unwrap(),
)
.unwrap();
let mut batch = UploadBatch::scan(
&request,
"recursive",
"key",
Journal::new(tmp.path().join("journal")),
&answers.control,
)
.await
.unwrap();
let remote = Remote::new(true);
run(&mut batch, &remote, &answers.control).await.unwrap();
assert_eq!(remote.bytes("/dest/中文 目录/large.bin"), data);
assert_eq!(remote.bytes("/dest/中文 目录/零 字节"), Vec::<u8>::new());
assert!(
remote
.metadata(&RemotePath::new("/dest/中文 目录/空/嵌套").unwrap())
.await
.unwrap()
.is_some()
);
#[cfg(unix)]
assert_eq!(
remote
.readlink(&RemotePath::new("/dest/中文 目录/链接").unwrap())
.await
.unwrap(),
"../不存在"
);
assert_eq!(batch.progress.failed(), 0);
assert!(answers.questions.lock().unwrap().is_empty());
assert!(
std::fs::read_dir(tmp.path().join("journal"))
.unwrap()
.next()
.is_none()
);
});
}
#[test]
fn winscp_style_upload_finishes_without_a_verification_phase() {
runtime().block_on(async {
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let source = tmp.path().join("file");
let data = vec![73; 2 * 1024 * 1024 + 17];
std::fs::write(&source, &data).unwrap();
let answers = Answers::new(vec![]);
let mut batch = batch(
&source,
Journal::new(tmp.path().join("journal")),
&answers.control,
)
.await;
let remote = Remote::new(true);
run(&mut batch, &remote, &answers.control).await.unwrap();
tokio::task::yield_now().await;
assert_eq!(remote.bytes("/dest/file"), data);
assert!(remote.max_in_flight.get() > 1);
assert_eq!(batch.progress.succeeded(), 1);
let progress = answers.progress.lock().unwrap();
assert!(progress.iter().all(|snapshot| matches!(
snapshot.phase(),
UploadPhase::Uploading | UploadPhase::Completed
)));
});
}
#[test]
fn resume_uses_remote_filepart_size_and_preserves_old_target() {
runtime().block_on(async {
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let source = tmp.path().join("file");
let data = vec![71; 150_000];
std::fs::write(&source, &data).unwrap();
let journal = Journal::new(tmp.path().join("journal"));
let remote = Remote::new(false);
remote.file("/dest/file", b"old file intact");
remote.fault.set(Some(Fault::Write(2)));
let answers = Answers::new(vec![UploadChoice::Overwrite]);
let mut first = batch(&source, journal.clone(), &answers.control).await;
assert!(super::client::is_network_error(
&run(&mut first, &remote, &answers.control)
.await
.unwrap_err()
));
assert_eq!(remote.bytes("/dest/file"), b"old file intact");
let endpoint = format!("test@{}", source.display());
let target = RemotePath::new("/dest/file").unwrap();
let record = journal
.load(&endpoint, &source, &target)
.await
.unwrap()
.unwrap();
remote.file(record.temporary.as_str(), &data[..65_536]);
let answers = Answers::new(vec![UploadChoice::Resume]);
let mut second = batch(&source, journal.clone(), &answers.control).await;
run(&mut second, &remote, &answers.control).await.unwrap();
assert_eq!(remote.bytes("/dest/file"), data);
assert!(
journal
.load(&endpoint, &source, &target)
.await
.unwrap()
.is_none()
);
assert_eq!(
remote
.metadata(&target)
.await
.unwrap()
.unwrap()
.permissions(),
Some(0o640)
);
assert_eq!(second.progress.succeeded(), 1);
});
}
#[test]
fn filepart_larger_than_source_requires_explicit_restart() {
runtime().block_on(async {
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let source = tmp.path().join("file");
std::fs::write(&source, vec![1; 100_000]).unwrap();
let journal = Journal::new(tmp.path().join("journal"));
let remote = Remote::new(true);
remote.fault.set(Some(Fault::Write(2)));
let answers = Answers::new(vec![]);
let mut first = batch(&source, journal.clone(), &answers.control).await;
assert!(run(&mut first, &remote, &answers.control).await.is_err());
std::fs::write(&source, vec![2; 32_000]).unwrap();
let target = RemotePath::new("/dest/file").unwrap();
let answers = Answers::new(vec![UploadChoice::Resume, UploadChoice::Skip]);
let mut next = batch(&source, journal, &answers.control).await;
run(&mut next, &remote, &answers.control).await.unwrap();
assert!(remote.metadata(&target).await.unwrap().is_none());
assert_eq!(
*answers.questions.lock().unwrap(),
vec![
UploadQuestionKind::Resume,
UploadQuestionKind::InvalidResume
]
);
assert_eq!(next.progress.skipped(), 1);
});
}
#[test]
fn winscp_style_resume_does_not_prevalidate_the_source_version() {
runtime().block_on(async {
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let source = tmp.path().join("file");
let data = vec![1; 100_000];
std::fs::write(&source, &data).unwrap();
let journal = Journal::new(tmp.path().join("journal"));
let remote = Remote::new(true);
remote.fault.set(Some(Fault::Write(2)));
let answers = Answers::new(vec![]);
let mut first = batch(&source, journal.clone(), &answers.control).await;
assert!(run(&mut first, &remote, &answers.control).await.is_err());
let endpoint = format!("test@{}", source.display());
let target = RemotePath::new("/dest/file").unwrap();
let record = journal
.load(&endpoint, &source, &target)
.await
.unwrap()
.unwrap();
let old_prefix = remote.bytes(record.temporary.as_str());
std::fs::write(&source, vec![2; 100_000]).unwrap();
let answers = Answers::new(vec![UploadChoice::Resume]);
let mut resumed = batch(&source, journal, &answers.control).await;
run(&mut resumed, &remote, &answers.control).await.unwrap();
let mut expected = vec![2; 100_000];
expected[..old_prefix.len()].copy_from_slice(&old_prefix);
assert_eq!(remote.bytes("/dest/file"), expected);
assert_eq!(
*answers.questions.lock().unwrap(),
vec![UploadQuestionKind::Resume]
);
assert_eq!(resumed.progress.succeeded(), 1);
});
}
#[test]
fn winscp_style_resume_detects_filepart_without_a_local_record() {
runtime().block_on(async {
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let source = tmp.path().join("file");
let data = vec![3; 100_000];
std::fs::write(&source, &data).unwrap();
let remote = Remote::new(true);
remote.file("/dest/file.filepart", &data[..32_768]);
let answers = Answers::new(vec![UploadChoice::Resume]);
let mut upload = batch(
&source,
Journal::new(tmp.path().join("journal")),
&answers.control,
)
.await;
run(&mut upload, &remote, &answers.control).await.unwrap();
assert_eq!(remote.bytes("/dest/file"), data);
assert_eq!(
*answers.questions.lock().unwrap(),
vec![UploadQuestionKind::Resume]
);
assert_eq!(upload.progress.succeeded(), 1);
});
}
#[test]
fn lost_replies_at_backup_publish_and_close_are_reconciled() {
runtime().block_on(async {
for (atomic, fault) in [
(true, Fault::Rename(1)),
(false, Fault::Rename(1)),
(false, Fault::Rename(2)),
(false, Fault::Close),
] {
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let source = tmp.path().join("file");
std::fs::write(&source, b"new contents").unwrap();
let journal = Journal::new(tmp.path().join("journal"));
let remote = Remote::new(atomic);
remote.file("/dest/file", b"original");
remote.fault.set(Some(fault));
let answers = Answers::new(vec![UploadChoice::Overwrite]);
let mut first = batch(&source, journal.clone(), &answers.control).await;
assert!(run(&mut first, &remote, &answers.control).await.is_err());
let answers = Answers::new(vec![UploadChoice::Resume]);
let mut resumed = batch(&source, journal.clone(), &answers.control).await;
run(&mut resumed, &remote, &answers.control).await.unwrap();
assert_eq!(remote.bytes("/dest/file"), b"new contents");
assert_eq!(remote.nodes.borrow().len(), 1);
assert_eq!(resumed.progress.succeeded(), 1);
}
});
}
#[test]
fn cancellation_retains_journal_and_skip_does_not_overwrite() {
runtime().block_on(async {
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let source = tmp.path().join("file");
std::fs::write(&source, b"new").unwrap();
let remote = Remote::new(true);
remote.file("/dest/file", b"old");
let answers = Answers::new(vec![UploadChoice::Skip]);
let journal = Journal::new(tmp.path().join("journal"));
let mut skipped = batch(&source, journal.clone(), &answers.control).await;
run(&mut skipped, &remote, &answers.control).await.unwrap();
assert_eq!(remote.bytes("/dest/file"), b"old");
assert_eq!(skipped.progress.skipped(), 1);
let answers = Answers::new(vec![UploadChoice::Overwrite]);
let mut canceled = batch(&source, journal, &answers.control).await;
answers.cancel.send_replace(true);
assert!(
run(&mut canceled, &remote, &answers.control)
.await
.unwrap_err()
.is::<super::control::Cancelled>()
);
assert_eq!(remote.bytes("/dest/file"), b"old");
});
}
#[test]
fn directory_permission_error_is_reported_as_failure() {
runtime().block_on(async {
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let source = tmp.path().join("blocked");
std::fs::create_dir(&source).unwrap();
let remote = Remote::new(true);
*remote.denied.borrow_mut() = Some("/dest/blocked".into());
let answers = Answers::new(vec![UploadChoice::Skip]);
let mut batch = batch(
&source,
Journal::new(tmp.path().join("journal")),
&answers.control,
)
.await;
run(&mut batch, &remote, &answers.control).await.unwrap();
assert_eq!(batch.progress.failed(), 1);
assert_eq!(batch.progress.succeeded(), 0);
assert!(batch.progress.details()[0].contains("permission denied"));
});
}
#[test]
fn paths_and_journals_reject_unsafe_identity() {
runtime().block_on(async {
assert!(RemotePath::new("bad\0name").is_err());
assert!(RemotePath::new("/dest").unwrap().join("../file").is_err());
assert_eq!(
RemotePath::new("/目录\\name/file")
.unwrap()
.parent()
.as_str(),
"/目录\\name"
);
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let source = tmp.path().join("file");
std::fs::write(&source, b"data").unwrap();
let source_metadata = SourceMetadata::read(&source).await.unwrap();
assert_eq!(source_metadata.size, 4);
let mut record = super::journal::ResumeRecord::new(
"endpoint",
"key",
&source,
RemotePath::new("/dest/file").unwrap(),
None,
)
.unwrap();
record.source_metadata = Some(source_metadata);
assert_eq!(record.version, 2);
assert_eq!(record.temporary.as_str(), "/dest/file.filepart");
assert!(
record
.validate("endpoint", "changed key", &source, &record.target)
.is_err()
);
record.temporary = RemotePath::new("/other/.shellr-evil.filepart").unwrap();
assert!(
record
.validate("endpoint", "key", &source, &record.target)
.is_err()
);
});
}
#[cfg(target_os = "linux")]
#[test]
fn local_listing_rejects_unrepresentable_names() {
use std::os::unix::ffi::OsStringExt as _;
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
std::fs::write(
tmp.path().join(std::ffi::OsString::from_vec(vec![0xff])),
[],
)
.unwrap();
assert!(SystemLocalDirectoryProvider.list(tmp.path()).is_err());
}
#[cfg(unix)]
#[test]
fn openssh_protocol_uploads_offsets_links_metadata_and_replaces_readonly_targets() {
runtime().block_on(async {
use std::os::unix::fs::PermissionsExt as _;
let temp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let dest = temp.path().join("remote");
let source = temp.path().join("中文 目录");
std::fs::create_dir_all(source.join("空目录/嵌套")).unwrap();
std::fs::create_dir(&dest).unwrap();
let data = vec![28; 1024 * 1024 + 7];
std::fs::write(source.join("文件.bin"), &data).unwrap();
std::fs::write(source.join("零字节"), []).unwrap();
std::os::unix::fs::symlink("文件.bin", source.join("链接")).unwrap();
let (client, mut process) = super::client::SftpClient::local_test_server(&dest)
.await
.unwrap();
let target = client
.canonicalize(&RemotePath::new(".").unwrap())
.await
.unwrap();
let answers = Answers::new(vec![]);
let request = UploadRequest::new(vec![source.clone()], target.clone()).unwrap();
let journal = Journal::new(temp.path().join("journal"));
let mut upload = UploadBatch::scan(
&request,
"local-protocol",
client.fingerprint(),
journal.clone(),
&answers.control,
)
.await
.unwrap();
run(&mut upload, &client, &answers.control).await.unwrap();
assert_eq!(
std::fs::read(dest.join("中文 目录/文件.bin")).unwrap(),
data
);
assert!(dest.join("中文 目录/空目录/嵌套").is_dir());
assert_eq!(
std::fs::read_link(dest.join("中文 目录/链接")).unwrap(),
Path::new("文件.bin")
);
assert_eq!(
std::fs::metadata(dest.join("中文 目录/零字节"))
.unwrap()
.len(),
0
);
let listing = client
.list(&target.join("中文 目录").unwrap())
.await
.unwrap();
assert_eq!(listing.entries().len(), 4);
let file = source.join("文件.bin");
let dest_file = dest.join("中文 目录/文件.bin");
std::fs::set_permissions(&dest_file, std::fs::Permissions::from_mode(0o400)).unwrap();
std::fs::write(&file, b"replaced").unwrap();
let request =
UploadRequest::new(vec![file.clone()], target.join("中文 目录").unwrap()).unwrap();
let answers = Answers::new(vec![UploadChoice::Overwrite]);
let mut upload = UploadBatch::scan(
&request,
"local-protocol",
client.fingerprint(),
journal,
&answers.control,
)
.await
.unwrap();
run(&mut upload, &client, &answers.control).await.unwrap();
assert_eq!(std::fs::read(&dest_file).unwrap(), b"replaced");
assert_eq!(
std::fs::metadata(&dest_file).unwrap().permissions().mode() & 0o777,
0o400
);
let local_time = SourceMetadata::read(&file).await.unwrap().modified_ns / 1_000_000_000;
assert_eq!(
u128::from(
client
.metadata(&target.join("中文 目录").unwrap().join("文件.bin").unwrap())
.await
.unwrap()
.unwrap()
.modified()
.unwrap()
),
local_time
);
drop(client);
process.kill().await.ok();
process.wait().await.unwrap();
});
}
#[test]
fn failed_compatibility_publish_restores_old_target_and_reports_failure() {
runtime().block_on(async {
let temp = tempfile::tempdir().unwrap();
let source = temp.path().join("file");
std::fs::write(&source, b"new").unwrap();
let remote = Remote::new(false);
remote.file("/dest/file", b"original");
remote.fault.set(Some(Fault::RejectRename(2)));
let answers = Answers::new(vec![UploadChoice::Overwrite, UploadChoice::Skip]);
let mut upload = batch(
&source,
Journal::new(temp.path().join("journal")),
&answers.control,
)
.await;
run(&mut upload, &remote, &answers.control).await.unwrap();
assert_eq!(remote.bytes("/dest/file"), b"original");
assert_eq!(upload.progress.failed(), 1);
assert_eq!(upload.progress.succeeded(), 0);
assert!(
remote
.nodes
.borrow()
.keys()
.any(|p| p.ends_with(".filepart"))
);
assert!(!remote.nodes.borrow().keys().any(|p| p.ends_with(".backup")));
});
}
#[test]
fn target_changed_during_upload_requires_another_conflict_answer() {
runtime().block_on(async {
let temp = tempfile::tempdir().unwrap();
let source = temp.path().join("file");
std::fs::write(&source, b"new").unwrap();
let remote = Remote::new(true);
remote.file("/dest/file", b"original");
remote.fault.set(Some(Fault::ChangeTargetOnClose));
let answers = Answers::new(vec![UploadChoice::Overwrite, UploadChoice::Skip]);
let mut upload = batch(
&source,
Journal::new(temp.path().join("journal")),
&answers.control,
)
.await;
run(&mut upload, &remote, &answers.control).await.unwrap();
assert_eq!(remote.bytes("/dest/file"), b"external mutation");
assert_eq!(upload.progress.skipped(), 1);
assert_eq!(
*answers.questions.lock().unwrap(),
vec![UploadQuestionKind::Conflict, UploadQuestionKind::Conflict]
);
assert_eq!(remote.nodes.borrow().len(), 1);
});
}
#[test]
fn cancel_then_reset_allows_new_commands_without_false_cancellation() {
runtime().block_on(async {
let answers = Answers::new(vec![]);
answers.cancel.send_replace(false);
assert_eq!(answers.control.run(async { Ok(42) }).await.unwrap(), 42);
answers.cancel.send_replace(true);
assert!(answers.control.run(async { Ok(42) }).await.is_err());
answers.cancel.send_replace(false);
assert_eq!(answers.control.run(async { Ok(43) }).await.unwrap(), 43);
});
}
#[test]
fn target_removed_during_upload_still_requires_confirmation() {
runtime().block_on(async {
let temp = tempfile::tempdir().unwrap();
let source = temp.path().join("file");
std::fs::write(&source, b"new").unwrap();
let remote = Remote::new(true);
remote.file("/dest/file", b"old");
remote.fault.set(Some(Fault::RemoveTargetOnClose));
let answers = Answers::new(vec![UploadChoice::Overwrite, UploadChoice::Skip]);
let mut upload = batch(
&source,
Journal::new(temp.path().join("journal")),
&answers.control,
)
.await;
run(&mut upload, &remote, &answers.control).await.unwrap();
assert!(remote.nodes.borrow().is_empty());
assert_eq!(
*answers.questions.lock().unwrap(),
vec![UploadQuestionKind::Conflict, UploadQuestionKind::Conflict]
);
assert_eq!(upload.progress.skipped(), 1);
});
}
+61
View File
@@ -0,0 +1,61 @@
use super::{
DirectoryListing, RemotePath, UploadAnswer, UploadProgress, UploadQuestion, UploadRequest,
};
use crate::{
connection::{ConnectionPrompt, ConnectionPromptReply},
session::Session,
};
use anyhow::Result;
use async_channel::{Receiver, Sender};
use std::sync::Arc;
#[derive(Debug)]
pub enum SftpCommand {
List {
request_id: u64,
path: RemotePath,
},
Upload(UploadRequest),
PromptReply {
request_id: u64,
reply: ConnectionPromptReply,
},
Answer {
request_id: u64,
answer: UploadAnswer,
},
Cancel,
Resume,
Discard,
Disconnect,
Shutdown,
}
#[derive(Clone, Debug)]
pub enum SftpEvent {
Connecting,
Connected {
home: RemotePath,
},
Disconnected(String),
Prompt(ConnectionPrompt),
Listed {
request_id: u64,
result: Result<DirectoryListing, String>,
},
Progress(UploadProgress),
Question(UploadQuestion),
Notice(String),
Idle,
}
/// One worker lifetime; the caller runs it on its own thread.
pub trait SftpTransport: Send + 'static {
fn run(
self: Box<Self>,
commands: Receiver<SftpCommand>,
events: Sender<SftpEvent>,
) -> Result<()>;
}
pub trait SftpTransportProvider: Send + Sync + 'static {
fn create(&self, session: &Session) -> Box<dyn SftpTransport>;
}
pub type SharedSftpTransportProvider = Arc<dyn SftpTransportProvider>;
+723
View File
@@ -0,0 +1,723 @@
use super::{
EntryKind, FileMetadata, RemotePath, SftpEvent, UploadChoice, UploadPhase, UploadProgress,
UploadQuestionKind, UploadRequest,
client::RemoteFs,
control::UploadControl,
journal::{Journal, PublishPhase, ResumeRecord, SourceMetadata},
model::local_metadata,
};
use anyhow::{Result, anyhow, bail};
use futures::{StreamExt as _, stream::FuturesUnordered};
use std::{
collections::HashSet,
path::PathBuf,
time::{Duration, Instant},
};
use tokio::io::{AsyncReadExt as _, AsyncSeekExt as _};
const CHUNK: usize = 32 * 1024;
const MAX_IN_FLIGHT: usize = 16;
#[derive(Clone)]
struct UploadItem {
source: PathBuf,
target: RemotePath,
metadata: FileMetadata,
error: Option<String>,
}
pub(crate) struct UploadBatch {
items: Vec<UploadItem>,
cursor: usize,
pub progress: UploadProgress,
endpoint: String,
host_key: String,
journal: Journal,
all_conflicts: Option<UploadChoice>,
approved_resumes: HashSet<RemotePath>,
blocked_directories: Vec<RemotePath>,
started: Instant,
sent_bytes: u64,
completed_bytes: u64,
last_progress: Instant,
}
impl UploadBatch {
pub async fn scan(
request: &UploadRequest,
endpoint: &str,
host_key: &str,
journal: Journal,
control: &UploadControl,
) -> Result<Self> {
let mut sources = Vec::new();
for source in request.sources() {
let absolute = std::path::absolute(source)?;
// Resolve parent aliases while preserving the leaf symlink itself.
let path = if let (Some(parent), Some(name)) = (absolute.parent(), absolute.file_name())
{
match tokio::fs::canonicalize(parent).await {
Ok(parent) => parent.join(name),
Err(_) => absolute,
}
} else {
absolute
};
sources.push(path);
}
sources.sort();
sources.dedup();
let mut roots: Vec<PathBuf> = Vec::new();
for source in sources {
if !roots.iter().any(|parent| source.starts_with(parent)) {
roots.push(source);
}
}
let mut stack = Vec::new();
for source in roots.into_iter().rev() {
let name = source
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| anyhow!("来源文件名不是有效的 UTF-8"))?;
let target = request.destination().join(name)?;
stack.push((source, target));
}
let mut items = Vec::new();
let mut total_bytes: u64 = 0;
while let Some((source, target)) = stack.pop() {
control.check()?;
let metadata = match tokio::fs::symlink_metadata(&source).await {
Ok(metadata) => local_metadata(&metadata),
Err(error) => {
items.push(UploadItem {
source,
target,
metadata: FileMetadata::new(EntryKind::Other, 0, None, None),
error: Some(error.to_string()),
});
continue;
}
};
let mut scan_error = None;
if metadata.kind() == EntryKind::Directory {
let read: Result<Vec<(PathBuf, RemotePath)>> = async {
let mut dir = tokio::fs::read_dir(&source).await?;
let mut children = Vec::new();
while let Some(entry) = dir.next_entry().await? {
let name = entry
.file_name()
.into_string()
.map_err(|_| anyhow!("目录包含无法用 UTF-8 表示的文件名"))?;
children.push((entry.path(), target.join(&name)?));
}
children.sort_by(|a, b| a.0.cmp(&b.0));
Ok(children)
}
.await;
match read {
Ok(children) => stack.extend(children.into_iter().rev()),
Err(e) => scan_error = Some(e.to_string()),
}
} else if metadata.kind() == EntryKind::File {
total_bytes = total_bytes.saturating_add(metadata.size());
}
items.push(UploadItem {
source,
target,
metadata,
error: scan_error,
});
}
let progress = UploadProgress {
total: items.len(),
total_bytes,
..UploadProgress::default()
};
Ok(Self {
items,
cursor: 0,
progress,
endpoint: endpoint.into(),
host_key: host_key.into(),
journal,
all_conflicts: None,
approved_resumes: HashSet::new(),
blocked_directories: Vec::new(),
started: Instant::now(),
sent_bytes: 0,
completed_bytes: 0,
last_progress: Instant::now(),
})
}
pub fn verify_host(&self, fingerprint: &str) -> Result<()> {
if self.host_key != fingerprint {
bail!("服务器主机指纹已改变,已停止续传");
}
Ok(())
}
pub fn is_complete(&self) -> bool {
self.cursor >= self.items.len()
}
pub fn emit(&self, control: &UploadControl) {
let _ = control
.events
.try_send(SftpEvent::Progress(self.progress.clone()));
}
pub fn phase(&mut self, phase: UploadPhase, control: &UploadControl) {
self.progress.phase = phase;
self.emit(control);
}
pub async fn run<F: RemoteFs>(&mut self, fs: &F, control: &UploadControl) -> Result<()> {
self.phase(UploadPhase::Uploading, control);
while self.cursor < self.items.len() {
control.check()?;
let item = self.items[self.cursor].clone();
self.progress.current = item.target.to_string();
self.emit(control);
let _target_guard = TargetGuard::acquire(&self.endpoint, &item.target)?;
let result = if self
.blocked_directories
.iter()
.any(|p| item.target.as_str().starts_with(&format!("{p}/")))
{
Err(anyhow!("父目录未创建,无法上传"))
} else if let Some(error) = &item.error {
Err(anyhow!("{error}"))
} else {
self.upload_item(fs, &item, control).await
};
match result {
Ok(true) => {
self.progress.succeeded += 1;
self.progress
.details
.push(format!("已上传:{}", item.target));
if item.metadata.kind() == EntryKind::File {
self.completed_bytes =
self.completed_bytes.saturating_add(item.metadata.size());
}
}
Ok(false) => {
self.progress.skipped += 1;
self.progress
.details
.push(format!("已跳过:{}", item.target));
}
Err(error)
if error.is::<super::control::Cancelled>()
|| super::client::is_network_error(&error) =>
{
return Err(error);
}
Err(error) => {
self.phase(UploadPhase::Waiting, control);
let answer = control
.ask(
UploadQuestionKind::Error,
item.target.as_str(),
&format!("无法上传:{error:#}"),
)
.await?;
if answer.choice() == UploadChoice::Retry {
if item.error.is_some() {
let request = UploadRequest::new(
vec![item.source.clone()],
item.target.parent(),
)?;
let scanned = Self::scan(
&request,
&self.endpoint,
&self.host_key,
self.journal.clone(),
control,
)
.await?;
self.progress.total =
self.progress.total.saturating_sub(1) + scanned.items.len();
self.progress.total_bytes = self
.progress
.total_bytes
.saturating_sub(item.metadata.size())
+ scanned.progress.total_bytes;
self.items.splice(self.cursor..=self.cursor, scanned.items);
}
continue;
}
self.progress.failed += 1;
self.progress
.details
.push(format!("失败:{}:{error:#}", item.target));
if item.metadata.kind() == EntryKind::Directory {
self.blocked_directories.push(item.target.clone());
}
}
}
self.cursor += 1;
self.progress.completed_bytes = self.completed_bytes;
self.phase(UploadPhase::Uploading, control);
}
self.phase(UploadPhase::Completed, control);
Ok(())
}
async fn approve(
&mut self,
target: &RemotePath,
original: &Option<FileMetadata>,
changed: bool,
control: &UploadControl,
) -> Result<bool> {
if original.is_none() && !changed {
return Ok(true);
}
if original
.as_ref()
.is_some_and(|m| m.kind() == EntryKind::Directory)
{
bail!("目录与文件类型冲突,不会删除远程目录");
}
if !changed && let Some(choice) = self.all_conflicts {
return Ok(choice == UploadChoice::Overwrite);
}
self.phase(UploadPhase::Waiting, control);
let message = match original {
Some(metadata) => format!(
"{}远程项目已存在({} 字节,修改时间 {:?})。覆盖会替换这个文件或链接。",
if changed {
"目标在上传期间发生变化。"
} else {
""
},
metadata.size(),
metadata.modified()
),
None => "原目标在上传期间已被删除或移走。是否继续发布此文件?".into(),
};
let answer = control
.ask(UploadQuestionKind::Conflict, target.as_str(), &message)
.await?;
if answer.apply_to_all() && !changed {
self.all_conflicts = Some(answer.choice());
}
self.phase(UploadPhase::Uploading, control);
Ok(answer.choice() == UploadChoice::Overwrite)
}
async fn upload_item<F: RemoteFs>(
&mut self,
fs: &F,
item: &UploadItem,
control: &UploadControl,
) -> Result<bool> {
let existing_record = self
.journal
.load(&self.endpoint, &item.source, &item.target)
.await?;
if item.metadata.kind() == EntryKind::Directory {
if let Some(record) = &existing_record {
record.validate(&self.endpoint, &self.host_key, &item.source, &item.target)?;
self.phase(UploadPhase::Waiting, control);
let answer = control
.ask(
UploadQuestionKind::InvalidResume,
item.target.as_str(),
"来源已变为目录,请丢弃旧文件进度后重新上传,或跳过。",
)
.await?;
if answer.choice() != UploadChoice::Restart {
return Ok(false);
}
self.discard_record(fs, record).await?;
}
return match control.run(fs.metadata(&item.target)).await? {
Some(metadata) if metadata.kind() == EntryKind::Directory => Ok(true),
Some(_) => bail!("目标已存在且不是目录,不会跟随或替换链接"),
None => {
control.run(fs.mkdir(&item.target)).await?;
Ok(true)
}
};
}
if item.metadata.kind() == EntryKind::Other {
bail!("不支持上传设备、套接字或其他特殊文件");
}
let mut record = if let Some(record) = existing_record {
record.validate(&self.endpoint, &self.host_key, &item.source, &item.target)?;
if !self.approved_resumes.contains(&item.target) {
self.phase(UploadPhase::Waiting, control);
let answer = control
.ask(
UploadQuestionKind::Resume,
item.target.as_str(),
"发现未完成的 .filepart 文件。续传会按它的现有大小跳过本地文件前缀,请确认本地文件仍是同一版本。",
)
.await?;
match answer.choice() {
UploadChoice::Skip => return Ok(false),
UploadChoice::Restart => {
self.discard_record(fs, &record).await?;
return Box::pin(self.upload_item(fs, item, control)).await;
}
_ => {}
}
}
record
} else {
let original = control.run(fs.metadata(&item.target)).await?;
if !self
.approve(&item.target, &original, false, control)
.await?
{
return Ok(false);
}
let mut record = ResumeRecord::new(
&self.endpoint,
&self.host_key,
&item.source,
item.target.clone(),
original,
)?;
if item.metadata.kind() == EntryKind::File {
record.source_metadata =
Some(control.run(SourceMetadata::read(&item.source)).await?);
if control.run(fs.metadata(&record.temporary)).await?.is_some() {
self.phase(UploadPhase::Waiting, control);
let answer = control
.ask(
UploadQuestionKind::Resume,
item.target.as_str(),
"发现未完成的 .filepart 文件。续传会按它的现有大小跳过本地文件前缀,请确认本地文件仍是同一版本。",
)
.await?;
match answer.choice() {
UploadChoice::Skip => return Ok(false),
UploadChoice::Restart => {
control.run(fs.remove(&record.temporary)).await?;
}
_ => {}
}
self.phase(UploadPhase::Uploading, control);
}
} else {
record.link_target = Some(
tokio::fs::read_link(&item.source)
.await?
.into_os_string()
.into_string()
.map_err(|_| anyhow!("链接目标不是有效的 UTF-8"))?,
);
}
self.journal.save(&record).await?;
record
};
self.approved_resumes.insert(item.target.clone());
if record.phase != PublishPhase::Writing {
return self.recover_publish(fs, &mut record, control).await;
}
let uploaded = if item.metadata.kind() == EntryKind::File {
record.source_metadata = Some(control.run(SourceMetadata::read(&item.source)).await?);
self.journal.save(&record).await?;
self.upload_file(fs, item, &mut record, control).await
} else {
let target = record
.link_target
.as_ref()
.ok_or_else(|| anyhow!("续传记录中缺少链接目标"))?;
if let Some(existing) = control.run(fs.metadata(&record.temporary)).await? {
if existing.kind() != EntryKind::Symlink
|| control.run(fs.readlink(&record.temporary)).await? != *target
{
bail!("临时链接已变化,无法继续上传");
}
} else {
control.run(fs.symlink(target, &record.temporary)).await?;
}
Ok(())
};
if let Err(error) = uploaded {
if error.is::<InvalidResume>() {
self.phase(UploadPhase::Waiting, control);
let answer = control
.ask(
UploadQuestionKind::InvalidResume,
item.target.as_str(),
&error.to_string(),
)
.await?;
if answer.choice() == UploadChoice::Restart {
self.discard_record(fs, &record).await?;
return Box::pin(self.upload_item(fs, item, control)).await;
}
return Ok(false);
}
return Err(error);
}
record.phase = PublishPhase::Ready;
self.journal.save(&record).await?;
self.recover_publish(fs, &mut record, control).await
}
async fn upload_file<F: RemoteFs>(
&mut self,
fs: &F,
item: &UploadItem,
record: &mut ResumeRecord,
control: &UploadControl,
) -> Result<()> {
let source_metadata = record
.source_metadata
.clone()
.ok_or_else(|| anyhow!("续传记录中缺少来源文件信息"))?;
let remote = control.run(fs.metadata(&record.temporary)).await?;
if remote.as_ref().is_some_and(|m| m.kind() != EntryKind::File) {
return Err(InvalidResume("远端临时文件类型已变化").into());
}
let resume_offset = remote.as_ref().map(FileMetadata::size).unwrap_or(0);
if resume_offset > source_metadata.size {
return Err(InvalidResume("远端续传偏移大于本地文件").into());
}
let handle = control
.run(fs.open(&record.temporary, remote.is_none()))
.await?;
let result: Result<()> = async {
let mut local = tokio::fs::File::open(&item.source).await?;
local.seek(std::io::SeekFrom::Start(resume_offset)).await?;
self.phase(UploadPhase::Uploading, control);
let mut next_offset = resume_offset;
let mut uploaded = resume_offset;
let mut writes = FuturesUnordered::new();
while next_offset < source_metadata.size || !writes.is_empty() {
while next_offset < source_metadata.size && writes.len() < MAX_IN_FLIGHT {
control.check()?;
let length = (source_metadata.size - next_offset).min(CHUNK as u64) as usize;
let mut bytes = vec![0; length];
local.read_exact(&mut bytes).await?;
let write_offset = next_offset;
next_offset += length as u64;
let handle = handle.as_str();
writes.push(async move {
control.run(fs.write(handle, write_offset, bytes)).await?;
Ok::<_, anyhow::Error>(length as u64)
});
}
let length = writes
.next()
.await
.ok_or_else(|| anyhow!("上传写入流水线意外结束"))??;
self.sent_bytes += length;
uploaded += length;
self.progress.completed_bytes = self.completed_bytes.saturating_add(uploaded);
self.progress.bytes_per_second = (self.sent_bytes as f64
/ self.started.elapsed().as_secs_f64().max(0.001))
as u64;
if self.last_progress.elapsed() >= Duration::from_millis(50) {
self.emit(control);
self.last_progress = Instant::now();
}
}
control
.run(fs.attributes(
&handle,
u32::try_from(source_metadata.modified_ns / 1_000_000_000).ok(),
record.original.as_ref().and_then(FileMetadata::permissions),
))
.await?;
control.run(fs.sync(&handle)).await?;
Ok(())
}
.await;
// Always close. After a broken connection, WinSCP-style resume uses the
// .filepart size reported by the server as the next source offset.
let close = fs.close(&handle).await;
result?;
close?;
Ok(())
}
async fn matches_expected_item<F: RemoteFs>(
&self,
fs: &F,
path: &RemotePath,
record: &ResumeRecord,
control: &UploadControl,
) -> Result<bool> {
let Some(metadata) = control.run(fs.metadata(path)).await? else {
return Ok(false);
};
if let Some(target) = &record.link_target {
return Ok(metadata.kind() == EntryKind::Symlink
&& control.run(fs.readlink(path)).await? == *target);
}
let Some(source_metadata) = &record.source_metadata else {
return Ok(false);
};
Ok(metadata.kind() == EntryKind::File && metadata.size() == source_metadata.size)
}
async fn recover_publish<F: RemoteFs>(
&mut self,
fs: &F,
record: &mut ResumeRecord,
control: &UploadControl,
) -> Result<bool> {
let temporary = control.run(fs.metadata(&record.temporary)).await?;
if temporary.is_none() {
if matches!(
record.phase,
PublishPhase::Publishing | PublishPhase::Published
) && self
.matches_expected_item(fs, &record.target, record, control)
.await?
{
self.cleanup(fs, record).await?;
return Ok(true);
}
// We cannot infer success from a lost reply. A backup may still be
// the only copy of the old file; restore only into an absent target.
if fs.metadata(&record.target).await?.is_none()
&& fs.metadata(&record.backup).await?.is_some()
{
fs.rename(&record.backup, &record.target, false).await?;
}
bail!("无法确认文件发布结果,已保留恢复文件;请检查远端目录");
}
if !self
.matches_expected_item(fs, &record.temporary, record, control)
.await?
{
bail!("临时文件大小或类型已变化,未替换目标");
}
let backup = control.run(fs.metadata(&record.backup)).await?;
let current = control.run(fs.metadata(&record.target)).await?;
if backup.is_some() {
if current.is_some() {
bail!("替换期间目标被其他操作创建,已保留目标、备份和临时文件");
}
if backup != record.original {
bail!("恢复备份已变化,停止替换");
}
record.phase = PublishPhase::BackedUp;
self.journal.save(record).await?;
} else if current != record.original {
if !self
.approve(&record.target, &current, true, control)
.await?
{
self.discard_record(fs, record).await?;
return Ok(false);
}
record.original = current;
self.journal.save(record).await?;
if let Some(source_metadata) = &record.source_metadata {
let handle = control.run(fs.open(&record.temporary, false)).await?;
let result = control
.run(fs.attributes(
&handle,
u32::try_from(source_metadata.modified_ns / 1_000_000_000).ok(),
record.original.as_ref().and_then(FileMetadata::permissions),
))
.await;
let closed = fs.close(&handle).await;
result?;
closed?;
}
}
if backup.is_none() && record.original.is_some() && !fs.atomic_replace() {
record.phase = PublishPhase::BackingUp;
self.journal.save(record).await?;
control
.run(fs.rename(&record.target, &record.backup, false))
.await?;
record.phase = PublishPhase::BackedUp;
self.journal.save(record).await?;
}
record.phase = PublishPhase::Publishing;
self.journal.save(record).await?;
let result = control
.run(fs.rename(
&record.temporary,
&record.target,
fs.atomic_replace() && record.original.is_some() && backup.is_none(),
))
.await;
if let Err(error) = result {
if !super::client::is_network_error(&error)
&& !error.is::<super::control::Cancelled>()
&& fs.metadata(&record.target).await?.is_none()
&& fs.metadata(&record.backup).await?.is_some()
{
fs.rename(&record.backup, &record.target, false).await?;
record.phase = PublishPhase::Ready;
self.journal.save(record).await?;
}
return Err(error);
}
record.phase = PublishPhase::Published;
self.journal.save(record).await?;
self.cleanup(fs, record).await?;
Ok(true)
}
async fn cleanup<F: RemoteFs>(&self, fs: &F, record: &ResumeRecord) -> Result<()> {
if let Some(backup) = fs.metadata(&record.backup).await? {
if Some(backup) != record.original {
bail!("备份已变化,未清理;上传的目标文件已发布");
}
fs.remove(&record.backup).await?;
}
self.journal.remove(record).await
}
async fn discard_record<F: RemoteFs>(&self, fs: &F, record: &ResumeRecord) -> Result<()> {
record.validate(
&self.endpoint,
&self.host_key,
&record.source,
&record.target,
)?;
if fs.metadata(&record.backup).await?.is_some() {
if fs.metadata(&record.target).await?.is_none() {
fs.rename(&record.backup, &record.target, false).await?;
} else {
bail!("存在恢复备份和目标文件,请先检查远端目录;未删除任何文件");
}
}
fs.remove(&record.temporary).await?;
self.journal.remove(record).await
}
pub async fn discard<F: RemoteFs>(&self, fs: &F) -> Result<()> {
for item in &self.items {
if let Some(record) = self
.journal
.load(&self.endpoint, &item.source, &item.target)
.await?
{
self.discard_record(fs, &record).await?;
}
}
Ok(())
}
}
#[derive(Debug)]
struct InvalidResume(&'static str);
impl std::fmt::Display for InvalidResume {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0)
}
}
impl std::error::Error for InvalidResume {}
/// Coordinate sibling sessions using the same endpoint and destination.
struct TargetGuard(String);
static ACTIVE_TARGETS: std::sync::Mutex<Option<HashSet<String>>> = std::sync::Mutex::new(None);
impl TargetGuard {
fn acquire(endpoint: &str, target: &RemotePath) -> Result<Self> {
let key = format!("{endpoint}\0{target}");
let mut active = ACTIVE_TARGETS.lock().unwrap_or_else(|e| e.into_inner());
if !active.get_or_insert_with(HashSet::new).insert(key.clone()) {
bail!("其他会话正在上传同一目标,请稍后继续上传");
}
Ok(Self(key))
}
}
impl Drop for TargetGuard {
fn drop(&mut self) {
if let Some(active) = ACTIVE_TARGETS
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_mut()
{
active.remove(&self.0);
}
}
}
+369
View File
@@ -0,0 +1,369 @@
use super::{
RemotePath, SftpCommand, SftpEvent, SftpTransport, SftpTransportProvider, UploadPhase,
client::{SftpClient, is_network_error},
control::{Cancelled, UploadControl},
journal::Journal,
upload::UploadBatch,
};
use crate::{
session::Session,
ssh::{SshConnectionConfig, SshConnector, SshPrompts},
};
use anyhow::Result;
use async_channel::{Receiver, Sender};
use std::{
path::PathBuf,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};
use tokio::sync::{RwLock, watch};
pub struct SshSftpTransportProvider {
connector: SshConnector,
resume_dir: PathBuf,
}
impl SshSftpTransportProvider {
pub fn new(connector: SshConnector, resume_dir: PathBuf) -> Self {
Self {
connector,
resume_dir,
}
}
}
impl SftpTransportProvider for SshSftpTransportProvider {
fn create(&self, session: &Session) -> Box<dyn SftpTransport> {
Box::new(SshSftpTransport {
connector: self.connector.clone(),
config: SshConnectionConfig::from(session),
journal: Journal::new(self.resume_dir.clone()),
})
}
}
struct SshSftpTransport {
connector: SshConnector,
config: SshConnectionConfig,
journal: Journal,
}
impl SftpTransport for SshSftpTransport {
fn run(
self: Box<Self>,
commands: Receiver<SftpCommand>,
events: Sender<SftpEvent>,
) -> Result<()> {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(self.run_async(commands, events))
}
}
impl SshSftpTransport {
async fn connect(
&self,
prompts: Arc<SshPrompts>,
events: &Sender<SftpEvent>,
shutdown: &watch::Receiver<bool>,
shared: &RwLock<Option<Arc<SftpClient>>>,
) -> Result<Arc<SftpClient>> {
events.send(SftpEvent::Connecting).await?;
let mut stop = shutdown.clone();
let client = tokio::select! {
result = SftpClient::connect(&self.connector, &self.config, prompts) => Arc::new(result?),
_ = stop.changed() => return Err(Cancelled.into()),
};
let home = client.canonicalize(&RemotePath::new(".")?).await?;
*shared.write().await = Some(client.clone());
events.send(SftpEvent::Connected { home }).await?;
Ok(client)
}
async fn run_async(
&self,
commands: Receiver<SftpCommand>,
events: Sender<SftpEvent>,
) -> Result<()> {
let (shutdown_tx, shutdown) = watch::channel(false);
let (cancel_tx, cancel) = watch::channel(false);
let (answers_tx, answers) = async_channel::unbounded();
let (operations_tx, operations) = async_channel::bounded(1);
let prompt_events = events.clone();
let prompts = Arc::new(SshPrompts::new(
Arc::new(move |p| prompt_events.try_send(SftpEvent::Prompt(p)).is_ok()),
shutdown.clone(),
));
let client: Arc<RwLock<Option<Arc<SftpClient>>>> = Arc::new(RwLock::new(None));
let busy = Arc::new(AtomicBool::new(false));
let router = {
let prompts = prompts.clone();
let events = events.clone();
let client = client.clone();
let busy = busy.clone();
let cancel_tx = cancel_tx.clone();
tokio::spawn(async move {
while let Ok(command) = commands.recv().await {
match command {
SftpCommand::PromptReply { request_id, reply } => {
prompts.respond(request_id, reply)
}
SftpCommand::Answer { request_id, answer } => {
let _ = answers_tx.send((request_id, answer)).await;
}
SftpCommand::Cancel => {
cancel_tx.send_replace(true);
prompts.cancel_all();
}
SftpCommand::Disconnect => {
cancel_tx.send_replace(true);
prompts.cancel_all();
*client.write().await = None;
let _ = events
.send(SftpEvent::Disconnected("SFTP 已断开".into()))
.await;
}
SftpCommand::Shutdown => break,
SftpCommand::List { request_id, path } => {
let shared = client.clone();
let connected = client.read().await.clone();
let events = events.clone();
tokio::spawn(async move {
let result = match connected {
Some(connected) => match connected.list(&path).await {
Ok(listing) => Ok(listing),
Err(error) => {
if is_network_error(&error) {
let mut current = shared.write().await;
if current
.as_ref()
.is_some_and(|c| Arc::ptr_eq(c, &connected))
{
*current = None;
let _ = events
.send(SftpEvent::Disconnected(
"SFTP 连接中断,请重新连接".into(),
))
.await;
}
}
Err(format!("无法读取目录:{error:#}"))
}
},
None => Err("SFTP 未连接,请重新连接".into()),
};
let _ = events.send(SftpEvent::Listed { request_id, result }).await;
});
}
command => {
if busy.swap(true, Ordering::AcqRel) {
let _ = events
.send(SftpEvent::Notice("已有上传批次正在处理".into()))
.await;
} else {
cancel_tx.send_replace(false);
if operations_tx.send(command).await.is_err() {
break;
}
}
}
}
}
shutdown_tx.send_replace(true);
cancel_tx.send_replace(true);
prompts.cancel_all();
})
};
let control = UploadControl {
events: events.clone(),
cancel,
answers,
};
let mut batch: Option<UploadBatch> = None;
match control
.run(self.connect(prompts.clone(), &events, &shutdown, &client))
.await
{
Ok(connected) => *client.write().await = Some(connected),
Err(error) => {
let _ = events
.send(SftpEvent::Disconnected(error.to_string()))
.await;
}
}
let mut stop = shutdown.clone();
while !*stop.borrow() {
let operation = tokio::select! { operation = operations.recv() => match operation { Ok(operation) => operation, Err(_) => break }, _ = stop.changed() => break };
if client.read().await.is_none() {
match control
.run(self.connect(prompts.clone(), &events, &shutdown, &client))
.await
{
Ok(connected) => *client.write().await = Some(connected),
Err(error) => {
events
.send(SftpEvent::Disconnected(error.to_string()))
.await?;
events.send(SftpEvent::Idle).await?;
busy.store(false, Ordering::Release);
continue;
}
}
}
let connected = client.read().await.clone().expect("connected above");
let prepared: Result<()> = async {
if let SftpCommand::Upload(request) = &operation {
batch = None;
events
.send(SftpEvent::Progress(super::UploadProgress::default()))
.await?;
let target = control
.run(connected.canonicalize(request.destination()))
.await?;
let request = super::UploadRequest::new(request.sources().to_vec(), target)?;
batch = Some(
control
.run(UploadBatch::scan(
&request,
&self.config.endpoint(),
connected.fingerprint(),
self.journal.clone(),
&control,
))
.await?,
);
}
if let Some(batch) = &batch {
batch.verify_host(connected.fingerprint())?;
}
if matches!(operation, SftpCommand::Discard) {
if let Some(current) = &batch {
current.discard(connected.as_ref()).await?;
}
batch = None;
events
.send(SftpEvent::Progress(super::UploadProgress {
phase: UploadPhase::Completed,
..Default::default()
}))
.await?;
}
Ok(())
}
.await;
if let Err(error) = prepared {
events.send(SftpEvent::Notice(error.to_string())).await?;
if let Some(batch) = &mut batch {
batch.phase(UploadPhase::Stopped, &control);
} else {
events
.send(SftpEvent::Progress(super::UploadProgress {
phase: UploadPhase::Stopped,
details: vec![error.to_string()],
..Default::default()
}))
.await?;
}
busy.store(false, Ordering::Release);
continue;
}
if !matches!(operation, SftpCommand::Discard)
&& let Some(batch) = &mut batch
{
let mut reconnects = 0;
loop {
let connected = client.read().await.clone();
let result = match connected {
Some(connected) => batch.run(connected.as_ref(), &control).await,
None => {
if control.check().is_err() {
Err(Cancelled.into())
} else {
Err(std::io::Error::new(
std::io::ErrorKind::NotConnected,
"SFTP 未连接",
)
.into())
}
}
};
match result {
Ok(()) => break,
Err(error) if is_network_error(&error) => {
*client.write().await = None;
events
.send(SftpEvent::Disconnected("连接中断,上传进度已保留".into()))
.await?;
let mut recovered = false;
while reconnects < 3 {
batch.progress.current = format!(
"将在 {} 秒后重连({}/3)",
[1, 3, 10][reconnects],
reconnects + 1
);
batch.phase(UploadPhase::Reconnecting, &control);
let delay = Duration::from_secs([1, 3, 10][reconnects]);
reconnects += 1;
if control
.run(async {
tokio::time::sleep(delay).await;
Ok(())
})
.await
.is_err()
{
break;
}
match control
.run(self.connect(prompts.clone(), &events, &shutdown, &client))
.await
{
Ok(next) => match batch.verify_host(next.fingerprint()) {
Ok(()) => {
*client.write().await = Some(next);
recovered = true;
break;
}
Err(error) => {
events
.send(SftpEvent::Notice(error.to_string()))
.await?;
break;
}
},
Err(error) => {
events
.send(SftpEvent::Disconnected(error.to_string()))
.await?;
// Authentication/trust failures must require user action.
if !is_network_error(&error) {
break;
}
}
}
}
if recovered {
continue;
}
batch.phase(UploadPhase::Stopped, &control);
break;
}
Err(error) => {
if !error.is::<Cancelled>() {
events.send(SftpEvent::Notice(error.to_string())).await?;
}
batch.phase(UploadPhase::Stopped, &control);
break;
}
}
}
if batch.is_complete() {
batch.phase(UploadPhase::Completed, &control);
}
}
busy.store(false, Ordering::Release);
events.send(SftpEvent::Idle).await?;
}
router.abort();
*client.write().await = None;
Ok(())
}
}
+681
View File
@@ -0,0 +1,681 @@
//! SSH connection and authentication shared by terminals and SFTP.
use crate::{
connection::{
ConnectionPrompt, ConnectionPromptField, ConnectionPromptKind, ConnectionPromptReply,
},
secrets::{SecretRef, SharedSecretStore},
session::{AuthKind, Session},
};
use anyhow::{Context as _, Result, anyhow, bail};
use russh::keys::{
HashAlg, PrivateKeyWithHashAlg, PublicKey,
agent::client::AgentClient,
known_hosts::{known_host_keys_path, learn_known_hosts_path},
load_secret_key, parse_public_key_base64,
};
use russh::{
MethodKind, MethodSet,
client::{self, AuthResult, KeyboardInteractiveAuthResponse},
};
use std::{
collections::HashMap,
future::Future,
path::{Path, PathBuf},
sync::{
Arc, Mutex,
atomic::{AtomicU64, Ordering},
},
time::Duration,
};
use tokio::sync::{oneshot, watch};
use zeroize::Zeroizing;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30);
const AUTH_RETRIES: usize = 3;
static NEXT_PROMPT_ID: AtomicU64 = AtomicU64::new(1);
#[derive(Clone)]
pub struct SshConnectionConfig {
host: String,
port: u16,
user: String,
auth: AuthKind,
key_path: Option<PathBuf>,
}
impl From<&Session> for SshConnectionConfig {
fn from(session: &Session) -> Self {
Self {
host: session.host.to_string(),
port: session.port,
user: session.user.to_string(),
auth: session.auth,
key_path: session
.key_path
.as_ref()
.map(|path| PathBuf::from(path.as_ref())),
}
}
}
/// Cloneable trust and credential service. Every clone shares the trust-file lock.
#[derive(Clone)]
pub struct SshConnector {
known_hosts_path: PathBuf,
known_hosts_lock: Arc<Mutex<()>>,
secrets: SharedSecretStore,
}
impl SshConnector {
pub fn new(path: impl Into<PathBuf>, secrets: SharedSecretStore) -> Self {
Self {
known_hosts_path: path.into(),
known_hosts_lock: Arc::new(Mutex::new(())),
secrets,
}
}
/// Must run on a worker: keychain and private-key reads are blocking.
pub async fn connect(
&self,
config: &SshConnectionConfig,
broker: Arc<SshPrompts>,
) -> Result<(client::Handle<SshClientHandler>, String)> {
let fingerprint = Arc::new(Mutex::new(String::new()));
let handler = SshClientHandler {
host: config.host.clone(),
port: config.port,
known_hosts_path: self.known_hosts_path.clone(),
known_hosts_lock: self.known_hosts_lock.clone(),
broker: broker.clone(),
fingerprint: fingerprint.clone(),
};
let connect = client::connect(
Arc::new(client::Config {
keepalive_interval: Some(KEEPALIVE_INTERVAL),
keepalive_max: 3,
nodelay: true,
..Default::default()
}),
(config.host.as_str(), config.port),
handler,
);
let mut shutdown = broker.shutdown_receiver();
let mut handle = tokio::select! {
result = timeout_excluding_prompts(connect, broker.prompt_activity_receiver(), CONNECT_TIMEOUT) => result?.map_err(|e| e.context(safe_connect_error()))?,
_ = shutdown.changed() => bail!("连接已取消"),
};
tokio::select! {
result = authenticate(&mut handle, config, &self.secrets, &broker) => result?,
_ = shutdown.changed() => bail!("连接已取消"),
}
let fingerprint = fingerprint
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
Ok((handle, fingerprint))
}
}
impl SshConnectionConfig {
pub fn endpoint(&self) -> String {
format!("{}@{}:{}", self.user, self.host, self.port)
}
}
pub struct SshPrompts {
pending: Mutex<HashMap<u64, oneshot::Sender<ConnectionPromptReply>>>,
events: Arc<dyn Fn(ConnectionPrompt) -> bool + Send + Sync>,
shutdown: watch::Receiver<bool>,
prompt_activity: watch::Sender<bool>,
}
impl SshPrompts {
pub fn new(
events: Arc<dyn Fn(ConnectionPrompt) -> bool + Send + Sync>,
shutdown: watch::Receiver<bool>,
) -> Self {
let (prompt_activity, _) = watch::channel(false);
Self {
pending: Mutex::new(HashMap::new()),
events,
shutdown,
prompt_activity,
}
}
pub fn shutdown_receiver(&self) -> watch::Receiver<bool> {
self.shutdown.clone()
}
fn prompt_activity_receiver(&self) -> watch::Receiver<bool> {
self.prompt_activity.subscribe()
}
async fn ask(&self, kind: ConnectionPromptKind) -> Result<ConnectionPromptReply> {
let request_id = NEXT_PROMPT_ID.fetch_add(1, Ordering::Relaxed);
let (sender, receiver) = oneshot::channel();
self.pending
.lock()
.unwrap_or_else(|error| error.into_inner())
.insert(request_id, sender);
let _ = self.prompt_activity.send(true);
let result = async {
if !(self.events)(ConnectionPrompt::new(request_id, kind)) {
bail!("连接视图已关闭");
}
let mut shutdown = self.shutdown.clone();
tokio::select! {
reply = receiver => reply.map_err(|_| anyhow!("认证请求已取消")),
_ = shutdown.changed() => bail!("连接已取消"),
}
}
.await;
self.pending
.lock()
.unwrap_or_else(|error| error.into_inner())
.remove(&request_id);
let _ = self.prompt_activity.send(false);
result
}
async fn emit(&self, kind: ConnectionPromptKind) {
let request_id = NEXT_PROMPT_ID.fetch_add(1, Ordering::Relaxed);
(self.events)(ConnectionPrompt::new(request_id, kind));
}
pub fn respond(&self, request_id: u64, reply: ConnectionPromptReply) {
if let Some(sender) = self
.pending
.lock()
.unwrap_or_else(|error| error.into_inner())
.remove(&request_id)
{
let _ = sender.send(reply);
}
}
pub fn cancel_all(&self) {
self.pending
.lock()
.unwrap_or_else(|error| error.into_inner())
.clear();
}
}
/// Apply the network handshake timeout without counting time spent waiting
/// for an explicit answer from the user to a host-trust prompt.
async fn timeout_excluding_prompts<F>(
future: F,
mut prompt_activity: watch::Receiver<bool>,
timeout: Duration,
) -> Result<F::Output>
where
F: Future,
{
tokio::pin!(future);
let mut remaining = timeout;
loop {
if *prompt_activity.borrow() {
tokio::select! {
result = &mut future => return Ok(result),
changed = prompt_activity.changed() => {
if changed.is_err() {
bail!("连接已取消");
}
}
}
continue;
}
let started = tokio::time::Instant::now();
tokio::select! {
result = &mut future => return Ok(result),
_ = tokio::time::sleep(remaining) => return Err(russh::Error::ConnectionTimeout.into()),
changed = prompt_activity.changed() => {
if changed.is_err() {
bail!("连接已取消");
}
remaining = remaining.saturating_sub(started.elapsed());
if remaining.is_zero() && !*prompt_activity.borrow() {
return Err(russh::Error::ConnectionTimeout.into());
}
}
}
}
}
pub type SshHandle = client::Handle<SshClientHandler>;
pub struct SshClientHandler {
host: String,
port: u16,
known_hosts_path: PathBuf,
known_hosts_lock: Arc<Mutex<()>>,
broker: Arc<SshPrompts>,
fingerprint: Arc<Mutex<String>>,
}
impl client::Handler for SshClientHandler {
type Error = anyhow::Error;
async fn check_server_key(
&mut self,
server_public_key: &russh::keys::PublicKeyOrCertificate,
) -> Result<bool, Self::Error> {
let key = server_public_key.public_key();
let fingerprint = key.fingerprint(HashAlg::Sha256).to_string();
*self.fingerprint.lock().unwrap_or_else(|e| e.into_inner()) = fingerprint.clone();
let algorithm = key.algorithm().to_string();
let known = {
let _guard = self
.known_hosts_lock
.lock()
.unwrap_or_else(|error| error.into_inner());
read_known_keys(&self.host, self.port, &self.known_hosts_path)?
};
if known.iter().any(|(_, saved)| saved == &key) {
return Ok(true);
}
if !known.is_empty() {
let old = known
.iter()
.map(|(_, saved)| saved.fingerprint(HashAlg::Sha256).to_string())
.collect();
self.broker
.emit(ConnectionPromptKind::host_key_changed(
&self.host,
self.port,
algorithm,
old,
fingerprint,
&self.known_hosts_path,
))
.await;
return Ok(false);
}
let reply = self
.broker
.ask(ConnectionPromptKind::unknown_host(
&self.host,
self.port,
algorithm,
fingerprint,
))
.await?;
if !matches!(reply, ConnectionPromptReply::TrustAndSave) {
return Ok(false);
}
let _guard = self
.known_hosts_lock
.lock()
.unwrap_or_else(|error| error.into_inner());
let known = read_known_keys(&self.host, self.port, &self.known_hosts_path)?;
if known.iter().any(|(_, saved)| saved == &key) {
return Ok(true);
}
if !known.is_empty() {
bail!("保存主机密钥时发现信任文件已发生变化");
}
learn_known_hosts_path(&self.host, self.port, &key, &self.known_hosts_path)
.map_err(|_| anyhow!("无法写入主机信任文件:{}", self.known_hosts_path.display()))?;
Ok(true)
}
}
fn read_known_keys(host: &str, port: u16, path: &Path) -> Result<Vec<(usize, PublicKey)>> {
if path.exists() {
let contents = std::fs::read_to_string(path)
.with_context(|| format!("无法读取主机信任文件:{}", path.display()))?;
for line in contents.lines().map(str::trim) {
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut fields = line.split_whitespace();
let valid = fields.next().is_some()
&& fields.next().is_some()
&& fields
.next()
.is_some_and(|encoded| parse_public_key_base64(encoded).is_ok());
if !valid {
bail!("主机信任文件已损坏:{}", path.display());
}
}
}
known_host_keys_path(host, port, path)
.map_err(|_| anyhow!("主机信任文件已损坏或无法读取:{}", path.display()))
}
async fn authenticate<H: client::Handler>(
handle: &mut client::Handle<H>,
config: &SshConnectionConfig,
secrets: &SharedSecretStore,
broker: &SshPrompts,
) -> Result<()>
where
H::Error: From<russh::Error>,
{
let first = handle
.authenticate_none(&config.user)
.await
.map_err(|_| anyhow!("无法查询服务器支持的认证方式"))?;
if first.success() {
return Ok(());
}
let mut methods = remaining_methods(first);
let mut partial = false;
if config.auth == AuthKind::Auto
&& methods.contains(&MethodKind::PublicKey)
&& let Some(result) = try_agent(handle, &config.user).await?
{
if result.success() {
return Ok(());
}
partial = is_partial(&result);
methods = remaining_methods(result);
}
if matches!(config.auth, AuthKind::Auto | AuthKind::Key)
&& methods.contains(&MethodKind::PublicKey)
{
let paths = key_paths(config)?;
for path in paths {
let Some(key) = load_private_key(&path, secrets, broker).await? else {
continue;
};
let hash = handle
.best_supported_rsa_hash()
.await
.map_err(|_| anyhow!("无法协商 RSA 签名算法"))?
.flatten();
let result = handle
.authenticate_publickey(
&config.user,
PrivateKeyWithHashAlg::new(Arc::new(key), hash),
)
.await
.map_err(|_| anyhow!("私钥认证失败"))?;
if result.success() {
return Ok(());
}
partial = is_partial(&result);
methods = remaining_methods(result);
if partial {
break;
}
}
if config.auth == AuthKind::Key && !partial {
bail!("服务器未接受指定的私钥");
}
}
if matches!(config.auth, AuthKind::Auto | AuthKind::Password) || partial {
if methods.contains(&MethodKind::Password) {
// Try what the session has saved before bothering anyone.
let mut saved_rejected = false;
if let Some(saved) = saved_secret(secrets, &password_secret(config)) {
let result = handle
.authenticate_password(&config.user, saved.to_string())
.await
.map_err(|_| anyhow!("密码认证失败"))?;
if result.success() {
return Ok(());
}
// The entry stays: the user typed it into the session dialog,
// and deleting it behind their back would be baffling. This
// connection just falls back to asking, and says why.
saved_rejected = true;
partial = is_partial(&result);
methods = remaining_methods(result);
}
let instructions = if saved_rejected {
"已保存的密码被服务器拒绝,请重新输入"
} else {
"请输入登录密码"
};
if !partial && methods.contains(&MethodKind::Password) {
for _ in 0..AUTH_RETRIES {
let answer = ask_one_secret(broker, "SSH 登录", instructions, "密码").await?;
let result = handle
.authenticate_password(&config.user, answer.into_inner())
.await
.map_err(|_| anyhow!("密码认证失败"))?;
if result.success() {
return Ok(());
}
partial = is_partial(&result);
methods = remaining_methods(result);
if partial || !methods.contains(&MethodKind::Password) {
break;
}
}
}
}
if methods.contains(&MethodKind::KeyboardInteractive) {
for _ in 0..AUTH_RETRIES {
if keyboard_interactive(handle, &config.user, broker).await? {
return Ok(());
}
}
}
}
bail!("认证失败:服务器未接受可用的认证方式")
}
async fn try_agent<H: client::Handler>(
handle: &mut client::Handle<H>,
user: &str,
) -> Result<Option<AuthResult>>
where
H::Error: From<russh::Error>,
{
#[cfg(unix)]
{
let Ok(mut agent) = AgentClient::connect_env().await else {
return Ok(None);
};
let Ok(identities) = agent.request_identities().await else {
return Ok(None);
};
let hash = handle
.best_supported_rsa_hash()
.await
.map_err(|_| anyhow!("无法协商 SSH Agent 签名算法"))?
.flatten();
let mut last = None;
for identity in identities {
let key = identity.public_key().into_owned();
match handle
.authenticate_publickey_with(user, key, hash, &mut agent)
.await
{
Ok(result) if result.success() => return Ok(Some(result)),
Ok(result) => last = Some(result),
Err(_) => continue,
}
}
Ok(last)
}
#[cfg(not(unix))]
{
let _ = (handle, user);
Ok(None)
}
}
fn key_paths(config: &SshConnectionConfig) -> Result<Vec<PathBuf>> {
if config.auth == AuthKind::Key {
return config
.key_path
.clone()
.map(|path| vec![path])
.ok_or_else(|| anyhow!("私钥认证需要选择私钥文件"));
}
let Some(home) = dirs::home_dir() else {
return Ok(Vec::new());
};
Ok(["id_ed25519", "id_ecdsa", "id_rsa"]
.into_iter()
.map(|name| home.join(".ssh").join(name))
.collect())
}
async fn load_private_key(
path: &Path,
secrets: &SharedSecretStore,
broker: &SshPrompts,
) -> Result<Option<russh::keys::PrivateKey>> {
if !path.exists() {
return Ok(None);
}
match load_secret_key(path, None) {
Ok(key) => return Ok(Some(key)),
Err(russh::keys::Error::KeyIsEncrypted) => {}
Err(_) => bail!("无法读取私钥文件:{}", path.display()),
}
// Passphrases are saved per key file, so one saved answer unlocks the same
// key for every session that uses it.
let mut saved_rejected = false;
if let Some(saved) = saved_secret(secrets, &SecretRef::passphrase(path)) {
if let Ok(key) = load_secret_key(path, Some(saved.as_str())) {
return Ok(Some(key));
}
saved_rejected = true;
}
let instructions = if saved_rejected {
format!("已保存的口令无法解开 {},请重新输入", path.display())
} else {
format!("请输入 {} 的口令", path.display())
};
for _ in 0..AUTH_RETRIES {
let answer = ask_one_secret(broker, "私钥口令", &instructions, "口令").await?;
if let Ok(key) = load_secret_key(path, Some(answer.expose())) {
return Ok(Some(key));
}
}
bail!("私钥口令错误次数过多")
}
/// Where this connection's password lives in the system keychain.
fn password_secret(config: &SshConnectionConfig) -> SecretRef {
SecretRef::password(&config.user, &config.host, config.port)
}
/// Read a saved secret. A keychain that errors, is locked, or holds an empty
/// value counts as nothing saved: the connection then falls back to asking,
/// which is always better than refusing to connect.
///
/// This blocks. It only ever runs on the SSH worker thread, whose runtime
/// serves this one connection, and only while authentication is already
/// waiting on a person.
fn saved_secret(secrets: &SharedSecretStore, secret: &SecretRef) -> Option<Zeroizing<String>> {
secrets
.get(secret)
.ok()
.flatten()
.filter(|value| !value.is_empty())
}
async fn ask_one_secret(
broker: &SshPrompts,
title: &str,
instructions: &str,
label: &str,
) -> Result<crate::connection::ConnectionSecret> {
let reply = broker
.ask(ConnectionPromptKind::authentication(
title,
instructions,
vec![ConnectionPromptField::new(label, false)],
))
.await?;
match reply {
ConnectionPromptReply::Answers(mut answers) if answers.len() == 1 => Ok(answers.remove(0)),
ConnectionPromptReply::Cancel => bail!("认证已取消"),
_ => bail!("认证回复无效"),
}
}
async fn keyboard_interactive<H: client::Handler>(
handle: &mut client::Handle<H>,
user: &str,
broker: &SshPrompts,
) -> Result<bool>
where
H::Error: From<russh::Error>,
{
let mut response = handle
.authenticate_keyboard_interactive_start(user, None)
.await
.map_err(|_| anyhow!("无法开始交互式认证"))?;
loop {
match response {
KeyboardInteractiveAuthResponse::Success => return Ok(true),
KeyboardInteractiveAuthResponse::Failure { .. } => return Ok(false),
KeyboardInteractiveAuthResponse::InfoRequest {
name,
instructions,
prompts,
} => {
let fields = prompts
.into_iter()
.map(|prompt| ConnectionPromptField::new(prompt.prompt, prompt.echo))
.collect();
let reply = broker
.ask(ConnectionPromptKind::authentication(
name,
instructions,
fields,
))
.await?;
let ConnectionPromptReply::Answers(answers) = reply else {
bail!("交互式认证已取消")
};
response = handle
.authenticate_keyboard_interactive_respond(
answers
.into_iter()
.map(|answer| answer.into_inner())
.collect(),
)
.await
.map_err(|_| anyhow!("交互式认证失败"))?;
}
}
}
}
fn remaining_methods(result: AuthResult) -> MethodSet {
match result {
AuthResult::Success => MethodSet::empty(),
AuthResult::Failure {
remaining_methods, ..
} => remaining_methods,
}
}
fn is_partial(result: &AuthResult) -> bool {
matches!(
result,
AuthResult::Failure {
partial_success: true,
..
}
)
}
fn safe_connect_error() -> &'static str {
"无法建立 SSH 连接,请检查主机、端口和主机密钥"
}
#[cfg(test)]
mod tests {
use super::read_known_keys;
#[test]
fn malformed_known_hosts_is_blocked() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("known_hosts");
std::fs::write(&path, "this is not a key\n").unwrap();
let error = read_known_keys("example.test", 22, &path).unwrap_err();
assert!(error.to_string().contains("已损坏"));
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
//! Real SSH transport. SFTP intentionally remains a separate mock capability.
//! Shared SSH connection service and remote terminal adapter.
mod connection;
mod probe;
mod transport;
pub use connection::{SshConnectionConfig, SshConnector, SshHandle, SshPrompts};
pub use transport::SshTerminalTransportProvider;
+51 -674
View File
@@ -1,115 +1,67 @@
use std::collections::HashMap;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, mpsc};
use std::thread;
use std::time::Duration;
use super::{
connection::{SshConnectionConfig, SshConnector, SshPrompts},
probe::{HostOsProbe, ProbeOutcome},
};
use crate::{
secrets::SharedSecretStore,
session::Session,
terminal::{
RemoteTerminalTransportProvider, SharedTerminalTransportFactory, TerminalSize,
TerminalTransport, TerminalTransportCommand, TerminalTransportEvent,
TerminalTransportFactory,
},
};
use anyhow::{Context as _, Result, anyhow, bail};
use async_channel::Sender;
use russh::ChannelMsg;
use russh::client::{self, AuthResult, KeyboardInteractiveAuthResponse};
use russh::keys::agent::client::AgentClient;
use russh::keys::known_hosts::{known_host_keys_path, learn_known_hosts_path};
use russh::keys::{
HashAlg, PrivateKeyWithHashAlg, PublicKey, load_secret_key, parse_public_key_base64,
use russh::{ChannelMsg, client};
use std::{
path::PathBuf,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc,
},
thread,
time::Duration,
};
use russh::{MethodKind, MethodSet};
use tokio::sync::{mpsc as tokio_mpsc, oneshot, watch};
use zeroize::Zeroizing;
use super::probe::{HostOsProbe, ProbeOutcome};
use crate::secrets::{SecretRef, SharedSecretStore};
use crate::session::{AuthKind, Session};
use crate::terminal::{
RemoteTerminalTransportProvider, SharedTerminalTransportFactory, TerminalPrompt,
TerminalPromptField, TerminalPromptKind, TerminalPromptReply, TerminalSize, TerminalTransport,
TerminalTransportCommand, TerminalTransportEvent, TerminalTransportFactory,
};
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30);
use tokio::sync::{mpsc as tokio_mpsc, watch};
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
const AUTH_RETRIES: usize = 3;
static NEXT_PROMPT_ID: AtomicU64 = AtomicU64::new(1);
/// Production remote provider. Its write lock is shared by every connection,
/// making host-key checks and appends to shellr's trust file serial.
/// Production remote-terminal adapter for the shared SSH connector.
pub struct SshTerminalTransportProvider {
known_hosts_path: PathBuf,
known_hosts_lock: Arc<Mutex<()>>,
secrets: SharedSecretStore,
connector: SshConnector,
}
impl SshTerminalTransportProvider {
pub fn new(path: impl Into<PathBuf>, secrets: SharedSecretStore) -> Self {
Self {
known_hosts_path: path.into(),
known_hosts_lock: Arc::new(Mutex::new(())),
secrets,
}
Self::with_connector(SshConnector::new(path, secrets))
}
pub fn with_connector(connector: SshConnector) -> Self {
Self { connector }
}
}
impl RemoteTerminalTransportProvider for SshTerminalTransportProvider {
fn factory_for(&self, session: &Session) -> SharedTerminalTransportFactory {
Arc::new(SshTerminalTransportFactory {
config: SshConnectionConfig::from(session),
known_hosts_path: self.known_hosts_path.clone(),
known_hosts_lock: self.known_hosts_lock.clone(),
secrets: self.secrets.clone(),
connector: self.connector.clone(),
})
}
}
#[derive(Clone)]
struct SshConnectionConfig {
host: String,
port: u16,
user: String,
auth: AuthKind,
key_path: Option<PathBuf>,
}
impl From<&Session> for SshConnectionConfig {
fn from(session: &Session) -> Self {
Self {
host: session.host.to_string(),
port: session.port,
user: session.user.to_string(),
auth: session.auth,
key_path: session
.key_path
.as_ref()
.map(|path| PathBuf::from(path.as_ref())),
}
}
}
struct SshTerminalTransportFactory {
config: SshConnectionConfig,
known_hosts_path: PathBuf,
known_hosts_lock: Arc<Mutex<()>>,
secrets: SharedSecretStore,
connector: SshConnector,
}
impl TerminalTransportFactory for SshTerminalTransportFactory {
fn create(&self) -> Box<dyn TerminalTransport> {
Box::new(SshTerminalTransport {
config: self.config.clone(),
known_hosts_path: self.known_hosts_path.clone(),
known_hosts_lock: self.known_hosts_lock.clone(),
secrets: self.secrets.clone(),
connector: self.connector.clone(),
})
}
}
struct SshTerminalTransport {
config: SshConnectionConfig,
known_hosts_path: PathBuf,
known_hosts_lock: Arc<Mutex<()>>,
secrets: SharedSecretStore,
connector: SshConnector,
}
impl TerminalTransport for SshTerminalTransport {
@@ -159,7 +111,15 @@ impl SshTerminalTransport {
) -> Result<()> {
let (io_tx, mut io_rx) = tokio_mpsc::unbounded_channel();
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let broker = Arc::new(PromptBroker::new(events.clone(), shutdown_rx));
let prompt_events = events.clone();
let broker = Arc::new(SshPrompts::new(
Arc::new(move |prompt| {
prompt_events
.try_send(TerminalTransportEvent::Prompt(prompt))
.is_ok()
}),
shutdown_rx,
));
let router_broker = broker.clone();
let router = tokio::spawn(async move {
while let Some(command) = bridge_commands.recv().await {
@@ -182,41 +142,8 @@ impl SshTerminalTransport {
}
});
let config = client::Config {
keepalive_interval: Some(KEEPALIVE_INTERVAL),
keepalive_max: 3,
nodelay: true,
..Default::default()
};
let handler = SshClientHandler {
host: self.config.host.clone(),
port: self.config.port,
known_hosts_path: self.known_hosts_path.clone(),
known_hosts_lock: self.known_hosts_lock.clone(),
broker: broker.clone(),
};
let connect = client::connect(
Arc::new(config),
(self.config.host.as_str(), self.config.port),
handler,
);
let (handle, _) = self.connector.connect(&self.config, broker.clone()).await?;
let mut shutdown = broker.shutdown_receiver();
let mut handle = tokio::select! {
result = timeout_excluding_prompts(
connect,
broker.prompt_activity_receiver(),
CONNECT_TIMEOUT,
) => {
result?.map_err(|error| anyhow!(safe_connect_error(&error)))?
}
_ = shutdown.changed() => bail!("连接已取消"),
};
tokio::select! {
result = authenticate(&mut handle, &self.config, &self.secrets, &broker) => result?,
_ = shutdown.changed() => bail!("连接已取消"),
}
let mut channel = tokio::select! {
result = handle.channel_open_session() => {
result.map_err(|_| anyhow!("无法创建 SSH 会话通道"))?
@@ -346,442 +273,6 @@ fn pixel_dimension(cells: usize, cell_size: u16) -> u32 {
.min(u32::MAX as usize) as u32
}
struct PromptBroker {
pending: Mutex<HashMap<u64, oneshot::Sender<TerminalPromptReply>>>,
events: Sender<TerminalTransportEvent>,
shutdown: watch::Receiver<bool>,
prompt_activity: watch::Sender<bool>,
}
impl PromptBroker {
fn new(events: Sender<TerminalTransportEvent>, shutdown: watch::Receiver<bool>) -> Self {
let (prompt_activity, _) = watch::channel(false);
Self {
pending: Mutex::new(HashMap::new()),
events,
shutdown,
prompt_activity,
}
}
fn shutdown_receiver(&self) -> watch::Receiver<bool> {
self.shutdown.clone()
}
fn prompt_activity_receiver(&self) -> watch::Receiver<bool> {
self.prompt_activity.subscribe()
}
async fn ask(&self, kind: TerminalPromptKind) -> Result<TerminalPromptReply> {
let request_id = NEXT_PROMPT_ID.fetch_add(1, Ordering::Relaxed);
let (sender, receiver) = oneshot::channel();
self.pending
.lock()
.unwrap_or_else(|error| error.into_inner())
.insert(request_id, sender);
let _ = self.prompt_activity.send(true);
let result = async {
self.events
.send(TerminalTransportEvent::Prompt(TerminalPrompt::new(
request_id, kind,
)))
.await
.map_err(|_| anyhow!("终端标签页已关闭"))?;
let mut shutdown = self.shutdown.clone();
tokio::select! {
reply = receiver => reply.map_err(|_| anyhow!("认证请求已取消")),
_ = shutdown.changed() => bail!("连接已取消"),
}
}
.await;
self.pending
.lock()
.unwrap_or_else(|error| error.into_inner())
.remove(&request_id);
let _ = self.prompt_activity.send(false);
result
}
async fn emit(&self, kind: TerminalPromptKind) {
let request_id = NEXT_PROMPT_ID.fetch_add(1, Ordering::Relaxed);
let _ = self
.events
.send(TerminalTransportEvent::Prompt(TerminalPrompt::new(
request_id, kind,
)))
.await;
}
fn respond(&self, request_id: u64, reply: TerminalPromptReply) {
if let Some(sender) = self
.pending
.lock()
.unwrap_or_else(|error| error.into_inner())
.remove(&request_id)
{
let _ = sender.send(reply);
}
}
fn cancel_all(&self) {
self.pending
.lock()
.unwrap_or_else(|error| error.into_inner())
.clear();
}
}
/// Apply the network handshake timeout without counting time spent waiting
/// for an explicit answer from the user to a host-trust prompt.
async fn timeout_excluding_prompts<F>(
future: F,
mut prompt_activity: watch::Receiver<bool>,
timeout: Duration,
) -> Result<F::Output>
where
F: Future,
{
tokio::pin!(future);
let mut remaining = timeout;
loop {
if *prompt_activity.borrow() {
tokio::select! {
result = &mut future => return Ok(result),
changed = prompt_activity.changed() => {
if changed.is_err() {
bail!("连接已取消");
}
}
}
continue;
}
let started = tokio::time::Instant::now();
tokio::select! {
result = &mut future => return Ok(result),
_ = tokio::time::sleep(remaining) => bail!("连接超时(15 秒)"),
changed = prompt_activity.changed() => {
if changed.is_err() {
bail!("连接已取消");
}
remaining = remaining.saturating_sub(started.elapsed());
if remaining.is_zero() && !*prompt_activity.borrow() {
bail!("连接超时(15 秒)");
}
}
}
}
}
struct SshClientHandler {
host: String,
port: u16,
known_hosts_path: PathBuf,
known_hosts_lock: Arc<Mutex<()>>,
broker: Arc<PromptBroker>,
}
impl client::Handler for SshClientHandler {
type Error = anyhow::Error;
async fn check_server_key(
&mut self,
server_public_key: &russh::keys::PublicKeyOrCertificate,
) -> Result<bool, Self::Error> {
let key = server_public_key.public_key();
let fingerprint = key.fingerprint(HashAlg::Sha256).to_string();
let algorithm = key.algorithm().to_string();
let known = {
let _guard = self
.known_hosts_lock
.lock()
.unwrap_or_else(|error| error.into_inner());
read_known_keys(&self.host, self.port, &self.known_hosts_path)?
};
if known.iter().any(|(_, saved)| saved == &key) {
return Ok(true);
}
if !known.is_empty() {
let old = known
.iter()
.map(|(_, saved)| saved.fingerprint(HashAlg::Sha256).to_string())
.collect();
self.broker
.emit(TerminalPromptKind::host_key_changed(
&self.host,
self.port,
algorithm,
old,
fingerprint,
&self.known_hosts_path,
))
.await;
return Ok(false);
}
let reply = self
.broker
.ask(TerminalPromptKind::unknown_host(
&self.host,
self.port,
algorithm,
fingerprint,
))
.await?;
if !matches!(reply, TerminalPromptReply::TrustAndSave) {
return Ok(false);
}
let _guard = self
.known_hosts_lock
.lock()
.unwrap_or_else(|error| error.into_inner());
let known = read_known_keys(&self.host, self.port, &self.known_hosts_path)?;
if known.iter().any(|(_, saved)| saved == &key) {
return Ok(true);
}
if !known.is_empty() {
bail!("保存主机密钥时发现信任文件已发生变化");
}
learn_known_hosts_path(&self.host, self.port, &key, &self.known_hosts_path)
.map_err(|_| anyhow!("无法写入主机信任文件:{}", self.known_hosts_path.display()))?;
Ok(true)
}
}
fn read_known_keys(host: &str, port: u16, path: &Path) -> Result<Vec<(usize, PublicKey)>> {
if path.exists() {
let contents = std::fs::read_to_string(path)
.with_context(|| format!("无法读取主机信任文件:{}", path.display()))?;
for line in contents.lines().map(str::trim) {
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut fields = line.split_whitespace();
let valid = fields.next().is_some()
&& fields.next().is_some()
&& fields
.next()
.is_some_and(|encoded| parse_public_key_base64(encoded).is_ok());
if !valid {
bail!("主机信任文件已损坏:{}", path.display());
}
}
}
known_host_keys_path(host, port, path)
.map_err(|_| anyhow!("主机信任文件已损坏或无法读取:{}", path.display()))
}
async fn authenticate<H: client::Handler>(
handle: &mut client::Handle<H>,
config: &SshConnectionConfig,
secrets: &SharedSecretStore,
broker: &PromptBroker,
) -> Result<()>
where
H::Error: From<russh::Error>,
{
let first = handle
.authenticate_none(&config.user)
.await
.map_err(|_| anyhow!("无法查询服务器支持的认证方式"))?;
if first.success() {
return Ok(());
}
let mut methods = remaining_methods(first);
let mut partial = false;
if config.auth == AuthKind::Auto
&& methods.contains(&MethodKind::PublicKey)
&& let Some(result) = try_agent(handle, &config.user).await?
{
if result.success() {
return Ok(());
}
partial = is_partial(&result);
methods = remaining_methods(result);
}
if matches!(config.auth, AuthKind::Auto | AuthKind::Key)
&& methods.contains(&MethodKind::PublicKey)
{
let paths = key_paths(config)?;
for path in paths {
let Some(key) = load_private_key(&path, secrets, broker).await? else {
continue;
};
let hash = handle
.best_supported_rsa_hash()
.await
.map_err(|_| anyhow!("无法协商 RSA 签名算法"))?
.flatten();
let result = handle
.authenticate_publickey(
&config.user,
PrivateKeyWithHashAlg::new(Arc::new(key), hash),
)
.await
.map_err(|_| anyhow!("私钥认证失败"))?;
if result.success() {
return Ok(());
}
partial = is_partial(&result);
methods = remaining_methods(result);
if partial {
break;
}
}
if config.auth == AuthKind::Key && !partial {
bail!("服务器未接受指定的私钥");
}
}
if matches!(config.auth, AuthKind::Auto | AuthKind::Password) || partial {
if methods.contains(&MethodKind::Password) {
// Try what the session has saved before bothering anyone.
let mut saved_rejected = false;
if let Some(saved) = saved_secret(secrets, &password_secret(config)) {
let result = handle
.authenticate_password(&config.user, saved.to_string())
.await
.map_err(|_| anyhow!("密码认证失败"))?;
if result.success() {
return Ok(());
}
// The entry stays: the user typed it into the session dialog,
// and deleting it behind their back would be baffling. This
// connection just falls back to asking, and says why.
saved_rejected = true;
partial = is_partial(&result);
methods = remaining_methods(result);
}
let instructions = if saved_rejected {
"已保存的密码被服务器拒绝,请重新输入"
} else {
"请输入登录密码"
};
if !partial && methods.contains(&MethodKind::Password) {
for _ in 0..AUTH_RETRIES {
let answer = ask_one_secret(broker, "SSH 登录", instructions, "密码").await?;
let result = handle
.authenticate_password(&config.user, answer.into_inner())
.await
.map_err(|_| anyhow!("密码认证失败"))?;
if result.success() {
return Ok(());
}
partial = is_partial(&result);
methods = remaining_methods(result);
if partial || !methods.contains(&MethodKind::Password) {
break;
}
}
}
}
if methods.contains(&MethodKind::KeyboardInteractive) {
for _ in 0..AUTH_RETRIES {
if keyboard_interactive(handle, &config.user, broker).await? {
return Ok(());
}
}
}
}
bail!("认证失败:服务器未接受可用的认证方式")
}
async fn try_agent<H: client::Handler>(
handle: &mut client::Handle<H>,
user: &str,
) -> Result<Option<AuthResult>>
where
H::Error: From<russh::Error>,
{
#[cfg(unix)]
{
let Ok(mut agent) = AgentClient::connect_env().await else {
return Ok(None);
};
let Ok(identities) = agent.request_identities().await else {
return Ok(None);
};
let hash = handle
.best_supported_rsa_hash()
.await
.map_err(|_| anyhow!("无法协商 SSH Agent 签名算法"))?
.flatten();
let mut last = None;
for identity in identities {
let key = identity.public_key().into_owned();
match handle
.authenticate_publickey_with(user, key, hash, &mut agent)
.await
{
Ok(result) if result.success() => return Ok(Some(result)),
Ok(result) => last = Some(result),
Err(_) => continue,
}
}
Ok(last)
}
#[cfg(not(unix))]
{
let _ = (handle, user);
Ok(None)
}
}
fn key_paths(config: &SshConnectionConfig) -> Result<Vec<PathBuf>> {
if config.auth == AuthKind::Key {
return config
.key_path
.clone()
.map(|path| vec![path])
.ok_or_else(|| anyhow!("私钥认证需要选择私钥文件"));
}
let Some(home) = dirs::home_dir() else {
return Ok(Vec::new());
};
Ok(["id_ed25519", "id_ecdsa", "id_rsa"]
.into_iter()
.map(|name| home.join(".ssh").join(name))
.collect())
}
async fn load_private_key(
path: &Path,
secrets: &SharedSecretStore,
broker: &PromptBroker,
) -> Result<Option<russh::keys::PrivateKey>> {
if !path.exists() {
return Ok(None);
}
match load_secret_key(path, None) {
Ok(key) => return Ok(Some(key)),
Err(russh::keys::Error::KeyIsEncrypted) => {}
Err(_) => bail!("无法读取私钥文件:{}", path.display()),
}
// Passphrases are saved per key file, so one saved answer unlocks the same
// key for every session that uses it.
let mut saved_rejected = false;
if let Some(saved) = saved_secret(secrets, &SecretRef::passphrase(path)) {
if let Ok(key) = load_secret_key(path, Some(saved.as_str())) {
return Ok(Some(key));
}
saved_rejected = true;
}
let instructions = if saved_rejected {
format!("已保存的口令无法解开 {},请重新输入", path.display())
} else {
format!("请输入 {} 的口令", path.display())
};
for _ in 0..AUTH_RETRIES {
let answer = ask_one_secret(broker, "私钥口令", &instructions, "口令").await?;
if let Ok(key) = load_secret_key(path, Some(answer.expose())) {
return Ok(Some(key));
}
}
bail!("私钥口令错误次数过多")
}
/// Open a channel and run one probe command on it. Best effort throughout: a
/// server that refuses the channel or the command just leaves the session's
/// recorded operating system as it was.
@@ -797,118 +288,6 @@ async fn open_probe<H: client::Handler>(
Some(channel)
}
/// Where this connection's password lives in the system keychain.
fn password_secret(config: &SshConnectionConfig) -> SecretRef {
SecretRef::password(&config.user, &config.host, config.port)
}
/// Read a saved secret. A keychain that errors, is locked, or holds an empty
/// value counts as nothing saved: the connection then falls back to asking,
/// which is always better than refusing to connect.
///
/// This blocks. It only ever runs on the SSH worker thread, whose runtime
/// serves this one connection, and only while authentication is already
/// waiting on a person.
fn saved_secret(secrets: &SharedSecretStore, secret: &SecretRef) -> Option<Zeroizing<String>> {
secrets
.get(secret)
.ok()
.flatten()
.filter(|value| !value.is_empty())
}
async fn ask_one_secret(
broker: &PromptBroker,
title: &str,
instructions: &str,
label: &str,
) -> Result<crate::terminal::TerminalSecret> {
let reply = broker
.ask(TerminalPromptKind::authentication(
title,
instructions,
vec![TerminalPromptField::new(label, false)],
))
.await?;
match reply {
TerminalPromptReply::Answers(mut answers) if answers.len() == 1 => Ok(answers.remove(0)),
TerminalPromptReply::Cancel => bail!("认证已取消"),
_ => bail!("认证回复无效"),
}
}
async fn keyboard_interactive<H: client::Handler>(
handle: &mut client::Handle<H>,
user: &str,
broker: &PromptBroker,
) -> Result<bool>
where
H::Error: From<russh::Error>,
{
let mut response = handle
.authenticate_keyboard_interactive_start(user, None)
.await
.map_err(|_| anyhow!("无法开始交互式认证"))?;
loop {
match response {
KeyboardInteractiveAuthResponse::Success => return Ok(true),
KeyboardInteractiveAuthResponse::Failure { .. } => return Ok(false),
KeyboardInteractiveAuthResponse::InfoRequest {
name,
instructions,
prompts,
} => {
let fields = prompts
.into_iter()
.map(|prompt| TerminalPromptField::new(prompt.prompt, prompt.echo))
.collect();
let reply = broker
.ask(TerminalPromptKind::authentication(
name,
instructions,
fields,
))
.await?;
let TerminalPromptReply::Answers(answers) = reply else {
bail!("交互式认证已取消")
};
response = handle
.authenticate_keyboard_interactive_respond(
answers
.into_iter()
.map(|answer| answer.into_inner())
.collect(),
)
.await
.map_err(|_| anyhow!("交互式认证失败"))?;
}
}
}
}
fn remaining_methods(result: AuthResult) -> MethodSet {
match result {
AuthResult::Success => MethodSet::empty(),
AuthResult::Failure {
remaining_methods, ..
} => remaining_methods,
}
}
fn is_partial(result: &AuthResult) -> bool {
matches!(
result,
AuthResult::Failure {
partial_success: true,
..
}
)
}
fn safe_connect_error(_: &anyhow::Error) -> &'static str {
"无法建立 SSH 连接,请检查主机、端口和主机密钥"
}
#[cfg(test)]
mod tests {
use super::*;
@@ -916,8 +295,15 @@ mod tests {
use crate::session::HostOs;
use crate::ssh::probe::{PROBE_COMMAND, WINDOWS_PROBE_COMMAND};
use crate::terminal::TerminalSecret;
use crate::{
session::AuthKind,
terminal::{TerminalPromptKind, TerminalPromptReply},
};
use russh::keys::{PublicKey, known_hosts::learn_known_hosts_path};
use russh::server::{self, Server as _};
use russh::{MethodKind, MethodSet};
use std::borrow::Cow;
use std::{path::Path, sync::Mutex};
const TEST_PASSWORD: &str = "test-password";
@@ -1747,15 +1133,6 @@ mod tests {
assert!(saw_challenge);
}
#[test]
fn malformed_known_hosts_is_blocked() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("known_hosts");
std::fs::write(&path, "this is not a key\n").unwrap();
let error = read_known_keys("example.test", 22, &path).unwrap_err();
assert!(error.to_string().contains("已损坏"));
}
#[test]
fn changed_host_key_is_reported_and_blocked() {
let Some(server) = start_server(TestAuth::Password) else {
+6 -212
View File
@@ -2,222 +2,16 @@ use std::sync::{Arc, mpsc};
use anyhow::Result;
use async_channel::Sender;
use zeroize::Zeroizing;
use super::TerminalSize;
use crate::session::{HostOs, Session};
/// One field requested by an SSH keyboard-interactive challenge.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TerminalPromptField {
label: String,
echo: bool,
}
impl TerminalPromptField {
pub fn new(label: impl Into<String>, echo: bool) -> Self {
Self {
label: label.into(),
echo,
}
}
pub fn label(&self) -> &str {
&self.label
}
pub fn echo(&self) -> bool {
self.echo
}
}
/// A transport question that must be answered by the UI before SSH setup can
/// continue. The id is scoped to a single transport generation.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TerminalPrompt {
request_id: u64,
kind: TerminalPromptKind,
}
impl TerminalPrompt {
pub fn new(request_id: u64, kind: TerminalPromptKind) -> Self {
Self { request_id, kind }
}
pub fn request_id(&self) -> u64 {
self.request_id
}
pub fn kind(&self) -> &TerminalPromptKind {
&self.kind
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TerminalPromptKind {
UnknownHost(UnknownHostPrompt),
HostKeyChanged(HostKeyChangedPrompt),
Authentication(AuthenticationPrompt),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UnknownHostPrompt {
host: String,
port: u16,
algorithm: String,
fingerprint: String,
}
impl UnknownHostPrompt {
pub fn host(&self) -> &str {
&self.host
}
pub fn port(&self) -> u16 {
self.port
}
pub fn algorithm(&self) -> &str {
&self.algorithm
}
pub fn fingerprint(&self) -> &str {
&self.fingerprint
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HostKeyChangedPrompt {
host: String,
port: u16,
algorithm: String,
old_fingerprints: Vec<String>,
fingerprint: String,
known_hosts_path: std::path::PathBuf,
}
impl HostKeyChangedPrompt {
pub fn host(&self) -> &str {
&self.host
}
pub fn port(&self) -> u16 {
self.port
}
pub fn algorithm(&self) -> &str {
&self.algorithm
}
pub fn old_fingerprints(&self) -> &[String] {
&self.old_fingerprints
}
pub fn fingerprint(&self) -> &str {
&self.fingerprint
}
pub fn known_hosts_path(&self) -> &std::path::Path {
&self.known_hosts_path
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuthenticationPrompt {
title: String,
instructions: String,
fields: Vec<TerminalPromptField>,
}
impl AuthenticationPrompt {
pub fn title(&self) -> &str {
&self.title
}
pub fn instructions(&self) -> &str {
&self.instructions
}
pub fn fields(&self) -> &[TerminalPromptField] {
&self.fields
}
}
impl TerminalPromptKind {
pub fn unknown_host(
host: impl Into<String>,
port: u16,
algorithm: impl Into<String>,
fingerprint: impl Into<String>,
) -> Self {
Self::UnknownHost(UnknownHostPrompt {
host: host.into(),
port,
algorithm: algorithm.into(),
fingerprint: fingerprint.into(),
})
}
pub fn host_key_changed(
host: impl Into<String>,
port: u16,
algorithm: impl Into<String>,
old_fingerprints: Vec<String>,
fingerprint: impl Into<String>,
known_hosts_path: impl Into<std::path::PathBuf>,
) -> Self {
Self::HostKeyChanged(HostKeyChangedPrompt {
host: host.into(),
port,
algorithm: algorithm.into(),
old_fingerprints,
fingerprint: fingerprint.into(),
known_hosts_path: known_hosts_path.into(),
})
}
pub fn authentication(
title: impl Into<String>,
instructions: impl Into<String>,
fields: Vec<TerminalPromptField>,
) -> Self {
Self::Authentication(AuthenticationPrompt {
title: title.into(),
instructions: instructions.into(),
fields,
})
}
}
/// A secret that is erased when dropped and never prints its contents.
pub struct TerminalSecret(Zeroizing<String>);
impl TerminalSecret {
pub fn new(value: impl Into<String>) -> Self {
Self(Zeroizing::new(value.into()))
}
pub(crate) fn expose(&self) -> &str {
self.0.as_str()
}
pub(crate) fn into_inner(mut self) -> String {
std::mem::take(&mut *self.0)
}
}
impl std::fmt::Debug for TerminalSecret {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("TerminalSecret([已隐藏])")
}
}
/// UI response to a transport prompt. Deliberately not `Clone`: answers may
/// contain passwords, private-key passphrases or one-time codes.
pub enum TerminalPromptReply {
TrustAndSave,
Answers(Vec<TerminalSecret>),
Cancel,
}
impl std::fmt::Debug for TerminalPromptReply {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::TrustAndSave => formatter.write_str("TrustAndSave"),
Self::Answers(answers) => formatter
.debug_tuple("Answers")
.field(&format_args!("{} 个已隐藏答案", answers.len()))
.finish(),
Self::Cancel => formatter.write_str("Cancel"),
}
}
}
pub use crate::connection::{
AuthenticationPrompt, ConnectionPrompt as TerminalPrompt,
ConnectionPromptField as TerminalPromptField, ConnectionPromptKind as TerminalPromptKind,
ConnectionPromptReply as TerminalPromptReply, ConnectionSecret as TerminalSecret,
HostKeyChangedPrompt, UnknownHostPrompt,
};
/// Commands accepted by every terminal byte-stream transport.
pub enum TerminalTransportCommand {
+290 -75
View File
@@ -20,16 +20,21 @@ use gpui_kit::*;
use crate::app::{
CloseActiveTab, CloseExplorer, CloseLocalTerminal, CloseTerminal, ConnectSession, CopyTerminal,
DeleteGroup, DeleteSession, DisconnectSession, DuplicateSession, EditSession, FocusSearch,
NewChildGroup, NewGroup, NewLocalTerminal, NewSession, NewSessionInGroup, OpenExplorer,
PasteTerminal, ReconnectTerminal, RenameGroup, RestartLocalTerminal, ToggleSessionPanel,
ToggleTheme, ZoomIn, ZoomOut, ZoomReset,
DeleteGroup, DeleteSession, DisconnectSession, DuplicateSession, EditSession, ExplorerAction,
ExplorerCommand, FocusSearch, NewChildGroup, NewGroup, NewLocalTerminal, NewSession,
NewSessionInGroup, OpenExplorer, PasteTerminal, ReconnectTerminal, RenameGroup,
RestartLocalTerminal, SelectAllUploadFiles, ToggleSessionPanel, ToggleTheme,
ToggleUploadSelection, UploadSelectedFiles, ZoomIn, ZoomOut, ZoomReset,
};
use crate::explorer::{ExplorerPanel, ExplorerPanelEvent};
use crate::explorer::{ExplorerPanel, ExplorerPanelEvent, confirm_close_upload};
use crate::session::{
ConnectionState, GroupId, SessionId, SessionPanel, SessionStore, SessionStoreEvent,
confirm_delete_group, confirm_delete_session, open_group_dialog, open_session_dialog,
};
use crate::sftp::{
SharedLocalDirectoryProvider, SharedSftpTransportProvider, SshSftpTransportProvider,
SystemLocalDirectoryProvider,
};
use crate::terminal::{
LocalPtyTransportFactory, LocalTerminalId, LocalTerminalPanel, LocalTerminalPanelEvent,
RemoteTerminalId, SharedRemoteTerminalTransportProvider, SharedTerminalTransportFactory,
@@ -71,6 +76,12 @@ enum CenterTab {
LocalTerminal(LocalTerminalId),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PromptOwner {
Terminal(RemoteTerminalId),
Sftp(SessionId, u64),
}
/// The main window content: title bar above the dock, status bar below.
///
/// Owns the session store, the dock and the registry of open per-session
@@ -87,12 +98,14 @@ pub struct Workspace {
local_terminals: HashMap<LocalTerminalId, Entity<LocalTerminalPanel>>,
local_terminal_factory: SharedTerminalTransportFactory,
remote_terminal_provider: SharedRemoteTerminalTransportProvider,
sftp_provider: SharedSftpTransportProvider,
local_directory_provider: SharedLocalDirectoryProvider,
next_remote_terminal_id: u64,
next_local_terminal_id: u64,
/// The center tab displayed most recently; `CloseActiveTab` closes it.
active_tab: Option<CenterTab>,
prompt_queue: VecDeque<(RemoteTerminalId, SessionId, TerminalPrompt)>,
active_prompt: Option<(RemoteTerminalId, SessionId, u64)>,
prompt_queue: VecDeque<(PromptOwner, SessionId, TerminalPrompt)>,
active_prompt: Option<(PromptOwner, SessionId, u64)>,
/// Dispatch target for the title bar and start page: actions sent to it
/// reach the workspace handlers whatever is focused.
focus_handle: FocusHandle,
@@ -103,38 +116,35 @@ impl Workspace {
/// `store` is built by `main` from the database on disk, and by the UI
/// tests from `SessionStore::seed`.
pub fn new(store: Entity<SessionStore>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let known_hosts = crate::app::known_hosts_path()
.unwrap_or_else(|_| crate::app::data_dir().join("known_hosts"));
let secrets = store.read(cx).secrets();
let remote = Arc::new(crate::ssh::SshTerminalTransportProvider::new(
known_hosts,
secrets,
));
Self::new_with_transport_providers(
store,
remote,
Arc::new(LocalPtyTransportFactory),
window,
cx,
)
Self::new_with_local_terminal_factory(store, Arc::new(LocalPtyTransportFactory), window, cx)
}
/// Alternate constructor used by UI tests to avoid launching a real
/// login shell while exercising workspace behavior.
pub fn new_with_local_terminal_factory(
store: Entity<SessionStore>,
local_terminal_factory: SharedTerminalTransportFactory,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let known_hosts = crate::app::known_hosts_path()
.unwrap_or_else(|_| crate::app::data_dir().join("known_hosts"));
let secrets = store.read(cx).secrets();
let remote = Arc::new(crate::ssh::SshTerminalTransportProvider::new(
known_hosts,
secrets,
let connector = crate::ssh::SshConnector::new(
crate::app::data_dir().join("known_hosts"),
store.read(cx).secrets(),
);
let remote = Arc::new(crate::ssh::SshTerminalTransportProvider::with_connector(
connector.clone(),
));
Self::new_with_transport_providers(store, remote, local_terminal_factory, window, cx)
let sftp = Arc::new(SshSftpTransportProvider::new(
connector,
crate::app::data_dir().join("upload-resume"),
));
Self::new_with_services(
store,
remote,
local_terminal_factory,
sftp,
Arc::new(SystemLocalDirectoryProvider),
window,
cx,
)
}
/// Fully injectable constructor used by UI tests: remote sessions never
@@ -145,6 +155,35 @@ impl Workspace {
local_terminal_factory: SharedTerminalTransportFactory,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let connector = crate::ssh::SshConnector::new(
crate::app::data_dir().join("known_hosts"),
store.read(cx).secrets(),
);
let sftp = Arc::new(SshSftpTransportProvider::new(
connector,
crate::app::data_dir().join("upload-resume"),
));
Self::new_with_services(
store,
remote_terminal_provider,
local_terminal_factory,
sftp,
Arc::new(SystemLocalDirectoryProvider),
window,
cx,
)
}
/// Inject every filesystem and transport service; tests require neither a server nor a keychain.
pub fn new_with_services(
store: Entity<SessionStore>,
remote_terminal_provider: SharedRemoteTerminalTransportProvider,
local_terminal_factory: SharedTerminalTransportFactory,
sftp_provider: SharedSftpTransportProvider,
local_directory_provider: SharedLocalDirectoryProvider,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let focus_handle = cx.focus_handle();
let recent = cx.new(|cx| RecentSessions::new(store.clone(), focus_handle.clone(), cx));
@@ -180,7 +219,9 @@ impl Workspace {
.cloned()
.collect();
if !panels.is_empty() {
this.cancel_prompts_for_session(*id, window, cx);
for panel in &panels {
this.cancel_prompts_for_terminal(panel.read(cx).id(), window, cx);
}
this.store.update(cx, |store, cx| {
store.set_state(*id, ConnectionState::Connecting, cx)
});
@@ -260,6 +301,8 @@ impl Workspace {
local_terminals: HashMap::new(),
local_terminal_factory,
remote_terminal_provider,
sftp_provider,
local_directory_provider,
next_remote_terminal_id,
next_local_terminal_id: 1,
active_tab: None,
@@ -316,7 +359,7 @@ impl Workspace {
fn enqueue_prompt(
&mut self,
terminal_id: RemoteTerminalId,
terminal_id: PromptOwner,
session_id: SessionId,
prompt: TerminalPrompt,
window: &mut Window,
@@ -354,7 +397,7 @@ impl Workspace {
let Some(next) = self.prompt_queue.pop_front() else {
return;
};
if self.terminals.contains_key(&next.0) {
if self.prompt_owner_is_live(next.0, cx) {
break next;
}
};
@@ -550,7 +593,7 @@ impl Workspace {
fn finish_prompt(
&mut self,
terminal_id: RemoteTerminalId,
terminal_id: PromptOwner,
request_id: u64,
reply: TerminalPromptReply,
cx: &mut Context<Self>,
@@ -563,37 +606,74 @@ impl Workspace {
}
self.active_prompt = None;
let canceled = matches!(&reply, TerminalPromptReply::Cancel);
if let Some(panel) = self.terminals.get(&terminal_id).cloned() {
panel.read(cx).reply_to_prompt(request_id, reply, cx);
if canceled {
panel.update(cx, |panel, cx| panel.cancel_connection(cx));
self.refresh_session_connection_state(session_id, cx);
self.reply_to_owner(terminal_id, request_id, reply, cx);
if canceled
&& let PromptOwner::Terminal(id) = terminal_id
&& let Some(panel) = self.terminals.get(&id).cloned()
{
panel.update(cx, |panel, cx| panel.cancel_connection(cx));
}
self.refresh_session_connection_state(session_id, cx);
}
fn prompt_owner_is_live(&self, owner: PromptOwner, cx: &App) -> bool {
match owner {
PromptOwner::Terminal(id) => self.terminals.contains_key(&id),
PromptOwner::Sftp(id, generation) => self
.explorers
.get(&id)
.is_some_and(|p| p.read(cx).generation() == generation),
}
}
fn reply_to_owner(
&self,
owner: PromptOwner,
request_id: u64,
reply: TerminalPromptReply,
cx: &App,
) {
match owner {
PromptOwner::Terminal(id) => {
if let Some(panel) = self.terminals.get(&id) {
panel.read(cx).reply_to_prompt(request_id, reply, cx);
}
}
PromptOwner::Sftp(id, generation) => {
if let Some(panel) = self.explorers.get(&id)
&& panel.read(cx).generation() == generation
{
panel.read(cx).reply_to_prompt(request_id, reply);
}
}
}
}
fn cancel_prompts_for_owner(
&mut self,
owner: PromptOwner,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.prompt_queue.retain(|(id, _, _)| *id != owner);
if let Some((id, _, request_id)) = self.active_prompt
&& id == owner
{
self.active_prompt = None;
self.reply_to_owner(id, request_id, TerminalPromptReply::Cancel, cx);
if window.has_active_dialog(cx) {
window.close_dialog(cx);
}
}
}
fn cancel_prompts_for_terminal(
&mut self,
terminal_id: RemoteTerminalId,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.prompt_queue.retain(|(id, _, _)| *id != terminal_id);
if let Some((id, _, request_id)) = self.active_prompt
&& id == terminal_id
{
self.active_prompt = None;
if let Some(panel) = self.terminals.get(&terminal_id) {
panel
.read(cx)
.reply_to_prompt(request_id, TerminalPromptReply::Cancel, cx);
}
if window.has_active_dialog(cx) {
window.close_dialog(cx);
}
}
self.cancel_prompts_for_owner(PromptOwner::Terminal(terminal_id), window, cx);
}
fn cancel_prompts_for_session(
&mut self,
session_id: SessionId,
@@ -601,18 +681,10 @@ impl Workspace {
cx: &mut Context<Self>,
) {
self.prompt_queue.retain(|(_, id, _)| *id != session_id);
if let Some((terminal_id, id, request_id)) = self.active_prompt
if let Some((owner, id, _)) = self.active_prompt
&& id == session_id
{
self.active_prompt = None;
if let Some(panel) = self.terminals.get(&terminal_id) {
panel
.read(cx)
.reply_to_prompt(request_id, TerminalPromptReply::Cancel, cx);
}
if window.has_active_dialog(cx) {
window.close_dialog(cx);
}
self.cancel_prompts_for_owner(owner, window, cx);
}
}
@@ -655,6 +727,13 @@ impl Workspace {
| TerminalLifecycle::Closing => {}
}
}
if let Some(panel) = self.explorers.get(&session_id) {
match panel.read(cx).connection_state() {
ConnectionState::Connected => has_running = true,
ConnectionState::Connecting => has_starting = true,
ConnectionState::Disconnected => {}
}
}
let state = if has_running {
ConnectionState::Connected
} else if has_starting {
@@ -709,7 +788,7 @@ impl Workspace {
if self.store.read(cx).session(id).is_none() {
return;
}
let (panel, subscription) = new_explorer_panel(&self.store, id, window, cx);
let (panel, subscription) = new_explorer_panel(self, id, window, cx);
self._subscriptions.push(subscription);
self.explorers.insert(id, panel.clone());
self.dock_area.update(cx, |area, cx| {
@@ -756,10 +835,88 @@ impl Workspace {
cx: &mut Context<Self>,
) {
if let Some(panel) = self.explorers.get(&action.0).cloned() {
let generation = panel.read(cx).generation();
if panel.read(cx).is_uploading() {
confirm_close_upload(action.0, generation, self.focus_handle.clone(), window, cx);
} else {
self.remove_explorer(action.0, window, cx);
}
}
}
fn remove_explorer(&mut self, id: SessionId, window: &mut Window, cx: &mut Context<Self>) {
if let Some(panel) = self.explorers.get(&id).cloned() {
self.cancel_prompts_for_owner(
PromptOwner::Sftp(id, panel.read(cx).generation()),
window,
cx,
);
self.dock_area
.update(cx, |area, cx| area.remove_panel(panel, window, cx));
}
}
fn on_explorer_action(
&mut self,
action: &ExplorerAction,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(panel) = self.explorers.get(&action.session()).cloned() else {
return;
};
if action
.generation()
.is_some_and(|g| g != panel.read(cx).generation())
{
return;
}
if matches!(action.command(), ExplorerCommand::CancelUpload) {
self.cancel_prompts_for_owner(
PromptOwner::Sftp(action.session(), panel.read(cx).generation()),
window,
cx,
);
}
if matches!(action.command(), ExplorerCommand::CloseConfirmed) {
self.remove_explorer(action.session(), window, cx);
} else {
panel.update(cx, |panel, cx| panel.execute(action.command(), window, cx));
}
}
fn explorer_shortcut(
&mut self,
command: ExplorerCommand,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(CenterTab::Explorer(id)) = self.active_tab {
self.on_explorer_action(&ExplorerAction::new(id, command), window, cx);
}
}
fn on_upload_selected(
&mut self,
_: &UploadSelectedFiles,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.explorer_shortcut(ExplorerCommand::UploadSelected, window, cx);
}
fn on_toggle_upload_selection(
&mut self,
_: &ToggleUploadSelection,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.explorer_shortcut(ExplorerCommand::ToggleSelection, window, cx);
}
fn on_select_all_upload_files(
&mut self,
_: &SelectAllUploadFiles,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.explorer_shortcut(ExplorerCommand::SelectAll, window, cx);
}
fn on_close_local_terminal(
&mut self,
@@ -854,6 +1011,9 @@ impl Workspace {
for terminal in terminals {
terminal.update(cx, |terminal, cx| terminal.disconnect(cx));
}
if let Some(panel) = self.explorers.get(&id).cloned() {
panel.update(cx, |panel, cx| panel.disconnect(window, cx));
}
}
fn on_reconnect_terminal(
@@ -919,7 +1079,14 @@ impl Workspace {
let workspace = cx.entity().downgrade();
confirm_delete_session(
&session,
closes_tabs,
(
closes_tabs,
usize::from(
self.explorers
.get(&id)
.is_some_and(|p| p.read(cx).is_uploading()),
),
),
Rc::new(move |window, cx| {
workspace
.update(cx, |this, cx| this.remove_session(id, window, cx))
@@ -1018,7 +1185,17 @@ impl Workspace {
&name,
doomed.len(),
subgroups,
closes_tabs,
(
closes_tabs,
doomed
.iter()
.filter(|id| {
self.explorers
.get(id)
.is_some_and(|p| p.read(cx).is_uploading())
})
.count(),
),
Rc::new(move |window, cx| {
workspace
.update(cx, |this, cx| this.remove_group(id, window, cx))
@@ -1140,9 +1317,14 @@ fn new_terminal_panel(
this.refresh_session_connection_state(*session_id, cx);
cx.notify();
}
TerminalPanelEvent::PromptRequested(terminal_id, session_id, prompt) => {
this.enqueue_prompt(*terminal_id, *session_id, prompt.clone(), window, cx)
}
TerminalPanelEvent::PromptRequested(terminal_id, session_id, prompt) => this
.enqueue_prompt(
PromptOwner::Terminal(*terminal_id),
*session_id,
prompt.clone(),
window,
cx,
),
TerminalPanelEvent::HostOsDetected(session_id, os) => {
let (session_id, os) = (*session_id, *os);
this.store
@@ -1155,7 +1337,7 @@ fn new_terminal_panel(
fn resolve_prompt(
workspace: &WeakEntity<Workspace>,
terminal_id: RemoteTerminalId,
terminal_id: PromptOwner,
request_id: u64,
reply: TerminalPromptReply,
cx: &mut App,
@@ -1214,22 +1396,51 @@ impl Render for AuthenticationPromptForm {
}
fn new_explorer_panel(
store: &Entity<SessionStore>,
workspace: &Workspace,
id: SessionId,
window: &mut Window,
cx: &mut Context<Workspace>,
) -> (Entity<ExplorerPanel>, Subscription) {
let panel = cx.new(|cx| ExplorerPanel::new(id, store.clone(), window, cx));
let subscription = cx.subscribe(
let panel = cx.new(|cx| {
ExplorerPanel::new(
id,
workspace.store.clone(),
workspace.sftp_provider.clone(),
workspace.local_directory_provider.clone(),
workspace.focus_handle.clone(),
window,
cx,
)
});
let subscription = cx.subscribe_in(
&panel,
|this, _, event: &ExplorerPanelEvent, _| match event {
window,
|this, _, event: &ExplorerPanelEvent, window, cx| match event {
ExplorerPanelEvent::Activated(id) => this.active_tab = Some(CenterTab::Explorer(*id)),
ExplorerPanelEvent::Closed(id) => {
this.explorers.remove(id);
if this.active_tab == Some(CenterTab::Explorer(*id)) {
this.active_tab = None;
}
this.refresh_session_connection_state(*id, cx);
}
ExplorerPanelEvent::StateChanged(id) => {
if let Some(panel) = this.explorers.get(id) {
let generation = panel.read(cx).generation();
this.prompt_queue.retain(|(owner,_,_)| !matches!(owner,PromptOwner::Sftp(s,g) if s == id && *g != generation));
if let Some((owner @ PromptOwner::Sftp(s,g),_,_)) = this.active_prompt && s == *id && g != generation {
this.cancel_prompts_for_owner(owner,window,cx);
}
}
this.refresh_session_connection_state(*id,cx);
},
ExplorerPanelEvent::PromptRequested(id, generation, prompt) => this.enqueue_prompt(
PromptOwner::Sftp(*id, *generation),
*id,
prompt.clone(),
window,
cx,
),
},
);
(panel, subscription)
@@ -1296,6 +1507,10 @@ impl Render for Workspace {
.on_action(cx.listener(Self::on_disconnect_session))
.on_action(cx.listener(Self::on_reconnect_terminal))
.on_action(cx.listener(Self::on_open_explorer))
.on_action(cx.listener(Self::on_explorer_action))
.on_action(cx.listener(Self::on_upload_selected))
.on_action(cx.listener(Self::on_toggle_upload_selection))
.on_action(cx.listener(Self::on_select_all_upload_files))
.on_action(cx.listener(Self::on_close_terminal))
.on_action(cx.listener(Self::on_close_explorer))
.on_action(cx.listener(Self::on_close_local_terminal))
+660 -7
View File
@@ -21,6 +21,10 @@ use shellr::session::{
AuthKind, ConnectionState, GroupId, HostOs, SessionDatabase, SessionDraft, SessionId,
SessionStore,
};
use shellr::sftp::{
DirectoryEntry, DirectoryListing, EntryKind, FileMetadata, LocalDirectoryProvider, RemotePath,
SftpCommand, SftpEvent, SftpTransport, SftpTransportProvider, UploadRequest,
};
use shellr::terminal::{
FixedRemoteTerminalTransportProvider, LocalTerminalId, RemoteTerminalId, TerminalLifecycle,
TerminalPrompt, TerminalPromptField, TerminalPromptKind, TerminalPromptReply, TerminalSize,
@@ -57,10 +61,12 @@ fn open_workspace_with_store(
FakeTerminalFactory::default(),
)));
let view = cx.new(|cx| {
Workspace::new_with_transport_providers(
Workspace::new_with_services(
store,
remote,
Arc::new(FakeTerminalFactory::default()),
Arc::new(FakeSftpProvider::default()),
Arc::new(FakeLocalDirectory),
window,
cx,
)
@@ -209,7 +215,15 @@ fn open_workspace_with_factory(
FakeTerminalFactory::default(),
)));
let view = cx.new(|cx| {
Workspace::new_with_transport_providers(store, remote, factory.clone(), window, cx)
Workspace::new_with_services(
store,
remote,
factory.clone(),
Arc::new(FakeSftpProvider::default()),
Arc::new(FakeLocalDirectory),
window,
cx,
)
});
workspace = Some(view.clone());
Root::new(view, window, cx)
@@ -228,10 +242,12 @@ fn open_workspace_with_remote_factory(
let store = cx.new(|_| store);
let remote = Arc::new(FixedRemoteTerminalTransportProvider::new(factory.clone()));
let view = cx.new(|cx| {
Workspace::new_with_transport_providers(
Workspace::new_with_services(
store,
remote,
Arc::new(FakeTerminalFactory::default()),
Arc::new(FakeSftpProvider::default()),
Arc::new(FakeLocalDirectory),
window,
cx,
)
@@ -618,7 +634,7 @@ async fn connection_edits_reconnect_once_but_display_edits_do_not(cx: &mut TestA
}
#[gpui_kit::test]
fn opening_mock_sftp_does_not_change_connection_state(cx: &mut TestAppContext) {
async fn opening_sftp_updates_connection_state_without_terminal(cx: &mut TestAppContext) {
let (store, id) = one_session_store(AuthKind::Auto);
let (handle, workspace) = open_workspace_with_store(cx, store);
@@ -627,14 +643,24 @@ fn opening_mock_sftp_does_not_change_connection_state(cx: &mut TestAppContext) {
window.dispatch_action(Box::new(OpenExplorer(id)), cx);
})
.unwrap();
cx.run_until_parked();
cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| {
workspace
.read(cx)
.store()
.read(cx)
.session(id)
.unwrap()
.state
== ConnectionState::Connected
})
.await;
cx.update(|cx| {
let workspace = workspace.read(cx);
assert!(workspace.explorer(id).is_some());
assert_eq!(
workspace.store().read(cx).session(id).unwrap().state,
shellr::session::ConnectionState::Disconnected
shellr::session::ConnectionState::Connected
);
});
}
@@ -863,7 +889,7 @@ fn search_filters_the_tree(cx: &mut TestAppContext) {
}
#[gpui_kit::test]
fn sftp_button_opens_explorer_and_navigates(cx: &mut TestAppContext) {
async fn sftp_button_opens_explorer_and_navigates(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace(cx);
cx.update_window(handle.into(), |_, window, cx| {
@@ -883,6 +909,14 @@ fn sftp_button_opens_explorer_and_navigates(cx: &mut TestAppContext) {
.unwrap();
cx.run_until_parked();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window
.try_find("remote-path")
.is_some_and(|p| p.value() == Some("~"))
})
.await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.find(("explorer", DB_01)).visible());
@@ -895,6 +929,11 @@ fn sftp_button_opens_explorer_and_navigates(cx: &mut TestAppContext) {
.unwrap();
cx.run_until_parked();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.find("remote-path").value() == Some("/home")
})
.await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(window.find("remote-path").value(), Some("/home"));
@@ -2127,3 +2166,617 @@ async fn the_start_page_marks_recent_hosts_with_their_operating_system(cx: &mut
})
.unwrap();
}
#[derive(Default)]
struct FakeSftpProvider {
requests: Arc<Mutex<Vec<UploadRequest>>>,
events: Arc<Mutex<Vec<async_channel::Sender<SftpEvent>>>>,
}
impl SftpTransportProvider for FakeSftpProvider {
fn create(&self, _: &shellr::session::Session) -> Box<dyn SftpTransport> {
Box::new(FakeSftpTransport {
requests: self.requests.clone(),
events: self.events.clone(),
})
}
}
struct FakeSftpTransport {
requests: Arc<Mutex<Vec<UploadRequest>>>,
events: Arc<Mutex<Vec<async_channel::Sender<SftpEvent>>>>,
}
impl SftpTransport for FakeSftpTransport {
fn run(
self: Box<Self>,
commands: async_channel::Receiver<SftpCommand>,
events: async_channel::Sender<SftpEvent>,
) -> anyhow::Result<()> {
use shellr::sftp::{UploadChoice, UploadPhase, UploadProgress};
self.events.lock().unwrap().push(events.clone());
events.send_blocking(SftpEvent::Connected {
home: RemotePath::new("/home/tester")?,
})?;
while let Ok(command) = commands.recv_blocking() {
match command {
SftpCommand::List { request_id, path } => {
if path.as_str() == "/slow" {
continue;
}
let result = match path.as_str() {
"/denied" => Err("权限不足".into()),
"/empty" => Ok(DirectoryListing::new("/empty", vec![])),
_ => Ok(fake_listing(path.as_str())),
};
events.send_blocking(SftpEvent::Listed { request_id, result })?;
}
SftpCommand::Upload(request) => {
self.requests.lock().unwrap().push(request);
events.send_blocking(SftpEvent::Progress(Default::default()))?;
}
SftpCommand::Cancel => {
events.send_blocking(SftpEvent::Progress(UploadProgress::new(
UploadPhase::Stopped,
)))?;
}
SftpCommand::Resume => {
events.send_blocking(SftpEvent::Progress(UploadProgress::new(
UploadPhase::Uploading,
)))?;
}
SftpCommand::Answer { answer, .. } => {
events.send_blocking(SftpEvent::Progress(UploadProgress::new(
if answer.choice() == UploadChoice::Cancel {
UploadPhase::Stopped
} else {
UploadPhase::Completed
},
)))?;
}
SftpCommand::Shutdown => break,
_ => {}
}
}
Ok(())
}
}
struct FakeLocalDirectory;
impl LocalDirectoryProvider for FakeLocalDirectory {
fn home(&self) -> std::path::PathBuf {
"/local/tester".into()
}
fn list(&self, path: &std::path::Path) -> anyhow::Result<DirectoryListing> {
Ok(fake_listing(path.to_str().unwrap()))
}
}
fn fake_listing(path: &str) -> DirectoryListing {
DirectoryListing::new(
path,
vec![
DirectoryEntry::new(
"文件 甲.txt",
FileMetadata::new(EntryKind::File, 12, Some(100), Some(0o644)),
),
DirectoryEntry::new(
"文件 乙.txt",
FileMetadata::new(EntryKind::File, 34, Some(200), Some(0o644)),
),
DirectoryEntry::new(
"目录",
FileMetadata::new(EntryKind::Directory, 0, None, Some(0o755)),
),
],
)
}
fn open_workspace_with_sftp(
cx: &mut TestAppContext,
provider: Arc<FakeSftpProvider>,
) -> (WindowHandle<Root>, Entity<Workspace>) {
cx.update(shellr::init);
let mut workspace = None;
let handle = cx.open_window(size(px(1280.), px(800.)), |window, cx| {
let store = cx.new(|_| SessionStore::seed());
let remote = Arc::new(FixedRemoteTerminalTransportProvider::new(Arc::new(
FakeTerminalFactory::default(),
)));
let view = cx.new(|cx| {
Workspace::new_with_services(
store,
remote,
Arc::new(FakeTerminalFactory::default()),
provider,
Arc::new(FakeLocalDirectory),
window,
cx,
)
});
workspace = Some(view.clone());
Root::new(view, window, cx)
});
(handle, workspace.unwrap())
}
async fn open_test_explorer(cx: &mut TestAppContext, handle: WindowHandle<Root>) {
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.dispatch_action(Box::new(OpenExplorer(SessionId(DB_01))), cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window
.try_find("remote-path")
.is_some_and(|p| p.value() == Some("~"))
&& window
.within(("local-pane", DB_01))
.try_find("file:文件 甲.txt")
.is_some()
})
.await;
}
#[gpui_kit::test]
async fn sftp_multi_selection_keyboard_upload_freezes_paths_and_cancel_resumes(
cx: &mut TestAppContext,
) {
use shellr::app::{ExplorerAction, ExplorerCommand};
let provider = Arc::new(FakeSftpProvider::default());
let (handle, workspace) = open_workspace_with_sftp(cx, provider.clone());
open_test_explorer(cx, handle).await;
cx.update_window(handle.into(), |_, window, cx| {
window
.within(("local-pane", DB_01))
.click("file:文件 甲.txt", cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.press("space", cx);
})
.unwrap();
cx.run_until_parked();
cx.update(|cx| {
assert_eq!(
workspace
.read(cx)
.explorer(SessionId(DB_01))
.unwrap()
.read(cx)
.local()
.read(cx)
.upload_sources(),
vec![std::path::PathBuf::from("/local/tester/文件 甲.txt")]
);
});
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.press("cmd-a", cx);
})
.unwrap();
cx.run_until_parked();
cx.update(|cx| {
assert_eq!(
workspace
.read(cx)
.explorer(SessionId(DB_01))
.unwrap()
.read(cx)
.local()
.read(cx)
.upload_sources()
.len(),
3
);
});
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.press("f5", cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.find("upload-confirm").visible());
window.click("upload-target", cx);
window.press("cmd-a", cx);
window.input("/固定目标", cx);
window.click("upload-confirm", cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.try_find("cancel-upload").is_some()
})
.await;
assert_eq!(
provider.requests.lock().unwrap()[0].destination().as_str(),
"/固定目标"
);
assert_eq!(provider.requests.lock().unwrap()[0].sources().len(), 3);
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.dispatch_action(
Box::new(ExplorerAction::new(
SessionId(DB_01),
ExplorerCommand::Navigate {
remote: true,
path: "/other".into(),
},
)),
cx,
);
window.click("cancel-upload", cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.try_find("resume-upload").is_some()
})
.await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click("resume-upload", cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.try_find("cancel-upload").is_some()
})
.await;
assert_eq!(provider.requests.lock().unwrap().len(), 1);
assert_eq!(
provider.requests.lock().unwrap()[0].destination().as_str(),
"/固定目标"
);
}
#[gpui_kit::test]
async fn sftp_native_picker_and_external_drop_share_confirmation(cx: &mut TestAppContext) {
let provider = Arc::new(FakeSftpProvider::default());
let (handle, _) = open_workspace_with_sftp(cx, provider);
open_test_explorer(cx, handle).await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click("choose-upload", cx);
})
.unwrap();
cx.run_until_parked();
assert!(cx.did_prompt_for_paths());
cx.simulate_path_prompt_response(|options| {
assert!(options.files && options.directories && options.multiple);
Some(vec!["/tmp/甲".into(), "/tmp/目录".into()])
});
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.find("upload-confirm").visible());
assert_eq!(window.find("upload-target").value(), Some("/home/tester"));
window.press("escape", cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.within(("remote-pane", DB_01)).hover("file:目录", cx);
window.render_frame(cx);
let position = window
.within(("remote-pane", DB_01))
.find("file:目录")
.bounds()
.center();
let paths =
gpui_kit::ExternalPaths(vec![std::path::PathBuf::from("/tmp/Finder 文件")].into());
window.dispatch_event(
gpui_kit::FileDropEvent::Entered { position, paths }.to_platform_input(),
cx,
);
window.dispatch_event(
gpui_kit::FileDropEvent::Submit { position }.to_platform_input(),
cx,
);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.find("upload-confirm").visible());
assert_eq!(
window.find("upload-target").value(),
Some("/home/tester/目录")
);
})
.unwrap();
}
#[gpui_kit::test]
async fn sftp_internal_drag_conflict_and_close_confirmation(cx: &mut TestAppContext) {
use shellr::sftp::{UploadQuestion, UploadQuestionKind};
let provider = Arc::new(FakeSftpProvider::default());
let (handle, workspace) = open_workspace_with_sftp(cx, provider.clone());
open_test_explorer(cx, handle).await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
let from = window
.within(("local-pane", DB_01))
.find("file:文件 甲.txt")
.bounds()
.center();
let to = window
.within(("remote-pane", DB_01))
.find("file:目录")
.bounds()
.center();
window.drag(from, to, cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(
window.find("upload-target").value(),
Some("/home/tester/目录")
);
window.click("upload-confirm", cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.try_find("cancel-upload").is_some()
})
.await;
provider.events.lock().unwrap()[0]
.send_blocking(SftpEvent::Question(UploadQuestion::new(
900,
UploadQuestionKind::Conflict,
"/home/tester/目录/文件 甲.txt",
"目标已存在",
)))
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.try_find("upload-question-confirm").is_some()
})
.await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click("upload-apply-all", cx);
window.click("upload-question-cancel", cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.try_find("resume-upload").is_some()
})
.await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click("resume-upload", cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.try_find("cancel-upload").is_some()
})
.await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click(("close-explorer", DB_01), cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.find("ok").visible());
assert!(workspace.read(cx).explorer(SessionId(DB_01)).is_some());
window.click("ok", cx);
})
.unwrap();
cx.run_until_parked();
cx.update(|cx| assert!(workspace.read(cx).explorer(SessionId(DB_01)).is_none()));
}
#[gpui_kit::test]
async fn sftp_sort_range_selection_and_dialog_focus_preserve_path_identity(
cx: &mut TestAppContext,
) {
use shellr::app::{ExplorerAction, ExplorerCommand};
let provider = Arc::new(FakeSftpProvider::default());
let (handle, workspace) = open_workspace_with_sftp(cx, provider);
open_test_explorer(cx, handle).await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window
.within(("local-pane", DB_01))
.click("check:文件 乙.txt", cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window
.within(("local-pane", DB_01))
.click(("col-header", 1usize), cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window
.within(("local-pane", DB_01))
.click(("col-header", 1usize), cx);
})
.unwrap();
cx.run_until_parked();
cx.update(|cx| {
assert_eq!(
workspace
.read(cx)
.explorer(SessionId(DB_01))
.unwrap()
.read(cx)
.local()
.read(cx)
.upload_sources(),
vec![std::path::PathBuf::from("/local/tester/文件 乙.txt")]
)
});
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.dispatch_action(
Box::new(ExplorerAction::new(
SessionId(DB_01),
ExplorerCommand::Check {
name: "文件 甲.txt".into(),
checked: true,
extend: true,
},
)),
cx,
);
})
.unwrap();
cx.run_until_parked();
cx.update(|cx| {
assert_eq!(
workspace
.read(cx)
.explorer(SessionId(DB_01))
.unwrap()
.read(cx)
.local()
.read(cx)
.upload_sources()
.len(),
2
)
});
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window
.within(("local-pane", DB_01))
.click("file:文件 甲.txt", cx);
window.press("f5", cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.find("upload-confirm").visible());
window.press("escape", cx);
})
.unwrap();
cx.run_until_parked();
// F5 works again without clicking a pane after dismissing the dialog.
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.press("f5", cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.find("upload-confirm").visible());
})
.unwrap();
}
#[gpui_kit::test]
async fn sftp_discards_stale_directory_replies(cx: &mut TestAppContext) {
use shellr::app::{ExplorerAction, ExplorerCommand};
let provider = Arc::new(FakeSftpProvider::default());
let (handle, workspace) = open_workspace_with_sftp(cx, provider.clone());
open_test_explorer(cx, handle).await;
for path in ["/slow", "/fresh"] {
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.dispatch_action(
Box::new(ExplorerAction::new(
SessionId(DB_01),
ExplorerCommand::Navigate {
remote: true,
path: path.into(),
},
)),
cx,
);
})
.unwrap();
cx.run_until_parked();
}
cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| {
workspace
.read(cx)
.explorer(SessionId(DB_01))
.unwrap()
.read(cx)
.remote()
.read(cx)
.path()
== "/fresh"
})
.await;
provider.events.lock().unwrap()[0]
.send_blocking(SftpEvent::Listed {
request_id: 2,
result: Ok(fake_listing("/slow")),
})
.unwrap();
// Follow with an ordinary command and wait for its reply to ensure both events were consumed.
cx.update_window(handle.into(), |_, window, cx| {
window.dispatch_action(
Box::new(ExplorerAction::new(
SessionId(DB_01),
ExplorerCommand::Refresh { remote: true },
)),
cx,
);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.find("remote-path").value() == Some("/fresh")
})
.await;
cx.update(|cx| {
assert_eq!(
workspace
.read(cx)
.explorer(SessionId(DB_01))
.unwrap()
.read(cx)
.remote()
.read(cx)
.path(),
"/fresh"
)
});
}
#[gpui_kit::test]
async fn sftp_controls_fit_small_window_in_light_dark_and_zoom(cx: &mut TestAppContext) {
use gpui_kit::component::{Theme, ThemeMode};
let provider = Arc::new(FakeSftpProvider::default());
let (handle, _) = open_workspace_with_sftp(cx, provider);
open_test_explorer(cx, handle).await;
for mode in [ThemeMode::Light, ThemeMode::Dark] {
for zoom in [12., 20.] {
cx.update_window(handle.into(), |_, window, cx| {
Theme::change(mode, Some(window), cx);
Theme::global_mut(cx).font_size = px(zoom);
window.resize(size(px(960.), px(600.)));
window.render_frame(cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
let local = window.find(("local-pane", DB_01)).bounds();
let upload = window.find("upload").bounds();
let choose = window.find("choose-upload").bounds();
assert!(upload.left() >= local.left() && choose.right() <= local.right());
assert_eq!(upload.top(), choose.top());
assert!(window.find("remote-path").visible());
})
.unwrap();
}
}
}