实现真实 SFTP 上传与 WinSCP 式断点续传
This commit is contained in:
1 parent
698b9ed925
commit
23da7a6724
34 files changed
+7137
-1651
No files matched your search
@@ -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` 占同样宽度的槽位,否则标签对不齐。
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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)。
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
@@ -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())),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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())
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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]
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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, ¤t, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user