SFTP 双栏对标 WinSCP:两行工具栏、WinSCP 列、多选、右键菜单、文件操作与下载

This commit is contained in:
Xuz committed 2026-09-25 01:54:32 +08:00
1 parent adbd1de688
commit 4badde667f
38 files changed
+7022 -1056

No files matched your search

+14 -7
View File
@@ -6,9 +6,9 @@ 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`),**数据库里永远不出现秘密**。SFTP 双栏浏览与上传也已接入真实文件系统,支持断点续传;下载、目录同步和传输队列尚未实现。详见 `README.md`。
会话和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellr/shellr.db`,`SHELLR_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览、上传、下载和文件操作(删除、重命名、属性、新建)也已接入真实文件系统,上传和下载都支持断点续传;目录同步和传输队列尚未实现。详见 `README.md`。
已确认的产品决定:WinSCP 式双栏文件浏览器是每个会话独立的「SFTP」Dock 标签页;暂不做传输队列和 Dock 布局持久化;首次启动是空库,不预置任何分组或会话;分组支持任意层级嵌套,会话也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的会话一起删,删除前确认并写明数量。
已确认的产品决定:WinSCP 式双栏文件浏览器是每个会话独立的「SFTP」Dock 标签页,操作逻辑对标 WinSCP Commander(两行工具栏、WinSCP 的列、列表右键菜单、高亮多选;本地侧与远程侧对称,本地删除进废纸篓;书签按会话、按本地 / 远程各一份持久化);暂不做传输队列、Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或会话;分组支持任意层级嵌套,会话也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的会话一起删,删除前确认并写明数量。
做 UI 之前先读 `gpui-kit` 与 `gpui-kit-design-guides` 两个技能(其中的 Coding Guides 和 Design Guides 在本仓库是规范,不是参考)。不要凭记忆臆造 gpui-kit API:以 `~/.cargo/registry/src/*/gpui-component-0.6.1/`、`gpui-base-0.6.1/` 的源码或 `https://gpui-kit.com/component/<name>.md` 为准。
@@ -43,15 +43,22 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- `ssh/` — `probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`:真实的 russh 客户端。known_hosts 校验写 `<data_dir>/known_hosts`(**不碰** `~/.ssh/known_hosts`),认证链是 agent → publickey → password → keyboard-interactive。密码和私钥口令先查 `secrets`,查不到或被服务器拒绝才走 prompt 弹框;被拒绝时**不删**钥匙串条目,只在弹框说明里讲清原因。shell 起来后主循环每 5 秒用 `Handle::send_ping()`(`keepalive@openssh.com`,服务器回成功或失败都算回复)量一次往返,5 秒没回就记为超时,结果以 `TerminalTransportEvent::Latency` 送出;引擎把最新一次存成状态(和 `lifecycle` 一样,重启、退出、失败时清空),`TerminalPanel::title_suffix` 在标签栏右侧显示:< 100 ms 用 `success`,100–200 ms 用 `warning`,> 200 ms 或超时用 `danger`,分档在 `Latency::level`。
- **会话对话框的「测试连接」是真登录。** 会话模块不依赖 `ssh`:`connection.rs` 定义 `LoginTest`(表单当前值,秘密用 `Zeroizing`,`Debug` 打码)和 `ConnectionTester` 接口,工作区用和终端 / SFTP 共享的 `SshConnector` 造 `ssh::SshConnectionTester` 注入进去(`Workspace::new_with_services` 的最后一个服务参数,UI 测试注入 `FakeConnectionTester`)。测试用的秘密来源 `FormSecrets` 只读:本端点的登录密码、表单所选私钥的口令只认表单里的值(钥匙串里的旧密码不参与),其余条目回退钥匙串,从不写入。`SshPrompts::non_interactive()` 让缺密码 / 口令 / 键盘交互直接以带类型的 `MissingCredential` 失败而不弹框,失败原因由 `tester.rs` 的 `describe_test_failure` 按类型映射成中文。陌生主机密钥仍然要问:工作线程通过 channel 把问题交给表单,表单每 50 ms 轮询,在会话对话框上方弹「首次连接此主机」,信任就写入 known_hosts;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。
- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制会话都不丢,同一台机器的多个会话共用一条),私钥口令按文件路径(同一把钥匙只问一次)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。
- `explorer/` — 目录与选择快照、`DataTable` 双栏、上传确认 / 冲突对话框、进度和取消 / 恢复展示。`ExplorerAction` 统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。
- `sftp/` — 可注入传输与本地目录接口、`RemotePath`、russh-sftp 3.0.0 协议适配、有界并发分块上传、UUID 临时文件、安全替换、原子 JSON 续传记录和三次自动重连。`ssh/connection.rs` 的 `SshConnector` 与终端共享认证、钥匙串服务及 known_hosts 写锁。
- `explorer/` — SFTP 标签页的界面。`explorer_panel.rs`(连接、事件轮询、`execute` 执行全部 `ExplorerCommand`、进度条)、`file_pane.rs`(一侧面板:两行工具栏、目录列表 `Select`、路径栏、列表容器上的右键菜单和拖放)、`file_listing.rs`(`DataTable` 委托:WinSCP 的列、选中行 / 光标的绘制、行点击与拖动)、`selection.rs`(纯函数的多选模型)、`history.rs`(后退 / 前进)、`pane_menu.rs`(右键、书签、新建菜单)、`pane_operations.rs`(把确认过的文件操作派给 SFTP 工作线程或本地提供者,完成后刷新)、`file_dialogs.rs`(删除确认、重命名 / 新建的名称表单)、`properties_dialog.rs`(权限 3×3 与八进制,`PermissionDraft` 是纯函数)、`transfer_dialog.rs`(上传 / 下载确认、冲突 / 续传 / 出错问答、关闭确认)、`model.rs`(`FileEntry` 保存原始值,`format_kilobytes` / `format_changed` / `format_rights` 负责显示)。`ExplorerAction` 统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。
- `sftp/` — 可注入传输与本地目录接口(`LocalDirectoryProvider` 也负责本地的废纸篓、重命名、新建和改权限)、`RemotePath`、russh-sftp 3.0.0 协议适配(`RemoteFs`:lstat / stat / read_dir / 读写 / rmdir / setstat;`list` 解析 `longname` 得到所有者,并发 `stat` 解析链接目标)、`operations.rs`(删除、重命名、新建、递归改权限,显式栈遍历,从不跟随链接)、`upload.rs` / `download.rs`(有界并发分块传输、`.filepart`、冲突与续传问答)、`journal.rs`(原子 JSON 续传记录:上传在 `upload-resume/`,下载在 `download-resume/`)、`worker.rs`(`TransferBatch` 二选一、三次自动重连)。`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`、`close_tab_items`)。
关键流程与不变量:
- **SFTP 独立于终端。** 会话状态综合所有终端与 SFTP 状态计算;关闭终端不停止上传,断开 / 删除会话会停止上传并保留续传数据。每会话一个运行批次,来源、端点和目标在确认时固定。认证问答按终端 / SFTP 来源与 SFTP 连接代次隔离。
- **SFTP UI 测试完全注入。** 使用 `Workspace::new_with_services` 注入目录与传输,不访问用户服务器和钥匙串。工作线程事件由 UI 定时轮询,禁止后台线程直接唤醒 GPUI 前台任务;实体回调中的 SFTP 动作通过 `ExplorerDispatch` 延后派发,避免重入实体。
- **SFTP 独立于终端。** 会话状态综合所有终端与 SFTP 状态计算;关闭终端不停止传输,断开 / 删除会话会停止传输并保留续传数据。每会话一个运行批次,上传和下载共用(`TransferBatch`,类型统一叫 `Transfer*`,方向在 `TransferProgress::direction`),来源、端点和目标在确认时固定。认证问答按终端 / SFTP 来源与 SFTP 连接代次隔离。
- **下载严格按顺序写盘。** 读请求并发且可能乱序、短读返回,`download_file` 用重排缓冲区按偏移顺序写入 `.filepart`,因为续传只信它的长度。远程文件的大小和修改时间与续传记录不符时不续传,只能重新开始或跳过。
- **文件操作从不跟随符号链接。** 删除只删链接本身,改权限跳过链接,下载 / 上传原样创建链接。本地删除走 `trash` crate 的 NSFileManager 方式(不弹自动化权限、不放声音)。文件操作与传输批次可以并行;同一侧面板有操作在跑时(`FilePane::is_busy`)第二行工具栏和对应命令都不接受新操作,结果回来后面板重新读目录。
- **选择归面板,不归表格。** `DataTable` 只支持单行选择,所以表格设成 `row_selectable(false)`,`FilePane` 持有按名称保存的 `Selection`(`..` 永远不进选择集),通过 `ListingContext` 快照交给委托;`render_tr` 自己画选中行的底色和光标框(绝对定位的子元素,不能用 `tr.bg`,否则会被 hover 色盖掉)。行的 `on_click` 读 `ClickEvent` 的修饰键和点击次数,直接更新面板(这是事件时刻,不是渲染时刻)。键盘上的方向键 / Home / End / 翻页绑在 `"LocalFileList > DataTable"` 这一层,盖过表格自带的单行选择键;←、→ 绑 `NoAction`,因为表格在行模式下也会把它们切成列选择。
- **文件列表快捷键按焦点路由。** 一个动作 `ExplorerShortcut(ExplorerCommand)`,侧别写进绑定本身;工作区找 focus handle 含焦点的那个 SFTP 面板(`contains_focus`),不用最近激活的标签,否则两个 SFTP 分栏并排时会发错。同一命令绑多个键时,最后注册的那个会显示在 tooltip 里(`tooltip_with_action`),所以 WinSCP 的键放最后。菜单项派发 `ExplorerAction`,按动作相等查不到绑定,菜单里不显示快捷键。
- **列表右键菜单挂在列表容器 `("file-list", side)` 上**,和会话树一样:行的右键处理器先把没选中的行选中、把行名写进 `menu_hit`,容器在捕获阶段清空它;委托的 `context_menu` 保持默认(空菜单不显示),所以行和空白处都只弹一个菜单。菜单构造器在打开时运行,可以读面板。表格给右键行画的边框要由我们清掉(下一次左键或键盘移动时)。
- **`Select` 必须放进有尺寸的容器。** 它的外层是 `size_full()`,直接放进工具栏行会盖住整行,别的按钮全点不到。
- **书签按会话、按侧持久化。** `bookmarks` 表(schema v6)对 `sessions` 有 `ON DELETE CASCADE`;`remove_unnotified` / `remove_group_unnotified` 必须同时丢掉内存里被删会话的书签。`BookmarkSide` 放在 session 模块,因为 session 不能依赖 explorer。
- **SFTP UI 测试完全注入。** 使用 `Workspace::new_with_services` 注入目录与传输,不访问用户服务器和钥匙串;`FakeLocalDirectory` 只记录本地操作,不真的改文件。工作线程事件由 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 返回给工作区去关标签页。
@@ -83,4 +90,4 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
## 测试约定
UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`,通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity<SessionStore>`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `SessionStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("session-row", id)`、`("session-os", id)` 与开始页的 `("recent-session-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", id)`、`("local-pane"|"remote-pane", id)`、`("close-terminal", id)`、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", session_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)`、`test-connection`、`("terminal-latency", id)`(`aria_label` 是「32 ms」或「超时」)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、`recent-sessions`、`("recent-session", id)`、`recent-new-session`、`session-search`、`new-session`、`new-group`、`theme-toggle`、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`session-name` / `-host` / `-port` / `-user` / `-auth` / `-key-path` / `-password` / `-passphrase`、`group-name`、`remote-path`。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。种子会话 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。
UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`,通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity<SessionStore>`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `SessionStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("session-row", id)`、`("session-os", id)` 与开始页的 `("recent-session-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", id)`、`("local-pane"|"remote-pane", id)`、`("close-terminal", id)`、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", session_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)`、`test-connection`、`("terminal-latency", id)`(`aria_label` 是「32 ms」或「超时」)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、`recent-sessions`、`("recent-session", id)`、`recent-new-session`、`session-search`、`new-session`、`new-group`、`theme-toggle`、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`session-name` / `-host` / `-port` / `-user` / `-auth` / `-key-path` / `-password` / `-passphrase`、`group-name`、`remote-path`。SFTP 相关的还有:各侧面板里的 `path-select`、`bookmarks`、`up`、`root`、`home`、`refresh`、`back`、`forward`、`upload`(本地)/ `download`(远程)、`delete`、`rename`、`properties`、`new`(同名按钮在两侧都有,要用 `within((pane, id))` 查)、行 `file:<名称>` 与名称单元格 `name:<名称>`(`aria_selected` 表示已选中)、`("file-list", 0|1)`、`entry-name`(重命名 / 新建表单)、`("perm", 0..9)` 与 `perm-octal` / `perm-recursive` / `perm-dir-x`(属性对话框)、`download-confirm` / `download-target` / `download-browse`、进度条的 `transfer-status` / `cancel-transfer` / `resume-transfer` / `discard-transfer` / `transfer-details`、问答的 `transfer-question-*` 与 `transfer-apply-all`。按钮的禁用状态 gpui-base 不报给无障碍树,测试里用「是否还能取得焦点」判断(`focused().is_some()`);`TestWindowExt::click` 不带修饰键,⌘ / Shift 单击用 `modified_click` 助手直接发鼠标事件。SFTP 测试的夹具会 `set_reduce_motion(true)`:对话框按真实时间滑入,小目标(复选框、「浏览…」)会在定位和点击之间移位。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。种子会话 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。
Generated
+25
View File
@@ -7423,6 +7423,7 @@ dependencies = [
"sha2 0.10.9",
"tempfile",
"tokio",
"trash",
"unicode-width",
"uuid",
"zeroize",
@@ -8430,6 +8431,24 @@ dependencies = [
"tracing-log",
]
[[package]]
name = "trash"
version = "5.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be89b3fe156965d29ac4f8522f3a640c655affdd9f21cb4f36857f0c92c00317"
dependencies = [
"chrono",
"libc",
"log",
"objc2 0.6.4",
"objc2-foundation 0.3.2",
"once_cell",
"percent-encoding",
"scopeguard",
"urlencoding",
"windows 0.62.2",
]
[[package]]
name = "triomphe"
version = "0.1.16"
@@ -8597,6 +8616,12 @@ dependencies = [
"serde",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "usvg"
version = "0.45.1"
+1
View File
@@ -25,6 +25,7 @@ tokio = { version = "1", features = ["fs", "io-util", "net", "rt", "sync", "time
unicode-width = "0.2"
zeroize = "1"
keyring = "4"
trash = "5"
[dev-dependencies]
tokio = { version = "1", features = ["process", "test-util"] }
+82 -13
View File
@@ -1,6 +1,6 @@
# shellr
基于 GPUI Kit 的中文 SSH 会话管理工具,提供持久化会话与分组、本地终端、SSH 多终端和真实 SFTP 双栏浏览器。
基于 GPUI Kit 的中文 SSH 会话管理工具,提供持久化会话与分组、本地终端、SSH 多终端和对标 WinSCP 的 SFTP 双栏浏览器。
## 会话列表
@@ -17,16 +17,83 @@
- **查找**(⌘F,其他平台 Ctrl+Shift+F):在终端右上角打开查找条,按字面量搜索屏幕和回滚历史。查询全为小写时不区分大小写,含大写字母时区分。所有匹配高亮显示,当前项颜色更深,查找条显示「当前项/总数」。回车或 ⌘G(F3)跳到下一项(更新的输出),Shift+回车或 ⌘⇧G(Shift+F3)跳到上一项(更早的输出),到头后循环。Esc 关闭。打开时如有选中文字,会作为查询词。
- **清屏**(⌘K,其他平台 Ctrl+Shift+K;焦点不在终端时 ⌘K 仍是聚焦会话搜索):清掉屏幕和回滚历史,只留下光标所在的提示符行,包括已输入但未回车的内容。清屏只在本地进行,不向服务器发送任何内容。vim、top 等全屏程序运行时不可用。
## SFTP 上传
## SFTP
打开会话的「SFTP」标签即可浏览本地与远程目录。SFTP 使用独立 SSH 连接,仅请求 SFTP 子系统,不要求服务器提供 Shell,关闭终端不会中止上传。
打开会话的「SFTP」标签即可浏览本地与远程目录,操作方式对标 WinSCP Commander。SFTP 使用独立 SSH 连接,仅请求 SFTP 子系统,不要求服务器提供 Shell;关闭终端不会中止传输。
- 地址输入、上级目录、刷新、列排序和目录双击导航;本地从系统用户目录开始,远程初始目录由服务器查询。
- 本地复选框选择上传项目,空格切换活动行、⌘/Ctrl+A 全选、Shift 点击复选框范围选择。选中集合按路径保存,排序不改变所选项目。
- 使用「上传所选…」、本地列表中的 F5、「选择文件上传…」,或从本地面板 / Finder 拖入远程列表。拖到目录行时使用该目录,其余列表区域使用当前目录。
- 所有入口均显示确认框,可修改目标目录。确认后固定来源、会话端点和目标;每会话同时运行一个批次,批内依次处理,其他会话和终端可继续操作。
- 递归上传包含空目录;符号链接原样创建,不跟随。默认二进制传输,保留普通文件修改时间;新建项使用服务器默认权限,覆盖保留目标基本权限。不复制所有者、ACL 或扩展属性。
- 同名目录合并,文件 / 链接冲突询问覆盖、跳过或取消,可应用于本批剩余冲突。目录与非目录冲突不会自动删除。错误可重试、跳过或取消,完成汇总分别显示成功、跳过和失败。
### 工具栏
每一侧面板都有两行工具栏,下方是可编辑的路径栏(回车前往,`~` 表示主目录):
- 第一行(导航):
- 「目录列表」:列出从根到当前目录的每一级,本地另有主目录、桌面、文稿、下载,选中即前往。
- 书签:列出本会话这一侧的书签,并可添加或移除当前路径。书签保存在本地数据库,删除会话时一并删除。
- 上级目录、根目录、主目录、刷新、后退、前进。
- 第二行(文件):
- 远程是「下载…」,本地是「上传…」,下拉里还有「选择文件上传…」。
- 删除、重命名…、属性…、新建(文件夹… / 文件…)。
列与 WinSCP 相同:本地为名称、大小、类型、修改时间,远程为名称、大小、修改时间、权限、所有者。
- 大小以 KB 显示(`4,008,960 KB`)。
- 时间形如 `2026/4/22 12:44:53`。
- 权限形如 `rwxr-xr-x`。
- 所有者来自服务器的 `ls -l` 行。
- 链接到目录的符号链接显示为带箭头的文件夹,可以双击进入。
### 选择与右键菜单
- 鼠标:单击选中,⌘ 单击(其他平台 Ctrl 单击)增减,Shift 单击选取范围。
- 键盘:↑↓、Home、End、PageUp、PageDown 移动,加 Shift 扩展;空格或 Insert 切换当前行;⌘A 全选;Tab 切到另一侧。
- 右键未选中的行会先选中它。
- 行上的菜单:打开、上传… / 下载…、删除、重命名…、属性…。
- 空白处的菜单:前往(上级 / 根 / 主目录 / 后退 / 前进)、刷新、添加路径到书签、新建。
### 快捷键
| 命令 | 快捷键 |
|---|---|
| 上传 / 下载(按所在面板) | F5 |
| 重命名 | F2 |
| 新建文件夹 | F7 |
| 删除 | F8、Delete;macOS 另有 ⌘⌫ |
| 属性 | F9;macOS 另有 ⌘I |
| 刷新 | ⌘R / Ctrl+R |
| 上级目录 | Backspace;macOS 另有 ⌘↑ |
| 根目录 | ⌘\ / Ctrl+\ |
| 主目录 | macOS ⌘⇧H,其他平台 Ctrl+H |
| 后退 / 前进 | ⌥← / ⌥→;macOS 另有 ⌘[ / ⌘] |
| 添加路径到书签 | ⌘D / Ctrl+D |
| 打开目录 | Enter;macOS 另有 ⌘↓ |
### 文件操作
- 删除前会确认,并写明对象和后果:
- 本地项目移到废纸篓。
- 远程项目永久删除,目录连同其中全部内容。
- 删除符号链接只删除链接本身。
- 重命名和新建:
- 按当前列表检查名称;不能为空,不能是 `.` 或 `..`,不能与已有项目同名。
- 重命名不会覆盖已有项目。
- 属性:
- 显示位置、大小、修改时间,远程另有所有者和组。
- 权限用 3×3 复选框和八进制数字编辑,两者同步。
- 多选时,项目之间不一致的权限位保持原样,除非你改动它。
- 含目录时可以递归应用;递归时默认「为目录添加执行权限(X)」,避免目录无法进入。
- 符号链接不参与修改权限。
### 上传与下载
- 入口:
- 上传:「上传…」、本地列表中的 F5、「选择文件上传…」,或把本地项目 / Finder 中的文件拖到远程列表。
- 下载:「下载…」、远程列表中的 F5,或把远程项目拖到本地列表。
- 拖到目录行时放进该目录,拖到其余区域时放进当前目录。
- 所有入口都显示确认框,可以修改目标目录;下载可以用「浏览…」选择本机目录。
- 确认后来源、会话端点和目标即固定。每会话同时运行一个批次(上传和下载共用),批内依次处理;其他会话和终端可以继续操作。
- 递归传输包含空目录。符号链接原样创建,不跟随。默认二进制传输,保留普通文件的修改时间。
- 新建项使用目标一侧的默认权限,覆盖时保留目标原有的基本权限。不复制所有者、ACL 或扩展属性。
- 同名目录合并。文件或链接冲突时询问覆盖、跳过或取消,可以应用于本批剩余冲突;目录与非目录冲突不会自动删除。
- 出错时可以重试、跳过或取消,完成后的汇总分别列出成功、跳过和失败。
## 断点续传和替换
@@ -34,13 +101,15 @@
续传记录保存在应用数据目录的 `upload-resume/`,采用版本化 JSON 和原子写入,不写入密码、口令或私钥,不改变会话数据库结构。记录用于绑定端点、主机指纹、来源、目标和安全替换阶段。续传方式与 WinSCP 相同:读取远端 `.filepart` 的当前大小,在本地文件中跳过相同长度并继续传输;使用者需确保来源仍是同一版本。上传过程不会为了校验而回读远端文件内容。
网络故障每批最多自动重连三次,间隔 1、3、10 秒。耗尽后使用「继续上传」,手动恢复会重置次数。认证失败、主机密钥变化和主动取消需要用户处理。重连沿用原连接配置,会话编辑不会改变正在上传的目标。
下载同样先写入本机的 `<目标文件名>.filepart`,完成后设置远程的修改时间、fsync,再在同一文件系统内重命名为正式文件(原子替换)。并发的读取请求可能乱序、短读返回,但临时文件严格按顺序写入,因为续传只信它的长度。下载续传记录保存在 `download-resume/`,绑定端点、主机指纹、远程来源及其大小与修改时间、本地目标;远程文件变化后不能续传,只能重新下载或跳过。
取消、关闭标签、断开会话或退出时保留续传记录;关闭正在上传的标签会先确认。重启后不会自动上传,再次选择相同来源和目标时会询问续传。停止状态下可使用「丢弃续传进度」清理该批受管理的临时文件;存在无法判断的恢复备份时会保留文件并说明原因。成功发布的项目自动清理记录和备份。
网络故障每批最多自动重连三次,间隔 1、3、10 秒。耗尽后使用「继续上传」或「继续下载」,手动恢复会重置次数。认证失败、主机密钥变化和主动取消需要用户处理。重连沿用原连接配置,会话编辑不会改变正在传输的目标。
取消、关闭标签、断开会话或退出时保留续传记录;关闭正在传输的标签会先确认。重启后不会自动继续,再次选择相同来源和目标时会询问续传。停止状态下可使用「丢弃续传进度」清理该批受管理的临时文件;存在无法判断的恢复备份时会保留文件并说明原因。成功发布的项目自动清理记录和备份。
文件名不能准确表示时会明确报错。当前 `russh-sftp 3.0.0` 的远端字符串解码会替换无效 UTF-8,浏览器因此拒绝包含替换字符 `U+FFFD` 的远端名称,避免访问错误路径。符号链接沿用 OpenSSH 的 SFTP v3 参数顺序。
**尚未实现:下载、目录同步、传输队列和 Dock 布局持久化。** 界面不会对这些操作显示虚假的传输成功提示。
**尚未实现:目录同步、传输队列、过滤、查找文件、目录树和 Dock 布局持久化。**
## 运行和验证
@@ -55,7 +124,7 @@ 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` 的随机端口,使用测试凭据。限制本地套接字的沙箱会跳过该部分,需要在允许回环套接字的环境运行。
`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 窗口中补充人工验收;无头测试覆盖原生文件拖入事件和面板内部拖放。
+94 -18
View File
@@ -224,6 +224,7 @@ pub struct CloseTabs {
}
/// Commands shared by explorer controls, drops, dialogs and keyboard bindings.
/// `remote` names the pane a command acts on.
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
pub enum ExplorerCommand {
Navigate {
@@ -236,14 +237,92 @@ pub enum ExplorerCommand {
Refresh {
remote: bool,
},
Check {
name: String,
checked: bool,
Root {
remote: bool,
},
Home {
remote: bool,
},
Back {
remote: bool,
},
Forward {
remote: bool,
},
/// Enter or double-click: open the directory under the cursor.
Open {
remote: bool,
},
/// Move keyboard focus to a pane's file list.
FocusPane {
remote: bool,
},
MoveCursor {
remote: bool,
motion: crate::explorer::CursorMotion,
extend: bool,
},
ToggleSelection,
SelectAll,
UploadSelected,
ToggleSelection {
remote: bool,
},
SelectAll {
remote: bool,
},
AddBookmark {
remote: bool,
},
RemoveBookmark {
remote: bool,
path: String,
},
/// F5: upload the local selection or download the remote one.
Transfer {
remote: bool,
},
/// Ask where to download these remote paths, starting from `target`.
DownloadPaths {
paths: Vec<String>,
target: String,
},
BeginDownload {
paths: Vec<String>,
target: String,
},
/// Ask to delete the pane's selection.
Delete {
remote: bool,
},
BeginDelete {
remote: bool,
names: Vec<String>,
},
Rename {
remote: bool,
},
CommitRename {
remote: bool,
from: String,
to: String,
},
New {
remote: bool,
kind: crate::explorer::NewEntryKind,
},
CommitNew {
remote: bool,
kind: crate::explorer::NewEntryKind,
name: String,
},
Properties {
remote: bool,
},
ApplyPermissions {
remote: bool,
names: Vec<String>,
edit: crate::sftp::PermissionEdit,
recursive: bool,
add_x_to_dirs: bool,
},
ChooseFiles,
UploadPaths {
paths: Vec<std::path::PathBuf>,
@@ -255,11 +334,11 @@ pub enum ExplorerCommand {
},
Answer {
request_id: u64,
answer: crate::sftp::UploadAnswer,
answer: crate::sftp::TransferAnswer,
},
CancelUpload,
ResumeUpload,
DiscardUpload,
CancelTransfer,
ResumeTransfer,
DiscardTransfer,
ToggleDetails,
CloseConfirmed,
}
@@ -292,14 +371,11 @@ impl ExplorerAction {
&self.command
}
}
gpui_kit::actions!(
shellr,
[
UploadSelectedFiles,
ToggleUploadSelection,
SelectAllUploadFiles
]
);
/// A file-list key binding. The binding carries the pane side, and the
/// workspace sends it to the explorer that holds focus.
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = shellr, no_json)]
pub struct ExplorerShortcut(pub ExplorerCommand);
/// UI entities dispatch after their update has finished so Workspace can read
/// their latest snapshot without re-entering a leased entity.
+9 -1
View File
@@ -20,7 +20,15 @@ icon_assets!(
FolderTree,
FolderPlus,
Eraser,
ClipboardPaste
ClipboardPaste,
House,
FolderUp,
FolderRoot,
FolderSymlink,
FileSymlink,
FilePlus,
Bookmark,
SquarePen
]
);
+130 -3
View File
@@ -16,6 +16,9 @@ use crate::terminal::{TERMINAL_FIND_KEY_CONTEXT, TERMINAL_KEY_CONTEXT, terminal_
/// Key context of the session panel, for bindings that only apply there.
pub const SESSION_PANEL_CONTEXT: &str = "SessionPanel";
pub const RECENT_SESSIONS_CONTEXT: &str = "RecentSessions";
/// Key contexts of the two SFTP file lists.
pub const LOCAL_FILE_LIST_CONTEXT: &str = "LocalFileList";
pub const REMOTE_FILE_LIST_CONTEXT: &str = "RemoteFileList";
/// Initialize GPUI Kit and everything global to the application.
pub fn init(cx: &mut App) {
@@ -66,9 +69,6 @@ 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)),
KeyBinding::new("enter", ConnectSelected, Some(RECENT_SESSIONS_CONTEXT)),
#[cfg(target_os = "macos")]
@@ -106,5 +106,132 @@ fn key_bindings() -> Vec<KeyBinding> {
]);
}
bindings.extend(terminal_key_bindings());
bindings.extend(file_list_key_bindings());
bindings
}
/// WinSCP's file-panel keys, bound once per pane with the side baked into the
/// command. The movement keys are bound one level deeper, at the table, so
/// they replace `DataTable`'s own single-row selection keys.
fn file_list_key_bindings() -> Vec<KeyBinding> {
use crate::explorer::CursorMotion;
#[cfg(target_os = "macos")]
const PRIMARY: &str = "cmd";
#[cfg(not(target_os = "macos"))]
const PRIMARY: &str = "ctrl";
let mut bindings = Vec::new();
for (context, remote) in [
(LOCAL_FILE_LIST_CONTEXT, false),
(REMOTE_FILE_LIST_CONTEXT, true),
] {
let table = format!("{context} > DataTable");
let bind = |keys: &str, command: ExplorerCommand, context: &str| {
KeyBinding::new(keys, ExplorerShortcut(command), Some(context))
};
// Several keys per command: the last one registered is the one
// tooltips show, so WinSCP's key goes last.
#[cfg(target_os = "macos")]
bindings.extend([
bind("cmd-up", ExplorerCommand::Up { remote }, context),
bind("cmd-[", ExplorerCommand::Back { remote }, context),
bind("cmd-]", ExplorerCommand::Forward { remote }, context),
bind("cmd-backspace", ExplorerCommand::Delete { remote }, context),
bind("cmd-i", ExplorerCommand::Properties { remote }, context),
// ⌘H hides the app on macOS; Finder's home is ⌘⇧H.
bind("cmd-shift-h", ExplorerCommand::Home { remote }, context),
]);
#[cfg(not(target_os = "macos"))]
bindings.push(bind("ctrl-h", ExplorerCommand::Home { remote }, context));
bindings.extend([
bind("backspace", ExplorerCommand::Up { remote }, context),
bind(
&format!("{PRIMARY}-\\"),
ExplorerCommand::Root { remote },
context,
),
bind(
&format!("{PRIMARY}-r"),
ExplorerCommand::Refresh { remote },
context,
),
bind("alt-left", ExplorerCommand::Back { remote }, context),
bind("alt-right", ExplorerCommand::Forward { remote }, context),
// WinSCP's Ctrl+B already toggles the session panel here.
bind(
&format!("{PRIMARY}-d"),
ExplorerCommand::AddBookmark { remote },
context,
),
bind("f2", ExplorerCommand::Rename { remote }, context),
bind(
"f7",
ExplorerCommand::New {
remote,
kind: crate::explorer::NewEntryKind::Folder,
},
context,
),
bind("delete", ExplorerCommand::Delete { remote }, context),
bind("f8", ExplorerCommand::Delete { remote }, context),
bind("f9", ExplorerCommand::Properties { remote }, context),
bind("f5", ExplorerCommand::Transfer { remote }, context),
bind(
"space",
ExplorerCommand::ToggleSelection { remote },
context,
),
bind(
"insert",
ExplorerCommand::ToggleSelection { remote },
context,
),
bind(
&format!("{PRIMARY}-a"),
ExplorerCommand::SelectAll { remote },
context,
),
bind("enter", ExplorerCommand::Open { remote }, context),
#[cfg(target_os = "macos")]
bind("cmd-down", ExplorerCommand::Open { remote }, context),
bind(
"tab",
ExplorerCommand::FocusPane { remote: !remote },
&table,
),
bind(
"shift-tab",
ExplorerCommand::FocusPane { remote: !remote },
&table,
),
KeyBinding::new("left", NoAction {}, Some(&table)),
KeyBinding::new("right", NoAction {}, Some(&table)),
]);
for (key, motion) in [
("up", CursorMotion::Up),
("down", CursorMotion::Down),
("pageup", CursorMotion::PageUp),
("pagedown", CursorMotion::PageDown),
("home", CursorMotion::Home),
("end", CursorMotion::End),
] {
for extend in [false, true] {
let keys = if extend {
format!("shift-{key}")
} else {
key.to_string()
};
bindings.push(bind(
&keys,
ExplorerCommand::MoveCursor {
remote,
motion,
extend,
},
&table,
));
}
}
}
bindings
}
+283 -92
View File
@@ -1,12 +1,16 @@
use super::{FilePane, PaneSide};
use super::{
FilePane, LoadIntent, PaneSide,
pane_operations::{PaneOperation, PendingOperation},
};
use crate::app::ExplorerDispatch as _;
use crate::{
app::{CatalogIcon, CenterTab, CloseExplorer, EditSession, ExplorerAction, ExplorerCommand},
connection::{ConnectionPrompt, ConnectionPromptReply},
session::{ConnectionState, SessionId, SessionStore},
session::{BookmarkSide, ConnectionState, SessionId, SessionStore},
sftp::{
RemotePath, SftpCommand, SftpEvent, SharedLocalDirectoryProvider,
SharedSftpTransportProvider, UploadPhase, UploadProgress, UploadQuestion, UploadRequest,
DownloadRequest, RemotePath, SftpCommand, SftpEvent, SharedLocalDirectoryProvider,
SharedSftpTransportProvider, TransferDirection, TransferPhase, TransferProgress,
TransferQuestion, UploadRequest,
},
shared::{ClosableTabTitle, close_tab_items},
};
@@ -24,6 +28,7 @@ use gpui_kit::component::{
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use std::{
collections::HashMap,
sync::atomic::{AtomicU64, Ordering},
time::Duration,
};
@@ -41,19 +46,24 @@ pub struct ExplorerPanel {
endpoint: String,
store: Entity<SessionStore>,
local: Entity<FilePane>,
remote: Entity<FilePane>,
local_provider: SharedLocalDirectoryProvider,
pub(super) remote: Entity<FilePane>,
pub(super) local_provider: SharedLocalDirectoryProvider,
/// File operations waiting for their result, by request id.
pub(super) operations: HashMap<u64, PendingOperation>,
pub(super) next_operation: u64,
commands: async_channel::Sender<SftpCommand>,
state: ConnectionState,
message: String,
progress: Option<UploadProgress>,
progress: Option<TransferProgress>,
details: bool,
upload_pending: bool,
pub(super) question: Option<UploadQuestion>,
transfer_pending: bool,
pub(super) question: Option<TransferQuestion>,
pub(super) dialog_open: bool,
pub(super) dispatch: FocusHandle,
focus_handle: FocusHandle,
tab_group: Option<WeakEntity<TabGroup>>,
/// The pane that held focus last, which takes it back on activation.
last_remote: bool,
_subscriptions: Vec<Subscription>,
_events: Task<()>,
}
@@ -74,11 +84,18 @@ impl ExplorerPanel {
.expect("workspace checked session");
let endpoint = format!("{}@{}:{}", session.user, session.host, session.port);
let home = local_provider.home().to_string_lossy().into_owned();
let places = local_provider
.places()
.into_iter()
.map(|(title, path)| (title.into(), path.to_string_lossy().into_owned()))
.collect();
let local = cx.new(|cx| {
FilePane::new(
PaneSide::Local,
session_id,
home.clone(),
places,
store.clone(),
dispatch.clone(),
window,
cx,
@@ -89,13 +106,15 @@ impl ExplorerPanel {
PaneSide::Remote,
session_id,
String::new(),
Vec::new(),
store.clone(),
dispatch.clone(),
window,
cx,
)
});
local.update(cx, |pane, cx| {
pane.load_local(home, local_provider.clone(), window, cx)
pane.load_local(home, LoadIntent::Reload, local_provider.clone(), window, cx)
});
let (commands, receiver) = async_channel::unbounded();
let (sender, events) = async_channel::unbounded();
@@ -135,8 +154,12 @@ impl ExplorerPanel {
}
}
});
let local_focus = local.read(cx).focus_handle(cx);
let remote_focus = remote.read(cx).focus_handle(cx);
let subscriptions = vec![
cx.observe(&store, |_, _, cx| cx.notify()),
cx.on_focus_in(&local_focus, window, |this, _, _| this.last_remote = false),
cx.on_focus_in(&remote_focus, window, |this, _, _| this.last_remote = true),
cx.on_app_quit(|this, _| {
this.send(SftpCommand::Shutdown);
async {}
@@ -150,17 +173,20 @@ impl ExplorerPanel {
local,
remote,
local_provider,
operations: HashMap::new(),
next_operation: 0,
commands,
state: ConnectionState::Connecting,
message: "正在连接 SFTP…".into(),
progress: None,
details: false,
upload_pending: false,
transfer_pending: false,
question: None,
dialog_open: false,
dispatch,
focus_handle: cx.focus_handle(),
tab_group: None,
last_remote: true,
_subscriptions: subscriptions,
_events: events_task,
}
@@ -183,18 +209,41 @@ impl ExplorerPanel {
pub fn remote(&self) -> &Entity<FilePane> {
&self.remote
}
pub fn pane(&self, remote: bool) -> &Entity<FilePane> {
if remote { &self.remote } else { &self.local }
}
/// The pane whose file list holds keyboard focus.
pub fn focused_pane(&self, window: &Window, cx: &App) -> Option<bool> {
[false, true].into_iter().find(|remote| {
self.pane(*remote)
.read(cx)
.focus_handle(cx)
.contains_focused(window, cx)
})
}
pub fn contains_focus(&self, window: &Window, cx: &App) -> bool {
self.focus_handle.contains_focused(window, cx)
}
pub fn connection_state(&self) -> ConnectionState {
self.state
}
pub fn progress(&self) -> Option<&UploadProgress> {
pub fn progress(&self) -> Option<&TransferProgress> {
self.progress.as_ref()
}
pub fn is_uploading(&self) -> bool {
self.upload_pending
/// The direction of the current or last batch; the next one is upload
/// until a download starts.
pub fn transfer_direction(&self) -> TransferDirection {
self.progress
.as_ref()
.map(TransferProgress::direction)
.unwrap_or_default()
}
pub fn is_transferring(&self) -> bool {
self.transfer_pending
|| self
.progress
.as_ref()
.is_some_and(UploadProgress::is_active)
.is_some_and(TransferProgress::is_active)
}
pub fn send(&self, command: SftpCommand) {
let _ = self.commands.try_send(command);
@@ -223,11 +272,12 @@ impl ExplorerPanel {
}
}
fn sync_available(&mut self, cx: &mut Context<Self>) {
let enabled = self.state == ConnectionState::Connected && !self.is_uploading();
let connected = self.state == ConnectionState::Connected;
let transfer = connected && !self.is_transferring();
self.local
.update(cx, |pane, cx| pane.set_available(enabled, cx));
.update(cx, |pane, cx| pane.set_available(true, transfer, cx));
self.remote
.update(cx, |pane, cx| pane.set_available(enabled, cx));
.update(cx, |pane, cx| pane.set_available(connected, transfer, cx));
}
fn on_event(&mut self, event: SftpEvent, window: &mut Window, cx: &mut Context<Self>) {
match event {
@@ -243,11 +293,12 @@ impl ExplorerPanel {
self.remote
.update(cx, |pane, _| pane.set_home(home.to_string()));
let path = self.remote.read(cx).path();
self.navigate(true, path, window, cx);
self.navigate(true, path, LoadIntent::Reload, window, cx);
cx.emit(ExplorerPanelEvent::StateChanged(self.session_id));
}
SftpEvent::Disconnected(message) => {
self.state = ConnectionState::Disconnected;
self.abandon_remote_operations(cx);
self.remote
.update(cx, |pane, cx| pane.disconnected(message.clone(), cx));
self.message = message;
@@ -261,16 +312,27 @@ impl ExplorerPanel {
SftpEvent::Listed { request_id, result } => self.remote.update(cx, |pane, cx| {
pane.apply_listing(request_id, result, window, cx)
}),
SftpEvent::Operated { request_id, result } => {
self.finish_operation(request_id, result, window, cx)
}
SftpEvent::Progress(progress) => {
let complete = progress.phase() == UploadPhase::Completed;
self.upload_pending = false;
let complete = progress.phase() == TransferPhase::Completed;
let direction = progress.direction();
self.transfer_pending = false;
self.progress = Some(progress);
if !self.is_uploading() {
if !self.is_transferring() {
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);
// Show what arrived: the remote pane after an upload, the local
// one after a download.
if complete {
match direction {
TransferDirection::Download => self.reload(false, window, cx),
TransferDirection::Upload if self.state == ConnectionState::Connected => {
self.reload(true, window, cx)
}
TransferDirection::Upload => {}
}
}
}
SftpEvent::Question(question) => {
@@ -300,7 +362,7 @@ impl ExplorerPanel {
.detach();
}
SftpEvent::Idle => {
self.upload_pending = false;
self.transfer_pending = false;
}
SftpEvent::Notice(message) => {
window.push_notification(Notification::error(message), cx);
@@ -313,17 +375,14 @@ impl ExplorerPanel {
&mut self,
remote: bool,
path: String,
intent: LoadIntent,
window: &mut Window,
cx: &mut Context<Self>,
) {
let pane = if remote {
self.remote.clone()
} else {
self.local.clone()
};
let pane = self.pane(remote).clone();
let path = pane.read(cx).expanded_path(&path);
if remote {
let id = pane.update(cx, |pane, cx| pane.begin_load(cx));
let id = pane.update(cx, |pane, cx| pane.begin_load(intent, cx));
match RemotePath::new(path) {
Ok(path) => self.send(SftpCommand::List {
request_id: id,
@@ -335,10 +394,15 @@ impl ExplorerPanel {
}
} else {
pane.update(cx, |pane, cx| {
pane.load_local(path, self.local_provider.clone(), window, cx)
pane.load_local(path, intent, self.local_provider.clone(), window, cx)
});
}
}
/// Re-read a pane's current directory, keeping its history.
pub fn reload(&mut self, remote: bool, window: &mut Window, cx: &mut Context<Self>) {
let path = self.pane(remote).read(cx).path();
self.navigate(remote, path, LoadIntent::Reload, window, cx);
}
pub fn execute(
&mut self,
command: &ExplorerCommand,
@@ -347,36 +411,159 @@ impl ExplorerPanel {
) {
match command {
ExplorerCommand::Navigate { remote, path } => {
self.navigate(*remote, path.clone(), window, cx)
self.navigate(*remote, path.clone(), LoadIntent::Visit, 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);
let path = self.pane(*remote).read(cx).parent_path();
self.navigate(*remote, path, LoadIntent::Visit, 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::Root { remote } => {
let path = self.pane(*remote).read(cx).root_path();
self.navigate(*remote, path, LoadIntent::Visit, window, cx);
}
ExplorerCommand::Check {
name,
checked,
ExplorerCommand::Home { remote } => {
let path = self.pane(*remote).read(cx).home();
if !path.is_empty() {
self.navigate(*remote, path, LoadIntent::Visit, window, cx);
}
}
ExplorerCommand::Back { remote } => {
if let Some(path) = self.pane(*remote).read(cx).back_target() {
self.navigate(*remote, path, LoadIntent::Back, window, cx);
}
}
ExplorerCommand::Forward { remote } => {
if let Some(path) = self.pane(*remote).read(cx).forward_target() {
self.navigate(*remote, path, LoadIntent::Forward, window, cx);
}
}
ExplorerCommand::Refresh { remote } => self.reload(*remote, window, cx),
ExplorerCommand::AddBookmark { remote } => {
let path = self.pane(*remote).read(cx).path();
let (id, side) = (self.session_id, BookmarkSide::from_remote(*remote));
self.store
.update(cx, |store, cx| store.add_bookmark(id, side, &path, cx));
}
ExplorerCommand::RemoveBookmark { remote, path } => {
let (id, side) = (self.session_id, BookmarkSide::from_remote(*remote));
self.store
.update(cx, |store, cx| store.remove_bookmark(id, side, path, cx));
}
ExplorerCommand::Open { remote } => {
let pane = self.pane(*remote).clone();
if let Some(entry) = pane.read(cx).cursor_entry(cx)
&& entry.is_dir()
{
let path = if entry.is_parent() {
pane.read(cx).parent_path()
} else {
pane.read(cx).child_path_of(&entry.name)
};
self.navigate(*remote, path, LoadIntent::Visit, window, cx);
}
}
ExplorerCommand::FocusPane { remote } => {
let focus = self.pane(*remote).read(cx).focus_handle(cx);
window.focus(&focus, cx);
}
ExplorerCommand::MoveCursor {
remote,
motion,
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();
.pane(*remote)
.clone()
.update(cx, |pane, cx| pane.move_cursor(*motion, *extend, cx)),
ExplorerCommand::ToggleSelection { remote } => self
.pane(*remote)
.clone()
.update(cx, |pane, cx| pane.toggle_selection(cx)),
ExplorerCommand::SelectAll { remote } => self
.pane(*remote)
.clone()
.update(cx, |pane, cx| pane.select_all(cx)),
ExplorerCommand::Transfer { remote: false } => {
let paths = self.local.read(cx).upload_sources(cx);
let target = self.remote.read(cx).path();
self.open_upload(paths, target, window, cx);
}
ExplorerCommand::Transfer { remote: true } => {
let remote = self.remote.read(cx);
let paths = remote
.selected_names(cx)
.iter()
.map(|name| remote.child_path_of(name))
.collect();
let target = self.local.read(cx).path();
self.open_download(paths, target, window, cx);
}
ExplorerCommand::DownloadPaths { paths, target } => {
self.open_download(paths.clone(), target.clone(), window, cx)
}
ExplorerCommand::BeginDownload { paths, target } => {
if self.is_transferring() || self.state != ConnectionState::Connected {
return;
}
let request = paths
.iter()
.map(|path| RemotePath::new(path.clone()))
.collect::<anyhow::Result<Vec<_>>>()
.and_then(|paths| DownloadRequest::new(paths, target.into()));
match request {
Ok(request) => {
self.transfer_pending = true;
self.details = false;
self.send(SftpCommand::Download(request));
}
Err(error) => {
window.push_notification(Notification::error(error.to_string()), cx)
}
}
}
ExplorerCommand::Delete { remote } => self.confirm_delete(*remote, window, cx),
ExplorerCommand::BeginDelete { remote, names } => {
self.start_operation(*remote, PaneOperation::Delete(names.clone()), window, cx)
}
ExplorerCommand::Rename { remote } => self.open_rename(*remote, window, cx),
ExplorerCommand::CommitRename { remote, from, to } => self.start_operation(
*remote,
PaneOperation::Rename {
from: from.clone(),
to: to.clone(),
},
window,
cx,
),
ExplorerCommand::New { remote, kind } => self.open_new(*remote, *kind, window, cx),
ExplorerCommand::CommitNew { remote, kind, name } => self.start_operation(
*remote,
PaneOperation::Create {
kind: *kind,
name: name.clone(),
},
window,
cx,
),
ExplorerCommand::Properties { remote } => self.open_properties(*remote, window, cx),
ExplorerCommand::ApplyPermissions {
remote,
names,
edit,
recursive,
add_x_to_dirs,
} => self.start_operation(
*remote,
PaneOperation::Permissions {
names: names.clone(),
edit: *edit,
recursive: *recursive,
add_x_to_dirs: *add_x_to_dirs,
},
window,
cx,
),
ExplorerCommand::ChooseFiles => {
if self.is_uploading() || self.state != ConnectionState::Connected {
if self.is_transferring() || self.state != ConnectionState::Connected {
return;
}
let choice = cx.prompt_for_paths(PathPromptOptions {
@@ -406,14 +593,14 @@ impl ExplorerPanel {
self.open_upload(paths.clone(), target.clone(), window, cx)
}
ExplorerCommand::BeginUpload { paths, target } => {
if self.is_uploading() || self.state != ConnectionState::Connected {
if self.is_transferring() || 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.transfer_pending = true;
self.details = false;
self.send(SftpCommand::Upload(request));
}
@@ -436,15 +623,15 @@ impl ExplorerPanel {
});
}
}
ExplorerCommand::CancelUpload => self.send(SftpCommand::Cancel),
ExplorerCommand::ResumeUpload => {
if !self.is_uploading() {
self.upload_pending = true;
ExplorerCommand::CancelTransfer => self.send(SftpCommand::Cancel),
ExplorerCommand::ResumeTransfer => {
if !self.is_transferring() {
self.transfer_pending = true;
self.send(SftpCommand::Resume);
}
}
ExplorerCommand::DiscardUpload => {
if !self.is_uploading() {
ExplorerCommand::DiscardTransfer => {
if !self.is_transferring() {
self.send(SftpCommand::Discard);
}
}
@@ -481,7 +668,10 @@ impl BasePanel for ExplorerPanel {
let id = self.session_id;
self.store
.update(cx, |store, cx| store.set_active(Some(id), cx));
window.focus(&self.focus_handle, cx);
// Focus a file list, not the panel root, so list shortcuts work
// without a click first. Both stay inside this panel.
let focus = self.pane(self.last_remote).read(cx).focus_handle(cx);
window.focus(&focus, cx);
cx.emit(ExplorerPanelEvent::Activated(id));
}
}
@@ -554,7 +744,7 @@ fn tab_menu(
impl Render for ExplorerPanel {
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 command_button = |id: &'static str, label: SharedString, command: ExplorerCommand| {
let dispatch = self.dispatch.clone();
Button::new(id)
.ghost()
@@ -584,12 +774,12 @@ impl Render for ExplorerPanel {
.border_color(cx.theme().border)
.child(div().flex_1().min_w_0().child(self.message.clone()))
.when(
self.state == ConnectionState::Disconnected && !self.is_uploading(),
self.state == ConnectionState::Disconnected && !self.is_transferring(),
|this| {
this.child(command_button(
"reconnect-sftp",
"重新连接",
ExplorerCommand::ResumeUpload,
"重新连接".into(),
ExplorerCommand::ResumeTransfer,
))
},
),
@@ -611,21 +801,22 @@ impl Render for ExplorerPanel {
),
)
.when_some(self.progress.as_ref(), |this, progress| {
let verb = progress.direction().verb();
let status = match progress.phase() {
UploadPhase::Scanning => "正在扫描",
UploadPhase::Uploading => "正在上传",
UploadPhase::Waiting => "等待处理",
UploadPhase::Reconnecting => "正在重连",
UploadPhase::Stopped => "已停止 · 可继续上传",
UploadPhase::Completed => {
TransferPhase::Scanning => "正在扫描".to_string(),
TransferPhase::Transferring => format!("正在{verb}"),
TransferPhase::Waiting => "等待处理".to_string(),
TransferPhase::Reconnecting => "正在重连".to_string(),
TransferPhase::Stopped => format!("已停止 · 可继续{verb}"),
TransferPhase::Completed => {
if progress.failed() > 0 {
"上传结束 · 部分失败"
format!("{verb}结束 · 部分失败")
} else {
"上传结束"
format!("{verb}结束")
}
}
};
let value = if progress.phase() == UploadPhase::Completed {
let value = if progress.phase() == TransferPhase::Completed {
100.
} else if progress.total_bytes() > 0 {
progress.completed_bytes() as f32 / progress.total_bytes() as f32 * 100.
@@ -634,7 +825,7 @@ impl Render for ExplorerPanel {
};
this.child(
v_flex()
.id("upload-progress")
.id("transfer-progress")
.test_support()
.gap_2()
.px_3()
@@ -646,7 +837,7 @@ impl Render for ExplorerPanel {
.gap_2()
.child(
div()
.id("upload-status")
.id("transfer-status")
.test_support()
.text_sm()
.child(status),
@@ -654,26 +845,26 @@ impl Render for ExplorerPanel {
.child(div().flex_1())
.when(progress.is_active(), |this| {
this.child(command_button(
"cancel-upload",
"取消",
ExplorerCommand::CancelUpload,
"cancel-transfer",
"取消".into(),
ExplorerCommand::CancelTransfer,
))
})
.when(progress.phase() == UploadPhase::Stopped, |this| {
.when(progress.phase() == TransferPhase::Stopped, |this| {
this.child(command_button(
"resume-upload",
"继续上传",
ExplorerCommand::ResumeUpload,
"resume-transfer",
format!("继续{verb}").into(),
ExplorerCommand::ResumeTransfer,
))
.child(command_button(
"discard-upload",
"丢弃续传进度",
ExplorerCommand::DiscardUpload,
"discard-transfer",
"丢弃续传进度".into(),
ExplorerCommand::DiscardTransfer,
))
})
.child(command_button(
"upload-details",
"详情",
"transfer-details",
"详情".into(),
ExplorerCommand::ToggleDetails,
)),
)
@@ -684,9 +875,9 @@ impl Render for ExplorerPanel {
.child(progress.current().to_string()),
)
.child(
Progress::new("upload-bytes")
Progress::new("transfer-bytes")
.value(value)
.accessibility_label("上传进度")
.accessibility_label(format!("{verb}进度"))
.small(),
)
.child(
@@ -706,7 +897,7 @@ impl Render for ExplorerPanel {
.when(self.details, |this| {
this.child(
div()
.id("upload-detail-list")
.id("transfer-detail-list")
.max_h_32()
.overflow_y_scroll()
.text_xs()
+300
View File
@@ -0,0 +1,300 @@
//! The delete confirmation and the name form shared by 重命名 and 新建.
use super::{ExplorerPanel, NewEntryKind};
use crate::app::ExplorerDispatch as _;
use crate::app::{ExplorerAction, ExplorerCommand};
use gpui_kit::component::{
ActiveTheme as _, Sizable as _, WindowExt as _,
button::{Button, ButtonVariant, ButtonVariants as _},
dialog::{DialogAction, DialogButtonProps, DialogClose, DialogFooter},
form::{Field, Form},
input::{Input, InputState},
v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
/// Check a name typed for a new or renamed item. `existing` is the current
/// listing; the server or file system still has the final word.
pub fn validate_entry_name(
name: &str,
original: Option<&str>,
existing: &[String],
) -> Result<(), String> {
if name.trim().is_empty() {
return Err("名称不能为空".into());
}
if matches!(name, "." | "..") {
return Err("名称不能是「.」或「..」".into());
}
if name.contains(['/', '\0']) {
return Err("名称不能包含「/」".into());
}
if original == Some(name) {
return Err("名称未改变".into());
}
if existing.iter().any(|existing| existing == name) {
return Err(format!("已有名为「{name}」的项目"));
}
Ok(())
}
/// What the name form commits.
#[derive(Clone)]
enum NameIntent {
Rename(String),
New(NewEntryKind),
}
struct NameForm {
input: Entity<InputState>,
label: &'static str,
error: Option<String>,
}
impl Render for NameForm {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap_2()
.child(
Form::new().child(
Field::new()
.label(self.label)
.required(true)
.child(Input::new(&self.input).id("entry-name").small()),
),
)
.when_some(self.error.clone(), |this, error| {
this.child(
div()
.id("form-error")
.test_support()
.text_sm()
.text_color(cx.theme().danger)
.child(error),
)
})
}
}
impl ExplorerPanel {
/// 删除: confirm with the scope and consequence, then delete. Local
/// items go to the Trash; remote ones are gone for good.
pub(super) fn confirm_delete(
&mut self,
remote: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let names = self.pane(remote).read(cx).selected_names(cx);
if names.is_empty() || !self.can_modify(remote, cx) || window.has_active_dialog(cx) {
return;
}
let title = if let [name] = names.as_slice() {
format!("删除「{name}」?")
} else {
format!("删除 {} 个项目?", names.len())
};
let description = if remote {
"远程文件无法恢复,目录会连同其中全部内容一起删除。"
} else {
"项目会移到废纸篓。"
};
let (dispatch, sid, generation) =
(self.dispatch.clone(), self.session_id(), self.generation());
let focus = window.focused(cx);
window.open_alert_dialog(cx, move |dialog, _, _| {
dialog
.title(title.clone())
.description(description)
.button_props(
DialogButtonProps::default()
.ok_text("删除")
.ok_variant(ButtonVariant::Danger)
.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, focus, names) = (dispatch.clone(), focus.clone(), names.clone());
move |_, window, cx| {
dispatch.dispatch_explorer_action(
&ExplorerAction::new(
sid,
ExplorerCommand::BeginDelete {
remote,
names: names.clone(),
},
)
.with_generation(generation),
window,
cx,
);
if let Some(focus) = &focus {
window.focus(focus, cx);
}
true
}
})
});
}
pub(super) fn open_rename(
&mut self,
remote: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let [name] = self.pane(remote).read(cx).selected_names(cx).as_slice() {
let name = name.clone();
self.open_name_dialog(remote, NameIntent::Rename(name), window, cx);
}
}
pub(super) fn open_new(
&mut self,
remote: bool,
kind: NewEntryKind,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.open_name_dialog(remote, NameIntent::New(kind), window, cx);
}
fn open_name_dialog(
&mut self,
remote: bool,
intent: NameIntent,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.can_modify(remote, cx) || window.has_active_dialog(cx) {
return;
}
let existing: Vec<String> = self
.pane(remote)
.read(cx)
.entries(cx)
.iter()
.filter(|entry| !entry.is_parent())
.map(|entry| entry.name.to_string())
.collect();
let (title, initial, commit, label) = match &intent {
NameIntent::Rename(name) => (
format!("重命名「{name}」"),
name.clone(),
"重命名",
"新名称",
),
NameIntent::New(kind) => (
kind.title().to_string(),
kind.default_name().to_string(),
"创建",
"名称",
),
};
let input = cx.new(|cx| InputState::new(window, cx).default_value(initial));
let form = cx.new(|_| NameForm {
input,
label,
error: None,
});
let (dispatch, sid, generation) =
(self.dispatch.clone(), self.session_id(), self.generation());
let focus = window.focused(cx);
window.open_dialog(cx, move |dialog, _, _| {
dialog
.title(title.clone())
.child(form.clone())
.footer(
DialogFooter::new()
.child(DialogClose::new().trigger(|button| button.label("取消")))
.child(
DialogAction::new()
.child(Button::new("commit").primary().label(commit)),
),
)
.on_ok({
let (form, dispatch, intent, existing) = (
form.clone(),
dispatch.clone(),
intent.clone(),
existing.clone(),
);
move |_, window, cx| {
let name = form.read(cx).input.read(cx).value().to_string();
let original = match &intent {
NameIntent::Rename(from) => Some(from.as_str()),
NameIntent::New(_) => None,
};
if let Err(error) = validate_entry_name(&name, original, &existing) {
form.update(cx, |form, cx| {
form.error = Some(error);
cx.notify();
});
return false;
}
let command = match &intent {
NameIntent::Rename(from) => ExplorerCommand::CommitRename {
remote,
from: from.clone(),
to: name,
},
NameIntent::New(kind) => ExplorerCommand::CommitNew {
remote,
kind: *kind,
name,
},
};
dispatch.dispatch_explorer_action(
&ExplorerAction::new(sid, command).with_generation(generation),
window,
cx,
);
true
}
})
.on_close({
let focus = focus.clone();
move |_, window, cx| {
if let Some(focus) = &focus {
window.focus(focus, cx);
}
}
})
});
}
}
#[cfg(test)]
mod tests {
use super::validate_entry_name;
#[test]
fn names_are_checked_against_the_listing() {
let existing = vec!["a.txt".to_string(), "目录".to_string()];
assert!(validate_entry_name("b.txt", None, &existing).is_ok());
assert_eq!(
validate_entry_name(" ", None, &existing).unwrap_err(),
"名称不能为空"
);
assert!(validate_entry_name("..", None, &existing).is_err());
assert!(validate_entry_name("a/b", None, &existing).is_err());
assert_eq!(
validate_entry_name("a.txt", Some("a.txt"), &existing).unwrap_err(),
"名称未改变"
);
assert_eq!(
validate_entry_name("目录", Some("a.txt"), &existing).unwrap_err(),
"已有名为「目录」的项目"
);
assert!(validate_entry_name("A.txt", Some("a.txt"), &existing).is_ok());
}
}
+296 -177
View File
@@ -1,127 +1,162 @@
use crate::app::ExplorerDispatch as _;
use crate::{
app::{ExplorerAction, ExplorerCommand},
app::{CatalogIcon, ExplorerAction, ExplorerCommand},
session::SessionId,
};
use gpui_kit::component::{
ActiveTheme as _, Icon, IconName, Sizable as _, h_flex,
table::{Column, ColumnSort, TableDelegate, TableState},
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use std::{collections::HashSet, path::PathBuf, rc::Rc};
use std::{cell::RefCell, cmp::Ordering, path::PathBuf, rc::Rc};
use gpui_kit::component::checkbox::Checkbox;
use super::{
ClickMode, FileEntry, FileKind, FilePane, PaneSide, Selection, format_changed,
format_kilobytes, format_rights,
};
use super::{FileEntry, FileKind, PaneSide, format_size};
/// What the row closures need from the pane, pushed in by `FilePane` so
/// rendering never reads another entity.
#[derive(Clone)]
pub(super) struct ListingContext {
pub session: SessionId,
pub path: String,
pub selection: Rc<Selection>,
pub transfer_enabled: bool,
pub dispatch: Option<FocusHandle>,
pub pane: Option<WeakEntity<FilePane>>,
/// The row under the last right-click, read by the list's context menu.
pub menu_hit: Rc<RefCell<Option<String>>>,
}
/// The rows and columns of one explorer pane, for `DataTable`.
impl Default for ListingContext {
fn default() -> Self {
Self {
session: SessionId(0),
path: String::new(),
selection: Rc::default(),
transfer_enabled: false,
dispatch: None,
pane: None,
menu_hit: Rc::default(),
}
}
}
/// The rows and columns of one explorer pane, for `DataTable`. Selection is
/// the pane's; the table's own row selection stays off.
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>,
context: ListingContext,
}
impl FileListing {
pub fn new(side: PaneSide, rows: Vec<FileEntry>) -> Self {
// Column widths are an API boundary that takes `Pixels`.
let mut columns = vec![
Column::new("name", "名称").width(px(240.)).sortable(),
Column::new("size", "大小")
.width(px(90.))
.text_right()
.sortable(),
Column::new("type", "类型").width(px(96.)),
Column::new("modified", "修改时间")
.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.)));
}
Self {
let columns = match side {
PaneSide::Local => vec![
Column::new("name", "名称").width(px(260.)).sortable(),
Column::new("size", "大小").width(px(100.)).sortable(),
Column::new("type", "类型").width(px(100.)).sortable(),
Column::new("modified", "修改时间")
.width(px(150.))
.sortable(),
],
PaneSide::Remote => vec![
Column::new("name", "名称").width(px(240.)).sortable(),
Column::new("size", "大小").width(px(100.)).sortable(),
Column::new("modified", "修改时间")
.width(px(150.))
.sortable(),
Column::new("rights", "权限").width(px(96.)).sortable(),
Column::new("owner", "所有者").width(px(80.)).sortable(),
],
};
let mut listing = Self {
columns,
rows,
rows: Vec::new(),
sort: None,
active_name: None,
side,
session: SessionId(0),
path: String::new(),
checked: Rc::new(HashSet::new()),
enabled: false,
dispatch: None,
}
context: ListingContext::default(),
};
listing.set_rows(rows);
listing
}
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(super) fn configure(&mut self, context: ListingContext) {
self.context = context;
}
pub fn rows(&self) -> &[FileEntry] {
&self.rows
}
/// Row names in display order, `..` included.
pub fn order(&self) -> Vec<&str> {
self.rows.iter().map(|row| row.name.as_ref()).collect()
}
pub fn entry(&self, ix: usize) -> Option<&FileEntry> {
self.rows.get(ix)
}
pub fn position(&self, name: &str) -> Option<usize> {
self.rows.iter().position(|row| row.name.as_ref() == name)
}
pub fn set_rows(&mut self, rows: Vec<FileEntry>) {
self.rows = rows;
self.apply_sort();
}
/// Re-sort by the active column; `..` stays first and directories stay
/// ahead of files.
/// Sort by the active column, by name without one. `..` stays first and
/// directories (links to directories included) stay ahead of files.
fn apply_sort(&mut self) {
let Some((col_ix, sort)) = self.sort else {
return;
};
let Some(key) = self.columns.get(col_ix).map(|column| column.key.clone()) else {
return;
};
let has_parent = self.rows.first().is_some_and(FileEntry::is_parent);
let start = usize::from(has_parent);
let rows = &mut self.rows[start..];
rows.sort_by(|a, b| {
let by_kind = b.is_dir().cmp(&a.is_dir());
let (key, descending) = self
.sort
.and_then(|(col_ix, sort)| {
self.columns
.get(col_ix)
.map(|column| (column.key.clone(), sort == ColumnSort::Descending))
})
.unwrap_or_else(|| ("name".into(), false));
let start = usize::from(self.rows.first().is_some_and(FileEntry::is_parent));
self.rows[start..].sort_by(|a, b| {
let by_name = || a.name.to_lowercase().cmp(&b.name.to_lowercase());
let by_key = match key.as_ref() {
"size" => a.size.cmp(&b.size),
"modified" => a.modified.cmp(&b.modified),
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
};
let by_key = if sort == ColumnSort::Descending {
by_key.reverse()
} else {
by_key
};
by_kind.then(by_key)
"type" => a.type_label().cmp(&b.type_label()),
"rights" => a
.permissions
.map(format_rights)
.cmp(&b.permissions.map(format_rights)),
"owner" => a.owner.cmp(&b.owner),
_ => Ordering::Equal,
}
.then_with(by_name);
let by_key = if descending { by_key.reverse() } else { by_key };
b.is_dir().cmp(&a.is_dir()).then(by_key)
});
}
}
fn icon_for(entry: &FileEntry) -> Icon {
if entry.is_parent() {
return Icon::new(CatalogIcon::FolderUp);
}
match (entry.kind, entry.target) {
(FileKind::Dir, _) => Icon::new(IconName::Folder),
(FileKind::File, _) => Icon::new(IconName::File),
(FileKind::Symlink, Some(FileKind::Dir)) => Icon::new(CatalogIcon::FolderSymlink),
(FileKind::Symlink, _) => Icon::new(CatalogIcon::FileSymlink),
}
}
impl TableDelegate for FileListing {
fn columns_count(&self, _: &App) -> usize {
self.columns.len()
@@ -135,6 +170,21 @@ impl TableDelegate for FileListing {
self.columns[col_ix].clone()
}
/// `Column::text_right` is not applied by `DataTable`, so the size
/// column aligns itself.
fn render_th(
&mut self,
col_ix: usize,
_: &mut Window,
_: &mut Context<TableState<Self>>,
) -> impl IntoElement {
let column = &self.columns[col_ix];
h_flex()
.size_full()
.when(column.key.as_ref() == "size", |this| this.justify_end())
.child(column.name.clone())
}
fn render_td(
&mut self,
row_ix: usize,
@@ -145,67 +195,42 @@ impl TableDelegate for FileListing {
let Some(entry) = self.rows.get(row_ix) else {
return div().into_any_element();
};
let parent = entry.is_parent();
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,
FileKind::File => IconName::File,
FileKind::Symlink => IconName::ExternalLink,
};
h_flex()
.gap_2()
.child(
Icon::new(icon)
.small()
.text_color(cx.theme().muted_foreground),
)
.child(entry.name.clone())
.into_any_element()
}
"size" => if entry.is_dir() {
"—".to_string()
} else {
format_size(entry.size)
}
.into_any_element(),
"type" => if entry.is_parent() {
"name" => h_flex()
.id(ElementId::Name(format!("name:{}", entry.name).into()))
.test_support()
.aria_selected(self.context.selection.contains(&entry.name))
.gap_2()
.child(
icon_for(entry)
.small()
.text_color(cx.theme().muted_foreground),
)
.child(entry.name.clone())
.into_any_element(),
"size" => h_flex()
.w_full()
.justify_end()
.when(!entry.is_dir(), |this| {
this.child(format_kilobytes(entry.size))
})
.into_any_element(),
"type" => entry.type_label().into_any_element(),
"modified" => if parent {
String::new()
} else {
entry.type_label()
entry.modified.map(format_changed).unwrap_or_default()
}
.into_any_element(),
"modified" => entry.modified.clone().into_any_element(),
"permissions" => div()
"rights" => div()
.font_family(cx.theme().mono_font_family.clone())
.child(entry.permissions.clone())
.when(!parent, |this| {
this.children(entry.permissions.map(format_rights))
})
.into_any_element(),
"owner" => div()
.when(!parent, |this| this.children(entry.owner.clone()))
.into_any_element(),
_ => div().into_any_element(),
}
@@ -215,45 +240,150 @@ impl TableDelegate for FileListing {
&mut self,
row_ix: usize,
_: &mut Window,
_: &mut Context<TableState<Self>>,
cx: &mut Context<TableState<Self>>,
) -> Stateful<Div> {
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 {
let context = &self.context;
let selected = context.selection.contains(&name);
let cursor = context.selection.cursor() == Some(name.as_str());
let row = div()
.id(ElementId::Name(format!("file:{name}").into()))
// Selected rows and the cursor are painted here, under the cells;
// the hover background replaces a plain row background.
.when(selected, |this| {
this.child(
div()
.absolute()
.inset_0()
.bg(cx.theme().tokens.table_active),
)
})
.when(cursor, |this| {
this.child(
div()
.absolute()
.inset_0()
.border_1()
.border_color(cx.theme().table_active_border),
)
});
let (Some(dispatch), Some(pane)) = (context.dispatch.clone(), context.pane.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 sid = context.session;
let remote = self.side == PaneSide::Remote;
let row = row
.on_click({
let name = name.clone();
let pane = pane.clone();
let dispatch = dispatch.clone();
let target = target.clone();
move |paths: &ExternalPaths, window, cx| {
move |event, window, cx| {
let modifiers = event.modifiers();
let mode = if modifiers.shift {
ClickMode::Extend
} else if modifiers.secondary() {
ClickMode::Toggle
} else {
ClickMode::Replace
};
let _ = pane.update(cx, |pane, cx| pane.click_row(&name, mode, cx));
if mode == ClickMode::Replace && event.click_count() == 2 {
dispatch.dispatch_explorer_action(
&ExplorerAction::new(sid, ExplorerCommand::Open { remote }),
window,
cx,
);
}
}
})
.on_mouse_down(MouseButton::Right, {
let name = name.clone();
let menu_hit = context.menu_hit.clone();
move |_, _, cx| {
let hit = (name != "..").then(|| name.clone());
if let Some(hit) = &hit {
let _ = pane.update(cx, |pane, cx| pane.select_for_menu(hit, cx));
}
*menu_hit.borrow_mut() = hit;
}
});
if entry.is_parent() {
return row;
}
let names = if selected {
context.selection.targets(&self.order())
} else {
vec![name.clone()]
};
let enabled = context.transfer_enabled;
let child = |name: &str| match self.side {
PaneSide::Local => PathBuf::from(&context.path)
.join(name)
.to_string_lossy()
.into_owned(),
PaneSide::Remote => format!("{}/{name}", context.path.trim_end_matches('/')),
};
// Dragging carries the selection when the dragged row is part of it.
let row = match self.side {
PaneSide::Local => {
let paths = names
.iter()
.map(|name| PathBuf::from(child(name)))
.collect();
row.on_drag(LocalFilesDrag { paths }, |drag, _, _, cx| {
cx.new(|_| FileDragPreview {
verb: "上传",
count: drag.paths.len(),
})
})
}
PaneSide::Remote => {
let paths = names.iter().map(|name| child(name)).collect();
row.on_drag(RemoteFilesDrag { paths }, |drag, _, _, cx| {
cx.new(|_| FileDragPreview {
verb: "下载",
count: drag.paths.len(),
})
})
}
};
if !entry.is_dir() {
return row;
}
// Dropping on a directory row puts the items in that directory.
let target = child(&name);
match self.side {
PaneSide::Remote => 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: paths.paths().to_vec(),
paths: drag.paths.clone(),
target: target.clone(),
},
),
@@ -262,14 +392,13 @@ impl TableDelegate for FileListing {
);
cx.stop_propagation();
}
}
})
.on_drop(move |drag: &LocalFilesDrag, window, cx| {
}),
PaneSide::Local => row.on_drop(move |drag: &RemoteFilesDrag, window, cx| {
if enabled {
dispatch.dispatch_explorer_action(
&ExplorerAction::new(
sid,
ExplorerCommand::UploadPaths {
ExplorerCommand::DownloadPaths {
paths: drag.paths.clone(),
target: target.clone(),
},
@@ -279,9 +408,7 @@ impl TableDelegate for FileListing {
);
cx.stop_propagation();
}
})
} else {
row
}),
}
}
@@ -297,20 +424,6 @@ 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();
}
@@ -334,7 +447,13 @@ impl TableDelegate for FileListing {
pub(super) struct LocalFilesDrag {
pub paths: Vec<PathBuf>,
}
/// Remote rows dragged toward the local pane, as remote paths.
#[derive(Clone)]
pub(super) struct RemoteFilesDrag {
pub paths: Vec<String>,
}
struct FileDragPreview {
verb: &'static str,
count: usize,
}
impl Render for FileDragPreview {
@@ -347,6 +466,6 @@ impl Render for FileDragPreview {
.border_1()
.border_color(cx.theme().border)
.rounded(cx.theme().radius)
.child(format!("上传 {} 个项目", self.count))
.child(format!("{} {} 个项目", self.verb, self.count))
}
}
+642 -298
View File
File diff suppressed because it is too large. Load diff
+102
View File
@@ -0,0 +1,102 @@
//! Back / forward directory history for one pane.
/// Why a directory load started; it decides how a successful load changes
/// the history. A failed load changes nothing.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LoadIntent {
/// Going somewhere new: typed path, double-click, up, root, home, bookmark.
Visit,
Back,
Forward,
/// Refresh, reconnect, or re-reading after an operation.
Reload,
}
const LIMIT: usize = 50;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct NavigationHistory {
back: Vec<String>,
forward: Vec<String>,
}
impl NavigationHistory {
pub fn back_target(&self) -> Option<&str> {
self.back.last().map(String::as_str)
}
pub fn forward_target(&self) -> Option<&str> {
self.forward.last().map(String::as_str)
}
/// Record a successful load of `to` that started from `from`.
pub fn record(&mut self, intent: LoadIntent, from: &str, to: &str) {
match intent {
LoadIntent::Visit => {
if !from.is_empty() && from != to {
push(&mut self.back, from);
self.forward.clear();
}
}
LoadIntent::Back => {
self.back.pop();
push(&mut self.forward, from);
}
LoadIntent::Forward => {
self.forward.pop();
push(&mut self.back, from);
}
LoadIntent::Reload => {}
}
}
}
fn push(stack: &mut Vec<String>, path: &str) {
if path.is_empty() {
return;
}
stack.push(path.to_string());
if stack.len() > LIMIT {
stack.remove(0);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn visits_push_back_and_clear_forward() {
let mut history = NavigationHistory::default();
history.record(LoadIntent::Visit, "", "/home");
assert_eq!(history.back_target(), None, "the first load has no origin");
history.record(LoadIntent::Visit, "/home", "/etc");
history.record(LoadIntent::Visit, "/etc", "/etc");
history.record(LoadIntent::Reload, "/etc", "/etc");
assert_eq!(history.back_target(), Some("/home"));
history.record(LoadIntent::Back, "/etc", "/home");
assert_eq!(history.back_target(), None);
assert_eq!(history.forward_target(), Some("/etc"));
history.record(LoadIntent::Forward, "/home", "/etc");
assert_eq!(history.back_target(), Some("/home"));
assert_eq!(history.forward_target(), None);
history.record(LoadIntent::Back, "/etc", "/home");
history.record(LoadIntent::Visit, "/home", "/var");
assert_eq!(history.forward_target(), None, "a new visit drops forward");
}
#[test]
fn history_is_bounded() {
let mut history = NavigationHistory::default();
for ix in 0..60 {
history.record(
LoadIntent::Visit,
&format!("/{ix}"),
&format!("/{}", ix + 1),
);
}
assert_eq!(history.back.len(), LIMIT);
assert_eq!(history.back_target(), Some("/59"));
assert_eq!(history.back[0], "/10");
}
}
+12 -2
View File
@@ -1,14 +1,24 @@
//! Real local/remote file browsing and SFTP upload UI.
mod explorer_panel;
mod file_dialogs;
mod file_listing;
mod file_pane;
mod history;
mod model;
mod pane_menu;
mod pane_operations;
mod properties_dialog;
mod selection;
pub use explorer_panel::{ExplorerPanel, ExplorerPanelEvent};
pub use file_dialogs::validate_entry_name;
pub use file_listing::FileListing;
pub use file_pane::{FilePane, PaneSide};
pub use history::{LoadIntent, NavigationHistory};
pub use model::*;
pub use properties_dialog::PermissionDraft;
pub use selection::{ClickMode, CursorMotion, Selection};
mod upload_dialog;
pub use upload_dialog::confirm_close_upload;
mod transfer_dialog;
pub use transfer_dialog::confirm_close_transfer;
+249 -31
View File
@@ -1,6 +1,31 @@
//! Directory row snapshots and display formatting.
use crate::sftp::{DirectoryEntry, EntryKind};
use gpui_kit::SharedString;
use serde::Deserialize;
/// What the 新建 menu creates.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
pub enum NewEntryKind {
Folder,
File,
}
impl NewEntryKind {
pub fn title(self) -> &'static str {
match self {
NewEntryKind::Folder => "新建文件夹",
NewEntryKind::File => "新建文件",
}
}
pub fn default_name(self) -> &'static str {
match self {
NewEntryKind::Folder => "新建文件夹",
NewEntryKind::File => "新建文件.txt",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FileKind {
@@ -19,61 +44,89 @@ impl FileKind {
}
}
/// One row of a directory listing.
impl From<EntryKind> for FileKind {
/// Devices, sockets and pipes list as files; nothing opens them.
fn from(kind: EntryKind) -> Self {
match kind {
EntryKind::Directory => FileKind::Dir,
EntryKind::Symlink => FileKind::Symlink,
EntryKind::File | EntryKind::Other => FileKind::File,
}
}
}
/// One row of a directory listing. Values stay raw; the table formats them.
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct FileEntry {
pub name: SharedString,
pub kind: FileKind,
/// What a symbolic link points to; `None` for other rows and broken links.
pub target: Option<FileKind>,
pub size: u64,
pub modified: SharedString,
pub permissions: SharedString,
/// Seconds since the Unix epoch.
pub modified: Option<i64>,
/// The raw mode, type bits included.
pub permissions: Option<u32>,
pub owner: Option<SharedString>,
pub group: Option<SharedString>,
}
impl FileEntry {
pub fn dir(name: &str, modified: &str, permissions: &str) -> Self {
pub fn new(name: &str, kind: FileKind) -> Self {
Self {
name: name.to_string().into(),
kind: FileKind::Dir,
kind,
target: None,
size: 0,
modified: modified.to_string().into(),
permissions: permissions.to_string().into(),
modified: None,
permissions: None,
owner: None,
group: None,
}
}
pub fn file(name: &str, size: u64, modified: &str, permissions: &str) -> Self {
pub fn dir(name: &str) -> Self {
Self::new(name, FileKind::Dir)
}
pub fn file(name: &str, size: u64) -> Self {
Self {
name: name.to_string().into(),
kind: FileKind::File,
size,
modified: modified.to_string().into(),
permissions: permissions.to_string().into(),
}
}
pub fn symlink(name: &str, modified: &str, permissions: &str) -> Self {
Self {
name: name.to_string().into(),
kind: FileKind::Symlink,
size: 0,
modified: modified.to_string().into(),
permissions: permissions.to_string().into(),
..Self::new(name, FileKind::File)
}
}
/// The `..` row that navigates to the parent directory.
pub fn parent() -> Self {
Self::dir("..")
}
pub fn from_directory_entry(entry: &DirectoryEntry) -> Self {
let metadata = entry.metadata();
let kind = FileKind::from(metadata.kind());
Self {
name: "..".into(),
kind: FileKind::Dir,
size: 0,
modified: "".into(),
permissions: "".into(),
name: entry.name().to_string().into(),
kind,
target: (kind == FileKind::Symlink)
.then(|| entry.target_kind().map(FileKind::from))
.flatten(),
size: metadata.size(),
modified: metadata.modified().map(i64::from),
permissions: metadata.permissions(),
owner: entry.owner().map(|owner| owner.to_string().into()),
group: entry.group().map(|group| group.to_string().into()),
}
}
/// Directories and links to directories: both open on double-click and
/// sort ahead of files.
pub fn is_dir(&self) -> bool {
matches!(self.kind, FileKind::Dir)
self.kind == FileKind::Dir || self.target == Some(FileKind::Dir)
}
pub fn is_link(&self) -> bool {
self.kind == FileKind::Symlink
}
pub fn is_parent(&self) -> bool {
@@ -82,6 +135,9 @@ impl FileEntry {
/// The type shown in the 类型 column: the kind, or the extension for files.
pub fn type_label(&self) -> String {
if self.is_parent() {
return "上级目录".into();
}
match self.kind {
FileKind::File => match self.name.rsplit_once('.') {
Some((stem, ext)) if !stem.is_empty() && !ext.is_empty() => {
@@ -94,6 +150,50 @@ impl FileEntry {
}
}
/// Every directory from the root down to `path`, as `(title, path)`: what the
/// 目录列表 select offers. The root's title is the root itself (`/`, `C:\`).
pub fn path_ancestors(path: &str, remote: bool) -> Vec<(String, String)> {
if path.is_empty() {
return Vec::new();
}
let mut chain = Vec::new();
if remote {
let Ok(mut current) = crate::sftp::RemotePath::new(path) else {
return chain;
};
loop {
let text = current.as_str().trim_end_matches('/');
let title = text.rsplit('/').next().filter(|name| !name.is_empty());
chain.push((
title.unwrap_or("/").to_string(),
if text.is_empty() {
"/".into()
} else {
text.into()
},
));
if current.is_root() || text.is_empty() {
break;
}
current = current.parent();
}
} else {
for ancestor in std::path::Path::new(path).ancestors() {
let text = ancestor.to_string_lossy().into_owned();
if text.is_empty() {
continue;
}
let title = ancestor
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| text.clone());
chain.push((title, text));
}
}
chain.reverse();
chain
}
/// 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"];
@@ -113,6 +213,53 @@ pub fn format_size(bytes: u64) -> String {
}
}
/// The 大小 column, as WinSCP and Explorer show it: whole kilobytes rounded
/// up, with thousands separators (`4,008,960 KB`).
pub fn format_kilobytes(bytes: u64) -> String {
let kilobytes = bytes.div_ceil(1024).to_string();
let mut grouped = String::with_capacity(kilobytes.len() + kilobytes.len() / 3 + 3);
for (index, digit) in kilobytes.chars().enumerate() {
if index > 0 && (kilobytes.len() - index).is_multiple_of(3) {
grouped.push(',');
}
grouped.push(digit);
}
grouped.push_str(" KB");
grouped
}
/// The 修改时间 column in local time: `2026/4/22 12:44:53`.
pub fn format_changed(seconds: i64) -> String {
chrono::DateTime::from_timestamp(seconds, 0)
.map(|time| {
time.with_timezone(&chrono::Local)
.format("%Y/%-m/%-d %-H:%M:%S")
.to_string()
})
.unwrap_or_default()
}
/// The 权限 column: `rwxr-xr-x`, with setuid, setgid and sticky shown as
/// `s`/`S` and `t`/`T` like `ls -l`.
pub fn format_rights(mode: u32) -> String {
let special = [(0o4000, 's', 'S'), (0o2000, 's', 'S'), (0o1000, 't', 'T')];
let mut rights = String::with_capacity(9);
for (class, (special_bit, with_x, without_x)) in special.into_iter().enumerate() {
let shift = 6 - class * 3;
let bits = (mode >> shift) & 0o7;
rights.push(if bits & 0o4 != 0 { 'r' } else { '-' });
rights.push(if bits & 0o2 != 0 { 'w' } else { '-' });
let execute = bits & 0o1 != 0;
rights.push(match (mode & special_bit != 0, execute) {
(true, true) => with_x,
(true, false) => without_x,
(false, true) => 'x',
(false, false) => '-',
});
}
rights
}
#[cfg(test)]
mod tests {
use super::*;
@@ -125,10 +272,81 @@ mod tests {
assert_eq!(format_size(0), "0 B");
}
#[test]
fn kilobytes_round_up_and_group_thousands() {
assert_eq!(format_kilobytes(0), "0 KB");
assert_eq!(format_kilobytes(1), "1 KB");
assert_eq!(format_kilobytes(1024), "1 KB");
assert_eq!(format_kilobytes(137_216), "134 KB");
assert_eq!(format_kilobytes(999 * 1024 + 1), "1,000 KB");
assert_eq!(format_kilobytes(4_105_175_040), "4,008,960 KB");
}
#[test]
fn rights_follow_ls() {
assert_eq!(format_rights(0o100_755), "rwxr-xr-x");
assert_eq!(format_rights(0o600), "rw-------");
assert_eq!(format_rights(0o41_777), "rwxrwxrwt");
assert_eq!(format_rights(0o1_776), "rwxrwxrwT");
assert_eq!(format_rights(0o4_755), "rwsr-xr-x");
assert_eq!(format_rights(0o2_745), "rwxr-Sr-x");
}
#[test]
fn changed_uses_unpadded_date_and_seconds() {
let formatted = format_changed(1_700_000_000);
let (date, time) = formatted.split_once(' ').unwrap();
let date: Vec<_> = date.split('/').collect();
let time: Vec<_> = time.split(':').collect();
assert_eq!(date[0].len(), 4, "{formatted}");
assert!(date[1..].iter().all(|part| !part.starts_with('0')));
assert_eq!(date.len(), 3);
assert_eq!(time.len(), 3);
assert_eq!(time[2].len(), 2);
}
#[test]
fn type_label_uses_extension_for_files() {
assert_eq!(FileEntry::file("a.log", 1, "", "").type_label(), "LOG 文件");
assert_eq!(FileEntry::file(".zshrc", 1, "", "").type_label(), "文件");
assert_eq!(FileEntry::dir("etc", "", "").type_label(), "文件夹");
assert_eq!(FileEntry::file("a.log", 1).type_label(), "LOG 文件");
assert_eq!(FileEntry::file(".zshrc", 1).type_label(), "文件");
assert_eq!(FileEntry::dir("etc").type_label(), "文件夹");
assert_eq!(FileEntry::parent().type_label(), "上级目录");
}
#[test]
fn ancestors_run_from_the_root_down() {
assert_eq!(
path_ancestors("/home/tester/目录", true),
[
("/".to_string(), "/".to_string()),
("home".into(), "/home".into()),
("tester".into(), "/home/tester".into()),
("目录".into(), "/home/tester/目录".into()),
]
);
assert_eq!(
path_ancestors("/", true),
[("/".to_string(), "/".to_string())]
);
#[cfg(unix)]
assert_eq!(
path_ancestors("/Users/me", false),
[
("/".to_string(), "/".to_string()),
("Users".into(), "/Users".into()),
("me".into(), "/Users/me".into()),
]
);
assert!(path_ancestors("", true).is_empty());
}
#[test]
fn links_to_directories_count_as_directories() {
let link = FileEntry {
target: Some(FileKind::Dir),
..FileEntry::new("bin", FileKind::Symlink)
};
assert!(link.is_dir() && link.is_link());
assert!(!FileEntry::new("broken", FileKind::Symlink).is_dir());
}
}
+205
View File
@@ -0,0 +1,205 @@
//! Menus of one file pane: the list's context menu and the toolbar's
//! bookmark and 新建 menus. Each item dispatches the same `ExplorerCommand`
//! as the toolbar button with that verb.
use super::NewEntryKind;
use crate::{
app::{CatalogIcon, ExplorerAction, ExplorerCommand},
session::SessionId,
};
use gpui_kit::component::{Icon, IconName, menu::PopupMenu};
use gpui_kit::*;
/// What the menus need to know about a pane, read when a menu opens.
#[derive(Clone, Debug)]
pub(super) struct PaneMenuState {
pub remote: bool,
pub session: SessionId,
/// The rows the item commands act on (the selection).
pub targets: Vec<String>,
/// The single selected row is a directory (or a link to one).
pub opens_directory: bool,
pub can_go_up: bool,
pub can_go_home: bool,
pub can_go_back: bool,
pub can_go_forward: bool,
pub path: String,
pub bookmarks: Vec<String>,
/// Commands that change files can run now.
pub can_modify: bool,
/// A transfer can start now.
pub can_transfer: bool,
}
impl PaneMenuState {
fn action(&self, command: ExplorerCommand) -> Box<dyn Action> {
Box::new(ExplorerAction::new(self.session, command))
}
}
/// Right-click on a row: commands for the selection.
pub(super) fn item_menu(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu {
let remote = state.remote;
let none = state.targets.is_empty() || !state.can_modify;
let menu = if state.opens_directory && state.targets.len() == 1 {
menu.menu_with_icon(
"打开",
Icon::new(IconName::FolderOpen),
state.action(ExplorerCommand::Open { remote }),
)
.separator()
} else {
menu
};
let (verb, icon) = if remote {
("下载…", CatalogIcon::Download)
} else {
("上传…", CatalogIcon::Upload)
};
menu.menu_with_icon_and_disabled(
verb,
Icon::new(icon),
state.action(ExplorerCommand::Transfer { remote }),
state.targets.is_empty() || !state.can_transfer,
)
.separator()
.menu_with_icon_and_disabled(
"删除",
Icon::new(CatalogIcon::Trash),
state.action(ExplorerCommand::Delete { remote }),
none,
)
.menu_with_icon_and_disabled(
"重命名…",
Icon::new(CatalogIcon::SquarePen),
state.action(ExplorerCommand::Rename { remote }),
none || state.targets.len() != 1,
)
.separator()
.menu_with_icon_and_disabled(
"属性…",
Icon::new(IconName::Info),
state.action(ExplorerCommand::Properties { remote }),
none,
)
}
/// Right-click on empty space: commands for the directory itself.
pub(super) fn directory_menu(
menu: PopupMenu,
state: &PaneMenuState,
window: &mut Window,
cx: &mut Context<PopupMenu>,
) -> PopupMenu {
let remote = state.remote;
let go = state.clone();
let create = state.clone();
menu.submenu("前往", window, cx, move |menu, _, _| {
menu.menu_with_icon_and_disabled(
"上级目录",
Icon::new(CatalogIcon::FolderUp),
go.action(ExplorerCommand::Up { remote }),
!go.can_go_up,
)
.menu_with_icon_and_disabled(
"根目录",
Icon::new(CatalogIcon::FolderRoot),
go.action(ExplorerCommand::Root { remote }),
!go.can_go_up,
)
.menu_with_icon_and_disabled(
"主目录",
Icon::new(CatalogIcon::House),
go.action(ExplorerCommand::Home { remote }),
!go.can_go_home,
)
.separator()
.menu_with_icon_and_disabled(
"后退",
Icon::new(IconName::ArrowLeft),
go.action(ExplorerCommand::Back { remote }),
!go.can_go_back,
)
.menu_with_icon_and_disabled(
"前进",
Icon::new(IconName::ArrowRight),
go.action(ExplorerCommand::Forward { remote }),
!go.can_go_forward,
)
})
.menu_with_icon(
"刷新",
Icon::new(CatalogIcon::RefreshCw),
state.action(ExplorerCommand::Refresh { remote }),
)
.separator()
.menu_with_icon_and_disabled(
"添加路径到书签",
Icon::new(CatalogIcon::Bookmark),
state.action(ExplorerCommand::AddBookmark { remote }),
state.bookmarks.contains(&state.path),
)
.separator()
.submenu("新建", window, cx, move |menu, _, _| {
new_menu(menu, &create)
})
}
/// The toolbar's 新建 menu, also the context menu's 新建 submenu.
pub(super) fn new_menu(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu {
let remote = state.remote;
menu.menu_with_icon_and_disabled(
"文件夹…",
Icon::new(CatalogIcon::FolderPlus),
state.action(ExplorerCommand::New {
remote,
kind: NewEntryKind::Folder,
}),
!state.can_modify,
)
.menu_with_icon_and_disabled(
"文件…",
Icon::new(CatalogIcon::FilePlus),
state.action(ExplorerCommand::New {
remote,
kind: NewEntryKind::File,
}),
!state.can_modify,
)
}
/// The toolbar's bookmark menu: this pane's bookmarks, then add or remove the
/// current path.
pub(super) fn bookmark_menu(menu: PopupMenu, state: &PaneMenuState) -> PopupMenu {
let remote = state.remote;
let menu = if state.bookmarks.is_empty() {
menu.label("暂无书签")
} else {
state.bookmarks.iter().fold(menu, |menu, path| {
menu.menu_with_check(
path.clone(),
*path == state.path,
state.action(ExplorerCommand::Navigate {
remote,
path: path.clone(),
}),
)
})
};
let menu = menu.separator();
if state.bookmarks.contains(&state.path) {
menu.menu(
"从书签中移除路径",
state.action(ExplorerCommand::RemoveBookmark {
remote,
path: state.path.clone(),
}),
)
} else {
menu.menu_with_disabled(
"添加路径到书签",
state.action(ExplorerCommand::AddBookmark { remote }),
state.path.is_empty(),
)
}
}
+218
View File
@@ -0,0 +1,218 @@
//! Running a confirmed file operation on one pane: remote ones through the
//! SFTP worker, local ones on the background executor. Either way the pane
//! stays busy until the result arrives, then re-reads its directory.
use super::{ExplorerPanel, LoadIntent, NewEntryKind};
use crate::session::ConnectionState;
use crate::sftp::{
LocalDirectoryProvider, PermissionEdit, RemoteOperation, RemotePath, SftpCommand,
};
use anyhow::Result;
use gpui_kit::component::{WindowExt as _, notification::Notification};
use gpui_kit::*;
use std::path::{Path, PathBuf};
/// A file operation the user confirmed, with names relative to the pane's
/// current directory.
#[derive(Clone, Debug)]
pub(super) enum PaneOperation {
Delete(Vec<String>),
Rename {
from: String,
to: String,
},
Create {
kind: NewEntryKind,
name: String,
},
Permissions {
names: Vec<String>,
edit: PermissionEdit,
recursive: bool,
add_x_to_dirs: bool,
},
}
/// What to do when an operation answers.
pub(super) struct PendingOperation {
remote: bool,
failure: &'static str,
select: Option<String>,
}
impl PaneOperation {
fn failure_title(&self) -> &'static str {
match self {
PaneOperation::Delete(_) => "删除失败",
PaneOperation::Rename { .. } => "重命名失败",
PaneOperation::Create { .. } => "新建失败",
PaneOperation::Permissions { .. } => "修改权限失败",
}
}
/// The row to select after the directory is read again.
fn result_name(&self) -> Option<String> {
match self {
PaneOperation::Rename { to, .. } => Some(to.clone()),
PaneOperation::Create { name, .. } => Some(name.clone()),
_ => None,
}
}
fn to_remote(&self, directory: &str) -> Result<RemoteOperation> {
let directory = RemotePath::new(directory)?;
let paths = |names: &[String]| -> Result<Vec<RemotePath>> {
names.iter().map(|name| directory.join(name)).collect()
};
Ok(match self {
PaneOperation::Delete(names) => RemoteOperation::Delete {
paths: paths(names)?,
},
PaneOperation::Rename { from, to } => RemoteOperation::Rename {
from: directory.join(from)?,
to: directory.join(to)?,
},
PaneOperation::Create {
kind: NewEntryKind::Folder,
name,
} => RemoteOperation::CreateDirectory {
path: directory.join(name)?,
},
PaneOperation::Create {
kind: NewEntryKind::File,
name,
} => RemoteOperation::CreateFile {
path: directory.join(name)?,
},
PaneOperation::Permissions {
names,
edit,
recursive,
add_x_to_dirs,
} => RemoteOperation::SetPermissions {
paths: paths(names)?,
edit: *edit,
recursive: *recursive,
add_x_to_dirs: *add_x_to_dirs,
},
})
}
/// Blocking; runs on the background executor.
fn run_local(&self, provider: &dyn LocalDirectoryProvider, directory: &Path) -> Result<()> {
let paths = |names: &[String]| -> Vec<PathBuf> {
names.iter().map(|name| directory.join(name)).collect()
};
match self {
PaneOperation::Delete(names) => provider.trash(&paths(names)),
PaneOperation::Rename { from, to } => {
provider.rename(&directory.join(from), &directory.join(to))
}
PaneOperation::Create {
kind: NewEntryKind::Folder,
name,
} => provider.create_dir(&directory.join(name)),
PaneOperation::Create {
kind: NewEntryKind::File,
name,
} => provider.create_file(&directory.join(name)),
PaneOperation::Permissions {
names,
edit,
recursive,
add_x_to_dirs,
} => provider.set_permissions(&paths(names), *edit, *recursive, *add_x_to_dirs),
}
}
}
impl ExplorerPanel {
/// Whether a pane accepts file commands now: its side is reachable and
/// no other operation of its is running.
pub(super) fn can_modify(&self, remote: bool, cx: &App) -> bool {
(!remote || self.connection_state() == ConnectionState::Connected)
&& !self.pane(remote).read(cx).is_busy()
}
pub(super) fn start_operation(
&mut self,
remote: bool,
operation: PaneOperation,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.can_modify(remote, cx) {
return;
}
let pane = self.pane(remote).clone();
let directory = pane.read(cx).path();
self.next_operation += 1;
let id = self.next_operation;
self.operations.insert(
id,
PendingOperation {
remote,
failure: operation.failure_title(),
select: operation.result_name(),
},
);
pane.update(cx, |pane, cx| pane.set_busy(true, cx));
if remote {
match operation.to_remote(&directory) {
Ok(operation) => self.send(SftpCommand::Operate {
request_id: id,
operation,
}),
Err(error) => self.finish_operation(id, Err(format!("{error:#}")), window, cx),
}
} else {
let provider = self.local_provider.clone();
cx.spawn_in(window, async move |this, cx| {
let result = cx
.background_spawn(async move {
operation
.run_local(provider.as_ref(), Path::new(&directory))
.map_err(|error| format!("{error:#}"))
})
.await;
let _ = this.update_in(cx, |this, window, cx| {
this.finish_operation(id, result, window, cx)
});
})
.detach();
}
}
pub(super) fn finish_operation(
&mut self,
id: u64,
result: Result<(), String>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(pending) = self.operations.remove(&id) else {
return;
};
let pane = self.pane(pending.remote).clone();
pane.update(cx, |pane, cx| {
pane.set_busy(false, cx);
if let (Ok(()), Some(name)) = (&result, pending.select) {
pane.select_after_load(name);
}
});
if let Err(message) = result {
window.push_notification(Notification::error(message).title(pending.failure), cx);
}
// Re-read even after a failure: a delete may have removed part of a tree.
if !pending.remote || self.connection_state() == ConnectionState::Connected {
let path = pane.read(cx).path();
self.navigate(pending.remote, path, LoadIntent::Reload, window, cx);
}
}
/// The connection dropped: remote operations will not answer.
pub(super) fn abandon_remote_operations(&mut self, cx: &mut Context<Self>) {
self.operations.retain(|_, pending| !pending.remote);
self.remote.update(cx, |pane, cx| pane.set_busy(false, cx));
}
}
+454
View File
@@ -0,0 +1,454 @@
//! 属性: what the selection is, and its rwx permissions as WinSCP edits
//! them: a 3×3 grid and an octal field that stay in step. With several items,
//! a bit that differs between them is left alone unless the user changes it.
use super::{ExplorerPanel, FileEntry, format_changed, format_size};
use crate::app::ExplorerDispatch as _;
use crate::app::{ExplorerAction, ExplorerCommand};
use crate::sftp::PermissionEdit;
use gpui_kit::component::{
ActiveTheme as _, Disableable as _, Sizable as _, WindowExt as _,
button::{Button, ButtonVariants as _},
checkbox::Checkbox,
dialog::{DialogAction, DialogClose, DialogFooter},
h_flex,
input::{Input, InputEvent, InputState},
separator::Separator,
v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
/// The nine rwx bits, owner read first.
const BITS: [u32; 9] = [
0o400, 0o200, 0o100, 0o040, 0o020, 0o010, 0o004, 0o002, 0o001,
];
/// The permission grid's state for one or more items.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PermissionDraft {
value: u32,
/// Bits that differ between the items.
mixed: u32,
/// Bits the user has set, by checkbox or octal field.
touched: u32,
}
impl PermissionDraft {
pub fn from_modes(modes: &[u32]) -> Self {
let all = modes.iter().fold(0o777, |bits, mode| bits & mode) & 0o777;
let any = modes.iter().fold(0, |bits, mode| bits | mode) & 0o777;
Self {
value: all,
mixed: any & !all,
touched: 0,
}
}
pub fn is_checked(&self, bit: u32) -> bool {
self.value & bit != 0
}
/// Differs between the items and has not been set by the user.
pub fn is_mixed(&self, bit: u32) -> bool {
self.mixed & !self.touched & bit != 0
}
pub fn has_mixed(&self) -> bool {
self.mixed & !self.touched != 0
}
pub fn set_bit(&mut self, bit: u32, on: bool) {
self.touched |= bit;
if on {
self.value |= bit;
} else {
self.value &= !bit;
}
}
/// A complete octal value replaces all nine bits.
pub fn set_octal(&mut self, mode: u32) {
self.value = mode & 0o777;
self.touched = 0o777;
}
/// The octal field's text; empty while any bit is mixed.
pub fn octal(&self) -> String {
if self.has_mixed() {
String::new()
} else {
format!("{:03o}", self.value)
}
}
/// The change to apply: only the bits the user touched.
pub fn edit(&self) -> PermissionEdit {
PermissionEdit::new(self.value & self.touched, !self.value & self.touched)
}
}
/// Parse three octal digits.
fn parse_octal(text: &str) -> Option<u32> {
(text.len() == 3)
.then(|| u32::from_str_radix(text, 8).ok())
.flatten()
}
struct PropertiesForm {
items: Vec<FileEntry>,
location: String,
remote: bool,
draft: PermissionDraft,
/// Links carry no permissions of their own; only the rest change.
editable: bool,
has_dirs: bool,
recursive: bool,
add_x_to_dirs: bool,
octal: Entity<InputState>,
_subscription: Subscription,
}
impl PropertiesForm {
fn new(
items: Vec<FileEntry>,
location: String,
remote: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let modes: Vec<u32> = items
.iter()
.filter(|item| !item.is_link())
.filter_map(|item| item.permissions)
.collect();
let draft = PermissionDraft::from_modes(&modes);
let octal = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("—")
.validate(|text, _| {
text.len() <= 3 && text.chars().all(|c| ('0'..='7').contains(&c))
})
.default_value(draft.octal())
});
let subscription = cx.subscribe_in(&octal, window, |this, input, event, _, cx| {
if let InputEvent::Change = event
&& let Some(mode) = parse_octal(input.read(cx).value().as_ref())
{
this.draft.set_octal(mode);
cx.notify();
}
});
Self {
has_dirs: items.iter().any(|item| item.kind == super::FileKind::Dir),
editable: !modes.is_empty(),
items,
location,
remote,
draft,
recursive: false,
add_x_to_dirs: true,
octal,
_subscription: subscription,
}
}
fn row(label: &'static str, value: String, cx: &App) -> impl IntoElement {
h_flex()
.gap_3()
.text_sm()
.child(
div()
.w_16()
.flex_shrink_0()
.text_color(cx.theme().muted_foreground)
.child(label),
)
.child(div().min_w_0().text_ellipsis().child(value))
}
/// One value when every item agrees, otherwise a note that they differ.
fn shared(values: impl Iterator<Item = Option<String>>) -> String {
let values: Vec<_> = values.collect();
match values.first() {
Some(first) if values.iter().all(|value| value == first) => {
first.clone().unwrap_or_else(|| "—".into())
}
Some(_) => "(不一致)".into(),
None => "—".into(),
}
}
}
impl Render for PropertiesForm {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let size = if self.items.iter().any(FileEntry::is_dir) {
"—".to_string()
} else {
let bytes: u64 = self.items.iter().map(|item| item.size).sum();
format!("{}({bytes} 字节)", format_size(bytes))
};
let classes = ["所有者", "组", "其他"];
let verbs = ["读", "写", "执行"];
let grid = v_flex()
.gap_1()
.children(classes.iter().enumerate().map(|(row, class)| {
h_flex()
.gap_3()
.text_sm()
.child(div().w_16().flex_shrink_0().child(*class))
.children(verbs.iter().enumerate().map(|(column, verb)| {
let index = row * 3 + column;
let bit = BITS[index];
Checkbox::new(("perm", index))
.label(*verb)
.small()
.checked(self.draft.is_checked(bit))
.disabled(!self.editable)
.on_change(cx.listener(move |this, checked: &bool, window, cx| {
this.draft.set_bit(bit, *checked);
let octal = this.draft.octal();
this.octal
.update(cx, |input, cx| input.set_value(octal, window, cx));
cx.notify();
}))
}))
}));
v_flex()
.gap_3()
.child(Self::row("位置", self.location.clone(), cx))
.child(Self::row("大小", size, cx))
.when(self.items.len() == 1, |this| {
this.child(Self::row(
"修改时间",
self.items[0]
.modified
.map(format_changed)
.unwrap_or_default(),
cx,
))
})
.when(self.remote, |this| {
this.child(Self::row(
"所有者",
Self::shared(
self.items
.iter()
.map(|item| item.owner.as_ref().map(|o| o.to_string())),
),
cx,
))
.child(Self::row(
"组",
Self::shared(
self.items
.iter()
.map(|item| item.group.as_ref().map(|g| g.to_string())),
),
cx,
))
})
.child(Separator::horizontal())
.child(grid)
.child(
h_flex()
.gap_3()
.text_sm()
.child(div().w_16().flex_shrink_0().child("八进制"))
.child(
div().w_20().child(
Input::new(&self.octal)
.id("perm-octal")
.small()
.disabled(!self.editable),
),
),
)
.when(self.draft.has_mixed(), |this| {
this.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("部分项目权限不同,未改动的位保持原样。"),
)
})
.when(!self.editable, |this| {
this.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(if self.items.iter().all(FileEntry::is_link) {
"符号链接没有独立的权限。"
} else {
"此系统不支持修改权限。"
}),
)
})
.when(self.has_dirs && self.editable, |this| {
this.child(
v_flex()
.gap_1()
.child(
Checkbox::new("perm-recursive")
.label("同时应用到其中的文件和子目录")
.small()
.checked(self.recursive)
.on_change(cx.listener(|this, checked: &bool, _, cx| {
this.recursive = *checked;
cx.notify();
})),
)
.child(
Checkbox::new("perm-dir-x")
.label("为目录添加执行权限(X)")
.small()
.checked(self.add_x_to_dirs)
.disabled(!self.recursive)
.on_change(cx.listener(|this, checked: &bool, _, cx| {
this.add_x_to_dirs = *checked;
cx.notify();
})),
),
)
})
}
}
impl ExplorerPanel {
pub(super) fn open_properties(
&mut self,
remote: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let pane = self.pane(remote).read(cx);
let items = pane.selected_entries(cx);
let location = pane.path();
if items.is_empty() || !self.can_modify(remote, cx) || window.has_active_dialog(cx) {
return;
}
let title = if let [item] = items.as_slice() {
format!("「{}」的属性", item.name)
} else {
format!("{} 个项目的属性", items.len())
};
let names: Vec<String> = items
.iter()
.filter(|item| !item.is_link())
.map(|item| item.name.to_string())
.collect();
let form = cx.new(|cx| PropertiesForm::new(items, location, remote, window, cx));
let (dispatch, sid, generation) =
(self.dispatch.clone(), self.session_id(), self.generation());
let focus = window.focused(cx);
window.open_dialog(cx, move |dialog, _, _| {
dialog
.title(title.clone())
.child(form.clone())
.footer(
DialogFooter::new()
.child(DialogClose::new().trigger(|button| button.label("取消")))
.child(
DialogAction::new()
.child(Button::new("commit").primary().label("应用")),
),
)
.on_ok({
let (form, dispatch, names) = (form.clone(), dispatch.clone(), names.clone());
move |_, window, cx| {
let (edit, recursive, add_x_to_dirs, editable) = {
let form = form.read(cx);
let recursive = form.recursive && form.has_dirs;
(
form.draft.edit(),
recursive,
recursive && form.add_x_to_dirs,
form.editable,
)
};
// Nothing to change: close without a round trip.
if !editable || (edit.is_empty() && !add_x_to_dirs) {
return true;
}
dispatch.dispatch_explorer_action(
&ExplorerAction::new(
sid,
ExplorerCommand::ApplyPermissions {
remote,
names: names.clone(),
edit,
recursive,
add_x_to_dirs,
},
)
.with_generation(generation),
window,
cx,
);
true
}
})
.on_close({
let focus = focus.clone();
move |_, window, cx| {
if let Some(focus) = &focus {
window.focus(focus, cx);
}
}
})
});
}
}
#[cfg(test)]
mod tests {
// Explicit imports: `gpui_kit::*` would shadow `#[test]`.
use super::{PermissionDraft, parse_octal};
use crate::sftp::PermissionEdit;
#[test]
fn one_item_edits_exactly_what_the_user_changed() {
let mut draft = PermissionDraft::from_modes(&[0o100_644]);
assert_eq!(draft.octal(), "644");
assert!(!draft.has_mixed());
draft.set_bit(0o100, true);
assert_eq!(draft.octal(), "744");
assert_eq!(draft.edit(), PermissionEdit::new(0o100, 0));
draft.set_octal(0o600);
assert_eq!(draft.edit(), PermissionEdit::exact(0o600));
assert_eq!(
draft.edit().apply(0o104_755, false, false),
0o104_600,
"setuid stays"
);
}
#[test]
fn differing_bits_stay_per_item_until_touched() {
let mut draft = PermissionDraft::from_modes(&[0o644, 0o755]);
assert!(draft.is_checked(0o400) && !draft.is_mixed(0o400));
assert!(draft.is_mixed(0o100) && !draft.is_checked(0o100));
assert_eq!(draft.octal(), "", "the octal field cannot show a mix");
draft.set_bit(0o002, true);
let edit = draft.edit();
assert_eq!(edit.apply(0o644, false, false), 0o646);
assert_eq!(edit.apply(0o755, false, false), 0o757);
draft.set_bit(0o100, false);
assert!(!draft.has_mixed() || draft.is_mixed(0o010));
}
#[test]
fn directories_can_gain_search_where_they_can_be_read() {
let edit = PermissionEdit::exact(0o644);
assert_eq!(edit.apply(0o40_700, true, true), 0o40_755);
assert_eq!(edit.apply(0o40_700, true, false), 0o40_644);
assert_eq!(edit.apply(0o100_700, false, true), 0o100_644);
}
#[test]
fn only_three_octal_digits_parse() {
assert_eq!(parse_octal("755"), Some(0o755));
assert_eq!(parse_octal("75"), None);
assert_eq!(parse_octal("7555"), None);
}
}
+269
View File
@@ -0,0 +1,269 @@
//! WinSCP-style multi-selection over one directory listing, kept by name so it
//! survives sorting and refreshes.
use serde::Deserialize;
use std::collections::HashSet;
/// The `..` row: it can hold the cursor but is never part of a selection.
const PARENT: &str = "..";
/// How a row click changes the selection.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ClickMode {
/// A plain click: select only this row.
Replace,
/// ⌘/Ctrl-click: add or remove this row.
Toggle,
/// Shift-click: select the rows from the anchor to this one.
Extend,
}
/// A keyboard cursor movement.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
pub enum CursorMotion {
Up,
Down,
PageUp,
PageDown,
Home,
End,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Selection {
names: HashSet<String>,
cursor: Option<String>,
anchor: Option<String>,
}
impl Selection {
pub fn names(&self) -> &HashSet<String> {
&self.names
}
pub fn cursor(&self) -> Option<&str> {
self.cursor.as_deref()
}
pub fn contains(&self, name: &str) -> bool {
self.names.contains(name)
}
pub fn is_empty(&self) -> bool {
self.names.is_empty()
}
pub fn len(&self) -> usize {
self.names.len()
}
/// Apply a row click. `order` is the displayed row order, `..` included.
pub fn click(&mut self, name: &str, mode: ClickMode, order: &[&str]) {
if !order.contains(&name) {
return;
}
match mode {
ClickMode::Replace => {
self.names = selectable(name).into_iter().collect();
self.anchor = Some(name.to_string());
}
ClickMode::Toggle => {
if let Some(name) = selectable(name)
&& !self.names.remove(&name)
{
self.names.insert(name);
}
self.anchor = Some(name.to_string());
}
ClickMode::Extend => {
let anchor = self
.anchor
.clone()
.or_else(|| self.cursor.clone())
.unwrap_or_else(|| name.to_string());
self.names = range(order, &anchor, name);
self.anchor = Some(anchor);
}
}
self.cursor = Some(name.to_string());
}
/// Move the cursor, replacing the selection with the new row, or with the
/// range from the anchor when `extend`. `page` is the number of visible
/// rows. Returns the new cursor index.
pub fn move_cursor(
&mut self,
motion: CursorMotion,
page: usize,
extend: bool,
order: &[&str],
) -> Option<usize> {
let last = order.len().checked_sub(1)?;
let current = self
.cursor
.as_deref()
.and_then(|cursor| order.iter().position(|name| *name == cursor));
let page = page.max(1);
let next = match (motion, current) {
(CursorMotion::Home, _) => 0,
(CursorMotion::End, _) => last,
(CursorMotion::Down | CursorMotion::PageDown, None) => 0,
(CursorMotion::Up | CursorMotion::PageUp, None) => last,
(CursorMotion::Up, Some(ix)) => ix.saturating_sub(1),
(CursorMotion::Down, Some(ix)) => (ix + 1).min(last),
(CursorMotion::PageUp, Some(ix)) => ix.saturating_sub(page),
(CursorMotion::PageDown, Some(ix)) => (ix + page).min(last),
};
let name = order[next];
if extend {
self.click(name, ClickMode::Extend, order);
} else {
self.click(name, ClickMode::Replace, order);
}
Some(next)
}
/// Space / Insert: add or remove the cursor row.
pub fn toggle_cursor(&mut self, order: &[&str]) {
if let Some(cursor) = self.cursor.clone() {
self.click(&cursor, ClickMode::Toggle, order);
}
}
pub fn select_all(&mut self, order: &[&str]) {
self.names = order.iter().filter_map(|name| selectable(name)).collect();
}
/// Select exactly one row and put the cursor on it; nothing when absent.
pub fn select_only(&mut self, name: &str, order: &[&str]) {
self.click(name, ClickMode::Replace, order);
}
pub fn clear(&mut self) {
*self = Self::default();
}
/// Forget rows that are no longer listed.
pub fn retain(&mut self, order: &[&str]) {
let present: HashSet<&str> = order.iter().copied().collect();
self.names.retain(|name| present.contains(name.as_str()));
for slot in [&mut self.cursor, &mut self.anchor] {
if slot.as_deref().is_some_and(|name| !present.contains(name)) {
*slot = None;
}
}
}
/// The selected names in display order: what an operation acts on.
pub fn targets(&self, order: &[&str]) -> Vec<String> {
order
.iter()
.filter(|name| self.names.contains(**name))
.map(|name| name.to_string())
.collect()
}
}
fn selectable(name: &str) -> Option<String> {
(name != PARENT).then(|| name.to_string())
}
fn range(order: &[&str], from: &str, to: &str) -> HashSet<String> {
let (Some(start), Some(end)) = (
order.iter().position(|name| *name == from),
order.iter().position(|name| *name == to),
) else {
return selectable(to).into_iter().collect();
};
order[start.min(end)..=start.max(end)]
.iter()
.filter_map(|name| selectable(name))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
const ROWS: [&str; 5] = ["..", "a", "b", "c", "d"];
fn names(selection: &Selection) -> Vec<String> {
selection.targets(&ROWS)
}
#[test]
fn clicks_replace_toggle_and_extend_from_the_anchor() {
let mut selection = Selection::default();
selection.click("b", ClickMode::Replace, &ROWS);
assert_eq!(names(&selection), ["b"]);
selection.click("d", ClickMode::Toggle, &ROWS);
assert_eq!(names(&selection), ["b", "d"]);
selection.click("b", ClickMode::Toggle, &ROWS);
assert_eq!(names(&selection), ["d"]);
assert_eq!(selection.cursor(), Some("b"));
selection.click("a", ClickMode::Extend, &ROWS);
assert_eq!(names(&selection), ["a", "b"]);
selection.click("d", ClickMode::Extend, &ROWS);
assert_eq!(names(&selection), ["b", "c", "d"]);
}
#[test]
fn the_parent_row_takes_the_cursor_but_is_never_selected() {
let mut selection = Selection::default();
selection.click("..", ClickMode::Replace, &ROWS);
assert!(selection.is_empty());
assert_eq!(selection.cursor(), Some(".."));
selection.click("b", ClickMode::Extend, &ROWS);
assert_eq!(names(&selection), ["a", "b"]);
selection.select_all(&ROWS);
assert_eq!(names(&selection), ["a", "b", "c", "d"]);
}
#[test]
fn keyboard_moves_replace_or_extend_and_clamp() {
let mut selection = Selection::default();
assert_eq!(
selection.move_cursor(CursorMotion::Down, 2, false, &ROWS),
Some(0)
);
assert!(selection.is_empty());
selection.move_cursor(CursorMotion::Down, 2, false, &ROWS);
assert_eq!(names(&selection), ["a"]);
selection.move_cursor(CursorMotion::Down, 2, true, &ROWS);
selection.move_cursor(CursorMotion::Down, 2, true, &ROWS);
assert_eq!(names(&selection), ["a", "b", "c"]);
selection.move_cursor(CursorMotion::Up, 2, true, &ROWS);
assert_eq!(names(&selection), ["a", "b"]);
assert_eq!(
selection.move_cursor(CursorMotion::PageDown, 2, false, &ROWS),
Some(4)
);
assert_eq!(
selection.move_cursor(CursorMotion::Down, 2, false, &ROWS),
Some(4)
);
assert_eq!(
selection.move_cursor(CursorMotion::Home, 2, false, &ROWS),
Some(0)
);
assert_eq!(
selection.move_cursor(CursorMotion::End, 2, false, &[]),
None
);
}
#[test]
fn space_toggles_the_cursor_row_and_retain_drops_missing_rows() {
let mut selection = Selection::default();
selection.select_only("c", &ROWS);
selection.toggle_cursor(&ROWS);
assert!(selection.is_empty());
selection.toggle_cursor(&ROWS);
selection.click("a", ClickMode::Toggle, &ROWS);
selection.retain(&["..", "a", "b"]);
assert_eq!(names(&selection), ["a"]);
assert_eq!(selection.cursor(), Some("a"));
selection.retain(&[".."]);
assert!(selection.is_empty() && selection.cursor().is_none());
}
}
@@ -3,7 +3,7 @@ use crate::app::ExplorerDispatch as _;
use crate::{
app::{ExplorerAction, ExplorerCommand},
session::SessionId,
sftp::{UploadAnswer, UploadChoice, UploadQuestionKind},
sftp::{TransferAnswer, TransferChoice, TransferDirection, TransferQuestionKind},
};
use gpui_kit::component::{
ActiveTheme as _, Sizable as _, WindowExt as _,
@@ -11,6 +11,7 @@ use gpui_kit::component::{
checkbox::Checkbox,
dialog::{DialogAction, DialogClose, DialogFooter},
form::{Field, Form},
h_flex,
input::{Input, InputState},
v_flex,
};
@@ -27,7 +28,7 @@ impl ExplorerPanel {
cx: &mut Context<Self>,
) {
if paths.is_empty()
|| self.is_uploading()
|| self.is_transferring()
|| self.connection_state() != crate::session::ConnectionState::Connected
|| window.has_active_dialog(cx)
{
@@ -100,6 +101,89 @@ impl ExplorerPanel {
})
});
}
/// 下载: confirm the remote items and choose the local directory, which
/// starts as the local pane's.
pub(super) fn open_download(
&mut self,
paths: Vec<String>,
target: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
if paths.is_empty()
|| self.is_transferring()
|| 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(|_| DownloadForm {
paths: paths.clone(),
target: target_input,
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("download-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 !std::path::Path::new(&target).is_absolute() {
form.update(cx, |form, cx| {
form.error = Some("请输入本机目录的完整路径".into());
cx.notify();
});
return false;
}
dispatch.dispatch_explorer_action(
&ExplorerAction::new(
sid,
ExplorerCommand::BeginDownload {
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;
@@ -114,16 +198,34 @@ impl ExplorerPanel {
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),
};
let verb = self.transfer_direction().verb();
let (title, primary_label, primary_choice): (SharedString, SharedString, _) =
match question.kind() {
TransferQuestionKind::Conflict => (
"同名文件已存在".into(),
"覆盖".into(),
TransferChoice::Overwrite,
),
TransferQuestionKind::Resume => (
format!("发现未完成的{verb}").into(),
format!("继续{verb}").into(),
TransferChoice::Resume,
),
TransferQuestionKind::InvalidResume => (
"无法继续此文件".into(),
format!("重新{verb}").into(),
TransferChoice::Restart,
),
TransferQuestionKind::Error => (
format!("无法{verb}此项目").into(),
"重试".into(),
TransferChoice::Retry,
),
};
let cancel_label: SharedString = format!("取消{verb}").into();
let restart_label: SharedString = format!("重新{verb}").into();
window.open_dialog(cx, move |dialog, _, _| {
let answer_button = |id: &'static str, label: &'static str, choice: UploadChoice| {
let answer_button = |id: &'static str, label: SharedString, choice: TransferChoice| {
let answered = answered.clone();
let dispatch = dispatch.clone();
let form = form.clone();
@@ -136,7 +238,7 @@ impl ExplorerPanel {
sid,
ExplorerCommand::Answer {
request_id,
answer: UploadAnswer::new(choice, all),
answer: TransferAnswer::new(choice, all),
},
)
.with_generation(generation),
@@ -147,7 +249,7 @@ impl ExplorerPanel {
})
};
dialog
.title(title)
.title(title.clone())
.overlay_closable(false)
.child(
v_flex()
@@ -159,27 +261,27 @@ impl ExplorerPanel {
.footer(
DialogFooter::new()
.child(answer_button(
"upload-question-cancel",
"取消上传",
UploadChoice::Cancel,
"transfer-question-cancel",
cancel_label.clone(),
TransferChoice::Cancel,
))
.child(answer_button(
"upload-question-skip",
"跳过",
UploadChoice::Skip,
"transfer-question-skip",
"跳过".into(),
TransferChoice::Skip,
))
.when(question.kind() == UploadQuestionKind::Resume, |footer| {
.when(question.kind() == TransferQuestionKind::Resume, |footer| {
footer.child(answer_button(
"upload-question-restart",
"重新上传",
UploadChoice::Restart,
"transfer-question-restart",
restart_label.clone(),
TransferChoice::Restart,
))
})
.child(
DialogAction::new().child(
Button::new("upload-question-confirm")
Button::new("transfer-question-confirm")
.primary()
.label(primary_label),
.label(primary_label.clone()),
),
),
)
@@ -196,7 +298,7 @@ impl ExplorerPanel {
sid,
ExplorerCommand::Answer {
request_id,
answer: UploadAnswer::new(primary_choice, all),
answer: TransferAnswer::new(primary_choice, all),
},
)
.with_generation(generation),
@@ -218,7 +320,7 @@ impl ExplorerPanel {
sid,
ExplorerCommand::Answer {
request_id,
answer: UploadAnswer::new(UploadChoice::Cancel, false),
answer: TransferAnswer::new(TransferChoice::Cancel, false),
},
)
.with_generation(generation),
@@ -278,15 +380,95 @@ impl Render for UploadForm {
})
}
}
struct DownloadForm {
paths: Vec<String>,
target: Entity<InputState>,
endpoint: String,
error: Option<String>,
}
impl DownloadForm {
fn browse(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let choice = cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: Some("选择下载到的目录".into()),
});
cx.spawn_in(window, async move |this, cx| {
if let Ok(Ok(Some(paths))) = choice.await
&& let Some(path) = paths.into_iter().next()
{
let _ = this.update_in(cx, |this, window, cx| {
this.target.update(cx, |input, cx| {
input.set_value(path.to_string_lossy().into_owned(), window, cx)
});
this.error = None;
cx.notify();
});
}
})
.detach();
}
}
impl Render for DownloadForm {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap_3()
.child(div().text_sm().child(format!(
"从 {} 下载 {} 个项目",
self.endpoint,
self.paths.len()
)))
.child(
div()
.id("download-source-list")
.max_h_32()
.overflow_y_scroll()
.text_sm()
.children(self.paths.iter().map(|p| div().child(p.clone()))),
)
.child(
Form::new().child(
Field::new().label("下载到").required(true).child(
h_flex()
.gap_2()
.child(
Input::new(&self.target)
.id("download-target")
.small()
.flex_1(),
)
.child(
Button::new("download-browse")
.small()
.label("浏览…")
.on_click(
cx.listener(|this, _, window, cx| this.browse(window, cx)),
),
),
),
),
)
.when_some(self.error.clone(), |this, error| {
this.child(
div()
.id("download-form-error")
.test_support()
.text_color(cx.theme().danger)
.child(error),
)
})
}
}
struct ConflictForm {
apply_all: bool,
kind: UploadQuestionKind,
kind: TransferQuestionKind,
}
impl Render for ConflictForm {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div().when(self.kind == UploadQuestionKind::Conflict, |this| {
div().when(self.kind == TransferQuestionKind::Conflict, |this| {
this.child(
Checkbox::new("upload-apply-all")
Checkbox::new("transfer-apply-all")
.label("应用于本批剩余冲突")
.checked(self.apply_all)
.on_change(cx.listener(|this, value, _, cx| {
@@ -298,22 +480,26 @@ impl Render for ConflictForm {
}
}
/// Session-scoped close confirmation dispatches through the same workspace handler.
pub fn confirm_close_upload(
pub fn confirm_close_transfer(
session: SessionId,
generation: u64,
direction: TransferDirection,
dispatch: FocusHandle,
window: &mut Window,
cx: &mut App,
) {
let focus = window.focused(cx);
let verb = direction.verb();
window.open_alert_dialog(cx, move |dialog, _, _| {
dialog
.title("停止上传并关闭?")
.description("上传进度会保留,下次选择相同文件和目标目录时可以继续上传。")
.title(format!("停止{verb}并关闭?"))
.description(format!(
"{verb}进度会保留,下次选择相同来源和目标目录时可以继续{verb}。"
))
.button_props(
gpui_kit::component::dialog::DialogButtonProps::default()
.ok_text("停止上传并关闭")
.cancel_text("继续上传"),
.ok_text(format!("停止{verb}并关闭"))
.cancel_text(format!("继续{verb}")),
)
.show_cancel(true)
.on_cancel({
+118 -2
View File
@@ -10,7 +10,8 @@ use std::path::Path;
use rusqlite::{Connection, params};
use super::{
AuthKind, GroupDraft, GroupId, HostOs, Session, SessionDraft, SessionGroup, SessionId,
AuthKind, BookmarkSide, GroupDraft, GroupId, HostOs, Session, SessionDraft, SessionGroup,
SessionId,
};
/// SQLite's only integer type is `i64`, so the `u64` ids cross the boundary
@@ -25,7 +26,7 @@ fn from_sql(id: i64) -> u64 {
}
/// Bumped whenever `migrate` gains a step. Stored in `PRAGMA user_version`.
const SCHEMA_VERSION: i64 = 5;
const SCHEMA_VERSION: i64 = 6;
const SCHEMA_V1: &str = "\
BEGIN;
@@ -76,6 +77,21 @@ ALTER TABLE groups ADD COLUMN expanded INTEGER NOT NULL DEFAULT 1;
PRAGMA user_version = 5;
COMMIT;";
/// Per-session SFTP bookmarks, one list per pane. The cascade removes them
/// with their session, the way WinSCP drops a site's bookmarks with the site.
const SCHEMA_V6: &str = "\
BEGIN;
CREATE TABLE bookmarks (
id INTEGER PRIMARY KEY,
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
side TEXT NOT NULL CHECK (side IN ('local', 'remote')),
path TEXT NOT NULL,
sort_order INTEGER NOT NULL,
UNIQUE (session_id, side, path)
);
PRAGMA user_version = 6;
COMMIT;";
/// Everything one launch reads back from disk.
pub struct StoredData {
/// Groups in id order; `parent` and `sort_order` give the visible tree.
@@ -84,6 +100,8 @@ pub struct StoredData {
pub sessions: Vec<Session>,
/// Sessions that have ever connected, most recently connected first.
pub recent: Vec<SessionId>,
/// SFTP bookmarks in the order they were added.
pub bookmarks: Vec<(SessionId, BookmarkSide, String)>,
}
pub struct SessionDatabase {
@@ -174,10 +192,30 @@ impl SessionDatabase {
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let bookmarks = self
.connection
.prepare("SELECT session_id, side, path FROM bookmarks ORDER BY sort_order, id")?
.query_map([], |row| {
let session: i64 = row.get(0)?;
let side: String = row.get(1)?;
Ok((
SessionId(from_sql(session)),
BookmarkSide::from_stored(&side),
row.get::<_, String>(2)?,
))
})?
.filter_map(|row| match row {
Ok((session, Some(side), path)) => Some(Ok((session, side, path))),
Ok((_, None, _)) => None,
Err(error) => Some(Err(error)),
})
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(StoredData {
groups,
sessions,
recent,
bookmarks,
})
}
@@ -311,6 +349,34 @@ impl SessionDatabase {
transaction.commit()
}
/// Append a bookmark; adding one that exists is a no-op.
pub fn insert_bookmark(
&self,
session: SessionId,
side: BookmarkSide,
path: &str,
) -> rusqlite::Result<()> {
self.connection.execute(
"INSERT OR IGNORE INTO bookmarks (session_id, side, path, sort_order) \
VALUES (?1, ?2, ?3, (SELECT COALESCE(MAX(sort_order), 0) + 1 FROM bookmarks))",
params![to_sql(session.0), side.as_str(), path],
)?;
Ok(())
}
pub fn remove_bookmark(
&self,
session: SessionId,
side: BookmarkSide,
path: &str,
) -> rusqlite::Result<()> {
self.connection.execute(
"DELETE FROM bookmarks WHERE session_id = ?1 AND side = ?2 AND path = ?3",
params![to_sql(session.0), side.as_str(), path],
)?;
Ok(())
}
/// Record the operating system a probe found on the host. `None` clears
/// it, which is what a failed probe on a rebuilt host leaves behind.
pub fn set_host_os(&self, id: SessionId, os: Option<HostOs>) -> rusqlite::Result<()> {
@@ -352,6 +418,9 @@ fn migrate(connection: &mut Connection) -> rusqlite::Result<()> {
if version < 5 {
connection.execute_batch(SCHEMA_V5)?;
}
if version < 6 {
connection.execute_batch(SCHEMA_V6)?;
}
Ok(())
}
@@ -592,6 +661,53 @@ mod tests {
assert_eq!(db.load().unwrap().sessions[0].os, None);
}
#[test]
fn v5_databases_gain_bookmarks_that_cascade_with_their_session() {
let connection = Connection::open_in_memory().unwrap();
for step in [SCHEMA_V1, SCHEMA_V2, SCHEMA_V3, SCHEMA_V4, SCHEMA_V5] {
connection.execute_batch(step).unwrap();
}
connection
.execute(
"INSERT INTO sessions (id, name, host, port, username, auth) \
VALUES (1, 'web', 'example.test', 22, 'root', 'auto'), \
(2, 'db', 'example.test', 22, 'root', 'auto')",
[],
)
.unwrap();
let db = SessionDatabase::prepare(connection).unwrap();
db.insert_bookmark(SessionId(1), BookmarkSide::Remote, "/var/log")
.unwrap();
db.insert_bookmark(SessionId(1), BookmarkSide::Local, "/Users/me")
.unwrap();
db.insert_bookmark(SessionId(1), BookmarkSide::Remote, "/etc")
.unwrap();
// Adding the same path twice keeps one bookmark in its first place.
db.insert_bookmark(SessionId(1), BookmarkSide::Remote, "/var/log")
.unwrap();
db.insert_bookmark(SessionId(2), BookmarkSide::Remote, "/srv")
.unwrap();
assert_eq!(
db.load().unwrap().bookmarks,
[
(SessionId(1), BookmarkSide::Remote, "/var/log".to_string()),
(SessionId(1), BookmarkSide::Local, "/Users/me".to_string()),
(SessionId(1), BookmarkSide::Remote, "/etc".to_string()),
(SessionId(2), BookmarkSide::Remote, "/srv".to_string()),
]
);
db.remove_bookmark(SessionId(1), BookmarkSide::Remote, "/etc")
.unwrap();
db.remove_session(SessionId(2)).unwrap();
assert_eq!(
db.load().unwrap().bookmarks,
[
(SessionId(1), BookmarkSide::Remote, "/var/log".to_string()),
(SessionId(1), BookmarkSide::Local, "/Users/me".to_string()),
]
);
}
#[test]
fn schema_never_contains_secret_columns() {
let db = SessionDatabase::in_memory().unwrap();
+1 -1
View File
@@ -217,7 +217,7 @@ pub fn confirm_delete_group(
if uploads > 0 {
description = Some(
format!(
"{}将停止 {uploads} 个上传批次并保留续传进度。",
"{}将停止 {uploads} 个传输批次并保留续传进度。",
description.unwrap_or_default()
)
.into(),
+29
View File
@@ -54,6 +54,35 @@ impl AuthKind {
}
}
/// Which pane of a session's SFTP tab a bookmark belongs to.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum BookmarkSide {
Local,
Remote,
}
impl BookmarkSide {
pub fn from_remote(remote: bool) -> Self {
if remote { Self::Remote } else { Self::Local }
}
/// The stored spelling.
pub fn as_str(self) -> &'static str {
match self {
BookmarkSide::Local => "local",
BookmarkSide::Remote => "remote",
}
}
pub fn from_stored(value: &str) -> Option<Self> {
match value {
"local" => Some(BookmarkSide::Local),
"remote" => Some(BookmarkSide::Remote),
_ => None,
}
}
}
/// The operating system running on a host, as reported by the session's own
/// shell after it connects.
///
+1 -1
View File
@@ -807,7 +807,7 @@ pub fn confirm_delete_session(
alert.description(format!(
"会一并关闭该会话已打开的终端和 SFTP 标签。{}",
if uploads > 0 {
format!("将停止 {uploads} 个上传批次并保留续传进度。")
format!("将停止 {uploads} 个传输批次并保留续传进度。")
} else {
String::new()
}
+111 -2
View File
@@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::sync::Arc;
use gpui_kit::{Context, EventEmitter, SharedString};
@@ -6,8 +7,8 @@ use crate::secrets::{NoSecretStore, SecretRef, SharedSecretStore};
use super::database::now_seconds;
use super::{
AuthKind, ConnectionState, GroupDraft, GroupId, HostOs, NodeDrop, Session, SessionDatabase,
SessionDraft, SessionGroup, SessionId, SessionNode, StoredData,
AuthKind, BookmarkSide, ConnectionState, GroupDraft, GroupId, HostOs, NodeDrop, Session,
SessionDatabase, SessionDraft, SessionGroup, SessionId, SessionNode, StoredData,
};
/// The single source of truth for sessions and groups. Created once by the
@@ -33,6 +34,8 @@ pub struct SessionStore {
active: Option<SessionId>,
/// Sessions in the order they last connected, most recent first.
recent: Vec<SessionId>,
/// SFTP bookmarks per session and pane, in the order they were added.
bookmarks: HashMap<(SessionId, BookmarkSide), Vec<String>>,
/// `None` for a memory-only store, as used by tests.
database: Option<SessionDatabase>,
/// Where passwords go. Defaults to a store that keeps nothing, so unit
@@ -65,6 +68,7 @@ impl SessionStore {
next_group_id: 1,
active: None,
recent: Vec::new(),
bookmarks: HashMap::new(),
database: None,
secrets: Arc::new(NoSecretStore),
}
@@ -84,8 +88,13 @@ impl SessionStore {
groups,
sessions,
mut recent,
bookmarks: stored_bookmarks,
} = database.load()?;
recent.truncate(MAX_RECENT);
let mut bookmarks: HashMap<_, Vec<String>> = HashMap::new();
for (session, side, path) in stored_bookmarks {
bookmarks.entry((session, side)).or_default().push(path);
}
Ok(Self {
next_group_id: groups.iter().map(|group| group.id.0).max().unwrap_or(0) + 1,
next_session_id: sessions.iter().map(|s| s.id.0).max().unwrap_or(0) + 1,
@@ -93,6 +102,7 @@ impl SessionStore {
sessions,
active: None,
recent,
bookmarks,
database: Some(database),
secrets: Arc::new(NoSecretStore),
})
@@ -369,6 +379,8 @@ impl SessionStore {
let before = self.sessions.len();
self.sessions.retain(|s| s.id != id);
self.recent.retain(|recent| *recent != id);
// The database drops them through `ON DELETE CASCADE`.
self.bookmarks.retain(|(session, _), _| *session != id);
if self.active == Some(id) {
self.active = None;
}
@@ -484,6 +496,81 @@ impl SessionStore {
true
}
/// A session's SFTP bookmarks for one pane, oldest first.
pub fn bookmarks(&self, id: SessionId, side: BookmarkSide) -> &[String] {
self.bookmarks
.get(&(id, side))
.map(Vec::as_slice)
.unwrap_or_default()
}
pub fn add_bookmark(
&mut self,
id: SessionId,
side: BookmarkSide,
path: &str,
cx: &mut Context<Self>,
) -> bool {
if !self.add_bookmark_unnotified(id, side, path) {
return false;
}
if let Some(database) = self.database.as_ref() {
let result = database.insert_bookmark(id, side, path);
self.report(result, "添加书签", cx);
}
cx.notify();
true
}
pub fn add_bookmark_unnotified(
&mut self,
id: SessionId,
side: BookmarkSide,
path: &str,
) -> bool {
if self.session(id).is_none() || path.is_empty() {
return false;
}
let list = self.bookmarks.entry((id, side)).or_default();
if list.iter().any(|existing| existing == path) {
return false;
}
list.push(path.to_string());
true
}
pub fn remove_bookmark(
&mut self,
id: SessionId,
side: BookmarkSide,
path: &str,
cx: &mut Context<Self>,
) -> bool {
if !self.remove_bookmark_unnotified(id, side, path) {
return false;
}
if let Some(database) = self.database.as_ref() {
let result = database.remove_bookmark(id, side, path);
self.report(result, "删除书签", cx);
}
cx.notify();
true
}
pub fn remove_bookmark_unnotified(
&mut self,
id: SessionId,
side: BookmarkSide,
path: &str,
) -> bool {
let Some(list) = self.bookmarks.get_mut(&(id, side)) else {
return false;
};
let before = list.len();
list.retain(|existing| existing != path);
list.len() != before
}
/// Persist a group's expanded or collapsed state after a tree interaction.
pub fn set_group_expanded(
&mut self,
@@ -852,6 +939,28 @@ mod tests {
SessionDraft::new(name, "10.0.0.1", 22, "root", AuthKind::Auto, group)
}
#[test]
fn bookmarks_are_per_pane_and_leave_with_their_session() {
let mut store = SessionStore::empty();
let group = store.insert_group_unnotified(GroupDraft::new("生产", None));
let web = store.insert_unnotified(draft("web", Some(group)));
let db = store.insert_unnotified(draft("db", None));
assert!(store.add_bookmark_unnotified(web, BookmarkSide::Remote, "/var/log"));
assert!(!store.add_bookmark_unnotified(web, BookmarkSide::Remote, "/var/log"));
assert!(store.add_bookmark_unnotified(web, BookmarkSide::Local, "/tmp"));
assert!(store.add_bookmark_unnotified(db, BookmarkSide::Remote, "/srv"));
assert!(!store.add_bookmark_unnotified(SessionId(99), BookmarkSide::Remote, "/x"));
assert_eq!(store.bookmarks(web, BookmarkSide::Remote), ["/var/log"]);
assert_eq!(store.bookmarks(web, BookmarkSide::Local), ["/tmp"]);
assert!(store.remove_bookmark_unnotified(db, BookmarkSide::Remote, "/srv"));
assert!(store.bookmarks(db, BookmarkSide::Remote).is_empty());
// Removing a group matches the database cascade.
store.remove_group_unnotified(group);
assert!(store.bookmarks(web, BookmarkSide::Remote).is_empty());
assert!(store.bookmarks(web, BookmarkSide::Local).is_empty());
}
#[test]
fn the_detected_system_survives_an_edit() {
let mut store = SessionStore::empty();
+166 -23
View File
@@ -1,6 +1,7 @@
use super::{DirectoryEntry, DirectoryListing, EntryKind, FileMetadata, RemotePath};
use crate::ssh::{SshConnectionConfig, SshConnector, SshHandle, SshPrompts};
use anyhow::{Result, anyhow, bail};
use futures::StreamExt as _;
use russh_sftp::{
client::{RawSftpSession, error::Error},
protocol::{FileAttributes, OpenFlags, Packet, StatusCode},
@@ -9,8 +10,17 @@ use std::sync::Arc;
/// Small protocol seam, also implemented by deterministic fault-injection tests.
pub(crate) trait RemoteFs {
/// `lstat`: a link describes itself. `None` when the path does not exist.
async fn metadata(&self, path: &RemotePath) -> Result<Option<FileMetadata>>;
/// `stat`: follows links. `None` when the path or the link target is missing.
async fn stat(&self, path: &RemotePath) -> Result<Option<FileMetadata>>;
/// The entries of a directory without `.` and `..`, kinds as `lstat` reports them.
async fn read_dir(&self, path: &RemotePath) -> Result<Vec<DirectoryEntry>>;
async fn open(&self, path: &RemotePath, create: bool) -> Result<String>;
/// Open an existing file for reading.
async fn open_read(&self, path: &RemotePath) -> Result<String>;
/// Up to `len` bytes at `offset`; may return fewer. `None` at end of file.
async fn read(&self, handle: &str, offset: u64, len: u32) -> Result<Option<Vec<u8>>>;
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<()>;
@@ -25,6 +35,10 @@ pub(crate) trait RemoteFs {
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<()>;
/// Remove an empty directory.
async fn rmdir(&self, path: &RemotePath) -> Result<()>;
/// Set the permission bits of a path (`setstat`, which follows links).
async fn set_permissions(&self, path: &RemotePath, permissions: u32) -> Result<()>;
fn atomic_replace(&self) -> bool;
}
@@ -113,10 +127,57 @@ impl SftpClient {
.filename,
)?)
}
/// A directory for the browser: canonical path, owners, and what each
/// link points to (resolved concurrently, one `stat` per link).
pub async fn list(&self, path: &RemotePath) -> Result<DirectoryListing> {
let path = self.canonicalize(path).await?;
let entries = self.read_dir(&path).await?;
let entries = futures::stream::iter(entries)
.map(|entry| {
let path = &path;
async move {
if entry.metadata().kind() != EntryKind::Symlink {
return Ok(entry);
}
let target = self
.stat(&path.join(entry.name())?)
.await
.or_else(|error| {
if is_network_error(&error) {
Err(error)
} else {
Ok(None)
}
})?;
Ok::<_, anyhow::Error>(entry.with_target_kind(target.map(|m| m.kind())))
}
})
.buffered(16)
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>>>()?;
Ok(DirectoryListing::new(path.as_str(), entries))
}
}
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 stat(&self, path: &RemotePath) -> Result<Option<FileMetadata>> {
match self.raw.stat(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 read_dir(&self, path: &RemotePath) -> Result<Vec<DirectoryEntry>> {
let handle = self.raw.opendir(path.as_str()).await?.handle;
let result: Result<DirectoryListing> = async {
let result: Result<Vec<DirectoryEntry>> = async {
let mut entries = Vec::new();
loop {
match self.raw.readdir(&handle).await {
@@ -127,30 +188,31 @@ impl SftpClient {
}
checked_text(&entry.filename)?;
path.join(&entry.filename)?;
entries
.push(DirectoryEntry::new(entry.filename, metadata(&entry.attrs)));
let (owner, group) = parse_longname(&entry.longname)
.map(|(owner, group)| (Some(owner), Some(group)))
.unwrap_or_else(|| {
(
entry.attrs.uid.map(|id| id.to_string()),
entry.attrs.gid.map(|id| id.to_string()),
)
});
entries.push(
DirectoryEntry::new(entry.filename, metadata(&entry.attrs))
.with_owner(owner, group),
);
}
}
Err(Error::Status(status)) if status.status_code == StatusCode::Eof => break,
Err(error) => return Err(error.into()),
}
}
Ok(DirectoryListing::new(path.as_str(), entries))
Ok(entries)
}
.await;
let closed = self.raw.close(handle).await;
let listing = result?;
let entries = 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()),
}
Ok(entries)
}
async fn open(&self, path: &RemotePath, create: bool) -> Result<String> {
let flags = if create {
@@ -164,6 +226,20 @@ impl RemoteFs for SftpClient {
.await?
.handle)
}
async fn open_read(&self, path: &RemotePath) -> Result<String> {
Ok(self
.raw
.open(path.as_str(), OpenFlags::READ, FileAttributes::empty())
.await?
.handle)
}
async fn read(&self, handle: &str, offset: u64, len: u32) -> Result<Option<Vec<u8>>> {
match self.raw.read(handle, offset, len).await {
Ok(data) => Ok(Some(data.data)),
Err(Error::Status(status)) if status.status_code == StatusCode::Eof => Ok(None),
Err(error) => Err(error.into()),
}
}
async fn sync(&self, handle: &str) -> Result<()> {
if self.fsync {
self.raw.fsync(handle).await?;
@@ -248,19 +324,34 @@ impl RemoteFs for SftpClient {
Err(error) => Err(error.into()),
}
}
async fn rmdir(&self, path: &RemotePath) -> Result<()> {
self.raw.rmdir(path.as_str()).await?;
Ok(())
}
async fn set_permissions(&self, path: &RemotePath, permissions: u32) -> Result<()> {
self.raw
.setstat(
path.as_str(),
FileAttributes {
permissions: Some(permissions & 0o7777),
..FileAttributes::empty()
},
)
.await?;
Ok(())
}
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
// Compare the whole type field: russh-sftp's `is_dir()` tests one bit,
// which sockets and block devices also carry.
let kind = match attrs.permissions.map(|mode| mode & 0o170_000) {
Some(0o040_000) => EntryKind::Directory,
Some(0o120_000) => EntryKind::Symlink,
Some(0o100_000) => EntryKind::File,
_ => EntryKind::Other,
};
FileMetadata::new(
kind,
@@ -312,6 +403,30 @@ pub(crate) fn is_network_error(error: &anyhow::Error) -> bool {
})
}
/// Owner and group from an SFTP v3 `longname`, the server's `ls -l` line:
/// `drwxr-xr-x 2 root root 4096 Jan 1 12:00 name`. `None` when
/// the line does not have that shape; callers fall back to numeric ids.
pub(crate) fn parse_longname(line: &str) -> Option<(String, String)> {
let mut fields = line.split_whitespace();
let mode = fields.next()?;
let mut chars = mode.chars();
if !chars.next().is_some_and(|c| "-dlcbpsD".contains(c))
|| chars.by_ref().take(9).count() != 9
|| !mode
.chars()
.skip(1)
.take(9)
.all(|c| "rwxsStTl-".contains(c))
{
return None;
}
fields.next()?.parse::<u64>().ok()?;
let owner = fields.next()?;
let group = fields.next()?;
fields.next()?.parse::<u64>().ok()?;
Some((owner.to_string(), group.to_string()))
}
// 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> {
@@ -320,3 +435,31 @@ fn checked_text(value: &str) -> Result<String> {
}
Ok(value.to_string())
}
#[cfg(test)]
mod longname_tests {
use super::parse_longname;
#[test]
fn parses_openssh_and_rejects_other_shapes() {
assert_eq!(
parse_longname("drwxr-xr-x 2 root wheel 4096 Jan 1 12:00 bin"),
Some(("root".into(), "wheel".into()))
);
assert_eq!(
parse_longname("-rw-r--r--+ 1 用户 staff 12 Sep 24 10:36 文件 甲.txt"),
Some(("用户".into(), "staff".into()))
);
assert_eq!(
parse_longname("lrwxrwxrwx 1 0 0 7 Apr 22 2024 bin -> usr/bin"),
Some(("0".into(), "0".into()))
);
assert_eq!(parse_longname(""), None);
assert_eq!(parse_longname("bin"), None);
assert_eq!(parse_longname("drwxr-xr-x root root 4096 Jan 1 bin"), None);
assert_eq!(
parse_longname("xrwxr-xr-x 1 root root 1 Jan 1 12:00 x"),
None
);
}
}
+36 -9
View File
@@ -1,7 +1,8 @@
use super::{SftpEvent, UploadAnswer, UploadChoice, UploadQuestion, UploadQuestionKind};
use super::{SftpEvent, TransferAnswer, TransferChoice, TransferQuestion, TransferQuestionKind};
use anyhow::Result;
use async_channel::{Receiver, Sender};
use std::{
collections::HashSet,
future::Future,
sync::atomic::{AtomicU64, Ordering},
};
@@ -11,17 +12,17 @@ static NEXT_QUESTION: AtomicU64 = AtomicU64::new(1);
pub(crate) struct Cancelled;
impl std::fmt::Display for Cancelled {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("上传已停止,进度已保留")
f.write_str("传输已停止,进度已保留")
}
}
impl std::error::Error for Cancelled {}
pub(crate) struct UploadControl {
pub(crate) struct TransferControl {
pub events: Sender<SftpEvent>,
pub cancel: watch::Receiver<bool>,
pub answers: Receiver<(u64, UploadAnswer)>,
pub answers: Receiver<(u64, TransferAnswer)>,
}
impl UploadControl {
impl TransferControl {
pub fn check(&self) -> Result<()> {
if *self.cancel.borrow() {
Err(Cancelled.into())
@@ -44,14 +45,14 @@ impl UploadControl {
}
pub async fn ask(
&self,
kind: UploadQuestionKind,
kind: TransferQuestionKind,
path: &str,
message: &str,
) -> Result<UploadAnswer> {
) -> Result<TransferAnswer> {
self.check()?;
let id = NEXT_QUESTION.fetch_add(1, Ordering::Relaxed);
self.events
.send(SftpEvent::Question(UploadQuestion::new(
.send(SftpEvent::Question(TransferQuestion::new(
id, kind, path, message,
)))
.await?;
@@ -59,7 +60,7 @@ impl UploadControl {
loop {
let (reply_id, answer) = self.answers.recv().await?;
if reply_id == id {
return if answer.choice() == UploadChoice::Cancel {
return if answer.choice() == TransferChoice::Cancel {
Err(Cancelled.into())
} else {
Ok(answer)
@@ -70,3 +71,29 @@ impl UploadControl {
.await
}
}
/// Keeps two sessions from writing the same target at once, for as long as
/// it is held. `key` names the target: endpoint and remote path for uploads,
/// the local path for downloads.
pub(crate) struct TargetGuard(String);
static ACTIVE_TARGETS: std::sync::Mutex<Option<HashSet<String>>> = std::sync::Mutex::new(None);
impl TargetGuard {
pub fn acquire(key: String, busy: &str) -> Result<Self> {
let mut active = ACTIVE_TARGETS.lock().unwrap_or_else(|e| e.into_inner());
if !active.get_or_insert_with(HashSet::new).insert(key.clone()) {
anyhow::bail!("{busy}");
}
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);
}
}
}
+658
View File
@@ -0,0 +1,658 @@
//! Downloading remote files and trees, the mirror of `upload`: each file is
//! written to `<target>.filepart` next to its target and renamed over it
//! once complete. An interrupted file resumes from the partial file's length,
//! as WinSCP does, as long as the remote file still has the size and time it
//! had when the partial file was started.
use super::{
DownloadRequest, EntryKind, FileMetadata, RemotePath, SftpEvent, TransferChoice,
TransferDirection, TransferPhase, TransferProgress, TransferQuestionKind,
client::RemoteFs,
control::{Cancelled, TargetGuard, TransferControl},
journal::{DownloadJournal, DownloadRecord, partial_path},
};
use anyhow::{Result, anyhow, bail};
use futures::{StreamExt as _, stream::FuturesUnordered};
use std::{
collections::{BTreeMap, HashSet},
path::{Path, PathBuf},
time::{Duration, Instant},
};
use tokio::io::{AsyncSeekExt as _, AsyncWriteExt as _};
const CHUNK: u32 = 32 * 1024;
const MAX_IN_FLIGHT: usize = 16;
#[derive(Clone)]
struct DownloadItem {
source: RemotePath,
target: PathBuf,
metadata: FileMetadata,
error: Option<String>,
}
pub(crate) struct DownloadBatch {
items: Vec<DownloadItem>,
cursor: usize,
pub progress: TransferProgress,
endpoint: String,
host_key: String,
journal: DownloadJournal,
all_conflicts: Option<TransferChoice>,
approved_resumes: HashSet<PathBuf>,
blocked_directories: Vec<PathBuf>,
started: Instant,
received_bytes: u64,
completed_bytes: u64,
last_progress: Instant,
}
/// A name that cannot be created on this machine gets a clear error instead
/// of a path that means something else.
fn local_name(name: &str) -> Result<&str> {
let invalid = if cfg!(windows) {
name.contains(['<', '>', ':', '"', '/', '\\', '|', '?', '*', '\0'])
|| name.ends_with([' ', '.'])
} else {
name.contains(['/', '\0'])
};
if invalid || matches!(name, "" | "." | "..") {
bail!("名称「{name}」在本机无效");
}
Ok(name)
}
fn remote_name(path: &RemotePath) -> &str {
path.as_str()
.trim_end_matches('/')
.rsplit('/')
.next()
.unwrap_or_default()
}
impl DownloadBatch {
pub async fn scan<F: RemoteFs>(
request: &DownloadRequest,
endpoint: &str,
host_key: &str,
journal: DownloadJournal,
fs: &F,
control: &TransferControl,
) -> Result<Self> {
let mut sources = request.sources().to_vec();
sources.sort_by(|a, b| a.as_str().cmp(b.as_str()));
sources.dedup();
// A source inside another source comes with its parent.
let mut roots: Vec<RemotePath> = Vec::new();
for source in sources {
if !roots.iter().any(|parent| {
source
.as_str()
.starts_with(&format!("{}/", parent.as_str().trim_end_matches('/')))
}) {
roots.push(source);
}
}
let mut stack = Vec::new();
for source in roots.into_iter().rev() {
let target =
local_name(remote_name(&source)).map(|name| request.destination().join(name));
stack.push((source, target, None));
}
let mut items = Vec::new();
let mut total_bytes: u64 = 0;
while let Some((source, target, known)) = stack.pop() {
control.check()?;
let target = match target {
Ok(target) => target,
Err(error) => {
items.push(DownloadItem {
target: request.destination().join(remote_name(&source)),
source,
metadata: FileMetadata::new(EntryKind::Other, 0, None, None),
error: Some(error.to_string()),
});
continue;
}
};
let metadata = match known {
Some(metadata) => metadata,
None => {
let found = control.run(fs.metadata(&source)).await;
let error = match found {
Ok(Some(metadata)) => Ok(metadata),
Ok(None) => Err("远程项目不存在".to_string()),
Err(error)
if super::client::is_network_error(&error)
|| error.is::<Cancelled>() =>
{
return Err(error);
}
Err(error) => Err(error.to_string()),
};
match error {
Ok(metadata) => metadata,
Err(error) => {
items.push(DownloadItem {
source,
target,
metadata: FileMetadata::new(EntryKind::Other, 0, None, None),
error: Some(error),
});
continue;
}
}
}
};
let mut scan_error = None;
if metadata.kind() == EntryKind::Directory {
match control.run(fs.read_dir(&source)).await {
Ok(mut children) => {
children.sort_by(|a, b| b.name().cmp(a.name()));
for child in children {
stack.push((
source.join(child.name())?,
local_name(child.name()).map(|name| target.join(name)),
Some(child.metadata().clone()),
));
}
}
Err(error)
if super::client::is_network_error(&error) || error.is::<Cancelled>() =>
{
return Err(error);
}
Err(error) => scan_error = Some(error.to_string()),
}
} else if metadata.kind() == EntryKind::File {
total_bytes = total_bytes.saturating_add(metadata.size());
}
items.push(DownloadItem {
source,
target,
metadata,
error: scan_error,
});
}
let progress = TransferProgress {
direction: TransferDirection::Download,
total: items.len(),
total_bytes,
..TransferProgress::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(),
received_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: &TransferControl) {
let _ = control
.events
.try_send(SftpEvent::Progress(self.progress.clone()));
}
pub fn phase(&mut self, phase: TransferPhase, control: &TransferControl) {
self.progress.phase = phase;
self.emit(control);
}
pub async fn run<F: RemoteFs>(&mut self, fs: &F, control: &TransferControl) -> Result<()> {
self.phase(TransferPhase::Transferring, control);
while self.cursor < self.items.len() {
control.check()?;
let item = self.items[self.cursor].clone();
self.progress.current = item.target.display().to_string();
self.emit(control);
let _target_guard = TargetGuard::acquire(
format!("local\0{}", item.target.display()),
"其他会话正在下载到同一位置,请稍后继续下载",
)?;
let result = if self
.blocked_directories
.iter()
.any(|dir| item.target.starts_with(dir) && item.target != *dir)
{
Err(anyhow!("父目录未创建,无法下载"))
} else if let Some(error) = &item.error {
Err(anyhow!("{error}"))
} else {
self.download_item(fs, &item, control).await
};
match result {
Ok(true) => {
self.progress.succeeded += 1;
self.progress
.details
.push(format!("已下载:{}", item.target.display()));
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.display()));
if item.metadata.kind() == EntryKind::Directory {
self.blocked_directories.push(item.target.clone());
}
}
Err(error)
if error.is::<Cancelled>() || super::client::is_network_error(&error) =>
{
return Err(error);
}
Err(error) => {
self.phase(TransferPhase::Waiting, control);
let answer = control
.ask(
TransferQuestionKind::Error,
item.source.as_str(),
&format!("无法下载:{error:#}"),
)
.await?;
if answer.choice() == TransferChoice::Retry {
if item.error.is_some() {
let request = DownloadRequest::new(
vec![item.source.clone()],
item.target
.parent()
.map(Path::to_path_buf)
.ok_or_else(|| anyhow!("无效的下载目标"))?,
)?;
let scanned = Self::scan(
&request,
&self.endpoint,
&self.host_key,
self.journal.clone(),
fs,
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.display()));
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(TransferPhase::Transferring, control);
}
self.phase(TransferPhase::Completed, control);
Ok(())
}
/// Ask before replacing an existing local item; remembers "apply to all".
/// `changed` means the target appeared or changed while downloading.
async fn approve(
&mut self,
target: &Path,
existing: &std::fs::Metadata,
changed: bool,
control: &TransferControl,
) -> Result<bool> {
if existing.is_dir() {
bail!("本地已有同名目录,不会删除它");
}
if !changed && let Some(choice) = self.all_conflicts {
return Ok(choice == TransferChoice::Overwrite);
}
self.phase(TransferPhase::Waiting, control);
let modified = existing
.modified()
.ok()
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
.map(|time| time.as_secs());
let message = format!(
"{}本地已有同名项目({} 字节,修改时间 {:?})。覆盖会替换它。",
if changed {
"目标在下载期间发生变化。"
} else {
""
},
existing.len(),
modified
);
let answer = control
.ask(
TransferQuestionKind::Conflict,
&target.display().to_string(),
&message,
)
.await?;
if answer.apply_to_all() && !changed {
self.all_conflicts = Some(answer.choice());
}
self.phase(TransferPhase::Transferring, control);
Ok(answer.choice() == TransferChoice::Overwrite)
}
async fn download_item<F: RemoteFs>(
&mut self,
fs: &F,
item: &DownloadItem,
control: &TransferControl,
) -> Result<bool> {
let existing = local_metadata(&item.target).await?;
match item.metadata.kind() {
EntryKind::Directory => match existing {
Some(metadata) if metadata.is_dir() => Ok(true),
Some(_) => bail!("本地已有同名文件,不会替换为目录"),
None => {
tokio::fs::create_dir(&item.target).await?;
Ok(true)
}
},
EntryKind::Symlink => {
let link = control.run(fs.readlink(&item.source)).await?;
if let Some(existing) = &existing
&& !self.approve(&item.target, existing, false, control).await?
{
return Ok(false);
}
create_link(&link, &item.target, existing.is_some()).await?;
Ok(true)
}
EntryKind::Other => bail!("不支持下载设备、套接字或其他特殊文件"),
EntryKind::File => self.download_regular(fs, item, existing, control).await,
}
}
async fn download_regular<F: RemoteFs>(
&mut self,
fs: &F,
item: &DownloadItem,
existing: Option<std::fs::Metadata>,
control: &TransferControl,
) -> Result<bool> {
let temporary = partial_path(&item.target);
let record = self
.journal
.load(&self.endpoint, &item.source, &item.target)
.await?;
let partial = local_metadata(&temporary).await?;
let resumable = record.as_ref().is_some_and(|record| {
record
.validate(&self.endpoint, &self.host_key, &item.source, &item.target)
.is_ok()
&& record.source_metadata == item.metadata
});
// Already answered in this batch: a reconnect retries the same item.
let answered = self.approved_resumes.contains(&item.target);
// A matching record means overwriting was approved when it was made.
if !answered
&& !resumable
&& let Some(existing) = &existing
&& !self.approve(&item.target, existing, false, control).await?
{
return Ok(false);
}
let mut offset = 0;
if let Some(partial) = &partial {
if !partial.is_file() {
bail!("临时文件 {} 不是普通文件", temporary.display());
}
if partial.len() > item.metadata.size() || (record.is_some() && !resumable) {
self.phase(TransferPhase::Waiting, control);
let reason = if partial.len() > item.metadata.size() {
"本地 .filepart 文件比远程文件大,无法续传。"
} else {
"远程文件在上次下载后已变化,无法续传。"
};
let answer = control
.ask(
TransferQuestionKind::InvalidResume,
item.source.as_str(),
reason,
)
.await?;
if answer.choice() != TransferChoice::Restart {
return Ok(false);
}
} else if answered {
offset = partial.len();
} else {
self.phase(TransferPhase::Waiting, control);
let answer = control
.ask(
TransferQuestionKind::Resume,
item.source.as_str(),
"发现未完成的 .filepart 文件。续传会按它的现有大小跳过远程文件前缀,请确认远程文件仍是同一版本。",
)
.await?;
match answer.choice() {
TransferChoice::Skip => return Ok(false),
TransferChoice::Restart => {}
_ => offset = partial.len(),
}
self.phase(TransferPhase::Transferring, control);
}
}
self.approved_resumes.insert(item.target.clone());
let record = DownloadRecord::new(
&self.endpoint,
&self.host_key,
item.source.clone(),
item.metadata.clone(),
item.target.clone(),
);
self.journal.save(&record).await?;
self.download_file(fs, item, &temporary, offset, control)
.await?;
// The target may have appeared or changed while the file downloaded.
let current = local_metadata(&item.target).await?;
if let Some(current) = &current
&& changed(existing.as_ref(), current)
&& !self.approve(&item.target, current, true, control).await?
{
return Ok(false);
}
publish(&temporary, &item.target, current.as_ref(), &item.metadata).await?;
self.journal.remove(&record).await?;
Ok(true)
}
/// Read the remote file from `offset` into the partial file. Reads run
/// in parallel and may complete out of order or short, but the file is
/// written strictly in sequence: resuming trusts its length.
async fn download_file<F: RemoteFs>(
&mut self,
fs: &F,
item: &DownloadItem,
temporary: &Path,
offset: u64,
control: &TransferControl,
) -> Result<()> {
let size = item.metadata.size();
let handle = control.run(fs.open_read(&item.source)).await?;
let result: Result<()> = async {
let mut file = tokio::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(offset == 0)
.open(temporary)
.await?;
file.set_len(offset).await?;
file.seek(std::io::SeekFrom::Start(offset)).await?;
let mut next_request = offset;
let mut next_write = offset;
let mut remainders: Vec<(u64, u32)> = Vec::new();
let mut ready: BTreeMap<u64, Vec<u8>> = BTreeMap::new();
let mut reads = FuturesUnordered::new();
self.phase(TransferPhase::Transferring, control);
while next_write < size {
while reads.len() < MAX_IN_FLIGHT && (!remainders.is_empty() || next_request < size)
{
control.check()?;
let (at, len) = remainders.pop().unwrap_or_else(|| {
let len = (size - next_request).min(u64::from(CHUNK)) as u32;
let request = (next_request, len);
next_request += u64::from(len);
request
});
let handle = handle.as_str();
reads.push(async move {
let bytes = control.run(fs.read(handle, at, len)).await;
(at, len, bytes)
});
}
let (at, len, bytes) = reads
.next()
.await
.ok_or_else(|| anyhow!("下载读取流水线意外结束"))?;
let bytes = match bytes? {
Some(bytes) if !bytes.is_empty() => bytes,
_ => bail!("远程文件在下载期间变短,已停止"),
};
let got = bytes.len().min(len as usize);
if got < len as usize {
remainders.push((at + got as u64, len - got as u32));
}
ready.insert(at, bytes[..got].to_vec());
while let Some(chunk) = ready.remove(&next_write) {
file.write_all(&chunk).await?;
next_write += chunk.len() as u64;
self.received_bytes += chunk.len() as u64;
self.progress.completed_bytes = self.completed_bytes.saturating_add(next_write);
self.progress.bytes_per_second = (self.received_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();
}
}
}
file.flush().await?;
file.sync_all().await?;
Ok(())
}
.await;
// Always close; the partial file's length is the resume point.
let close = fs.close(&handle).await;
result?;
close?;
Ok(())
}
/// Remove the partial files and resume records this batch left behind.
pub async fn discard(&self) -> Result<()> {
for item in &self.items {
if let Some(record) = self
.journal
.load(&self.endpoint, &item.source, &item.target)
.await?
{
record.validate(&self.endpoint, &self.host_key, &item.source, &item.target)?;
match tokio::fs::remove_file(&record.temporary).await {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
self.journal.remove(&record).await?;
}
}
Ok(())
}
}
async fn local_metadata(path: &Path) -> Result<Option<std::fs::Metadata>> {
match tokio::fs::symlink_metadata(path).await {
Ok(metadata) => Ok(Some(metadata)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error.into()),
}
}
/// Whether the local target is no longer what the user approved replacing.
fn changed(before: Option<&std::fs::Metadata>, now: &std::fs::Metadata) -> bool {
match before {
None => true,
Some(before) => {
before.len() != now.len()
|| before.modified().ok() != now.modified().ok()
|| before.file_type() != now.file_type()
}
}
}
/// Give the finished partial file the remote time, keep the replaced file's
/// permissions, and rename it over the target (atomic on one file system).
async fn publish(
temporary: &Path,
target: &Path,
replaced: Option<&std::fs::Metadata>,
metadata: &FileMetadata,
) -> Result<()> {
let file = tokio::fs::OpenOptions::new()
.write(true)
.open(temporary)
.await?
.into_std()
.await;
if let Some(seconds) = metadata.modified() {
file.set_modified(std::time::UNIX_EPOCH + Duration::from_secs(u64::from(seconds)))?;
}
if let Some(replaced) = replaced
&& replaced.is_file()
{
file.set_permissions(replaced.permissions())?;
}
file.sync_all()?;
drop(file);
tokio::fs::rename(temporary, target).await?;
Ok(())
}
#[cfg(unix)]
async fn create_link(link: &str, target: &Path, replace: bool) -> Result<()> {
if replace {
tokio::fs::remove_file(target).await?;
}
tokio::fs::symlink(link, target).await?;
Ok(())
}
#[cfg(not(unix))]
async fn create_link(_: &str, _: &Path, _: bool) -> Result<()> {
bail!("此系统不支持创建符号链接")
}
+148 -28
View File
@@ -126,16 +126,14 @@ impl Journal {
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()))
record_path(
&self.root,
[
endpoint.as_bytes(),
source.as_os_str().as_encoded_bytes(),
target.as_str().as_bytes(),
],
)
}
pub async fn load(
&self,
@@ -152,25 +150,8 @@ impl Journal {
}
}
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(())
write_atomic(&self.root, &path, &serde_json::to_vec(record)?).await
}
pub async fn remove(&self, record: &ResumeRecord) -> Result<()> {
match tokio::fs::remove_file(self.path(&record.endpoint, &record.source, &record.target))
@@ -182,3 +163,142 @@ impl Journal {
}
}
}
/// Write a record so a crash leaves either the old or the new file: write a
/// sibling, fsync it, rename over, then fsync the directory. Private to the
/// user (0600), since records name local and remote paths.
async fn write_atomic(root: &Path, path: &Path, bytes: &[u8]) -> Result<()> {
tokio::fs::create_dir_all(root).await?;
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(bytes).await?;
file.sync_all().await?;
drop(file);
tokio::fs::rename(&temp, path)
.await
.context("无法保存续传进度")?;
#[cfg(unix)]
{
tokio::fs::File::open(root).await?.sync_all().await?;
}
Ok(())
}
fn record_path(root: &Path, parts: [&[u8]; 3]) -> PathBuf {
let mut hash = Sha256::new();
for part in parts {
hash.update((part.len() as u64).to_be_bytes());
hash.update(part);
}
root.join(format!("{:x}.json", hash.finalize()))
}
/// Resume state of one downloaded file. The remote size and modification
/// time are what the partial `.filepart` was read from; a changed remote
/// file cannot be resumed.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct DownloadRecord {
pub version: u32,
pub endpoint: String,
pub host_key: String,
pub source: RemotePath,
pub source_metadata: FileMetadata,
pub target: PathBuf,
pub temporary: PathBuf,
}
impl DownloadRecord {
pub fn new(
endpoint: &str,
host_key: &str,
source: RemotePath,
source_metadata: FileMetadata,
target: PathBuf,
) -> Self {
Self {
version: 1,
endpoint: endpoint.into(),
host_key: host_key.into(),
temporary: partial_path(&target),
source,
source_metadata,
target,
}
}
pub fn validate(
&self,
endpoint: &str,
host_key: &str,
source: &RemotePath,
target: &Path,
) -> Result<()> {
if self.version != 1
|| self.endpoint != endpoint
|| self.host_key != host_key
|| &self.source != source
|| self.target != target
|| self.temporary != partial_path(target)
{
bail!("续传记录与当前来源或服务器不匹配");
}
Ok(())
}
}
/// `<target>.filepart`, next to the target so the final rename stays on one
/// file system.
pub(crate) fn partial_path(target: &Path) -> PathBuf {
let mut name = target.file_name().unwrap_or_default().to_os_string();
name.push(".filepart");
target.with_file_name(name)
}
#[derive(Clone)]
pub(crate) struct DownloadJournal {
root: PathBuf,
}
impl DownloadJournal {
pub fn new(root: PathBuf) -> Self {
Self { root }
}
fn path(&self, endpoint: &str, source: &RemotePath, target: &Path) -> PathBuf {
record_path(
&self.root,
[
endpoint.as_bytes(),
source.as_str().as_bytes(),
target.as_os_str().as_encoded_bytes(),
],
)
}
pub async fn load(
&self,
endpoint: &str,
source: &RemotePath,
target: &Path,
) -> Result<Option<DownloadRecord>> {
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: &DownloadRecord) -> Result<()> {
let path = self.path(&record.endpoint, &record.source, &record.target);
write_atomic(&self.root, &path, &serde_json::to_vec(record)?).await
}
pub async fn remove(&self, record: &DownloadRecord) -> 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()),
}
}
}
+2
View File
@@ -5,7 +5,9 @@ pub use model::*;
pub use transport::*;
mod client;
mod control;
mod download;
mod journal;
mod operations;
mod upload;
mod worker;
pub use worker::SshSftpTransportProvider;
+275 -23
View File
@@ -83,24 +83,51 @@ impl FileMetadata {
self.permissions
}
}
/// One directory row. Owner names and link targets are display data only, so
/// they stay out of `FileMetadata`, which resume records persist and compare.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DirectoryEntry {
name: String,
metadata: FileMetadata,
owner: Option<String>,
group: Option<String>,
target_kind: Option<EntryKind>,
}
impl DirectoryEntry {
pub fn new(name: impl Into<String>, metadata: FileMetadata) -> Self {
Self {
name: name.into(),
metadata,
owner: None,
group: None,
target_kind: None,
}
}
pub fn with_owner(mut self, owner: Option<String>, group: Option<String>) -> Self {
self.owner = owner;
self.group = group;
self
}
/// For a symbolic link: the kind of what it points to, `None` when broken.
pub fn with_target_kind(mut self, kind: Option<EntryKind>) -> Self {
self.target_kind = kind;
self
}
pub fn name(&self) -> &str {
&self.name
}
pub fn metadata(&self) -> &FileMetadata {
&self.metadata
}
pub fn owner(&self) -> Option<&str> {
self.owner.as_deref()
}
pub fn group(&self) -> Option<&str> {
self.group.as_deref()
}
pub fn target_kind(&self) -> Option<EntryKind> {
self.target_kind
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DirectoryListing {
@@ -144,18 +171,61 @@ impl UploadRequest {
&self.destination
}
}
/// Which way one transfer batch moves files.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TransferDirection {
#[default]
Upload,
Download,
}
impl TransferDirection {
/// The verb used in progress and confirmation copy.
pub fn verb(self) -> &'static str {
match self {
Self::Upload => "上传",
Self::Download => "下载",
}
}
}
/// Remote files and directories to copy into one local directory.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DownloadRequest {
sources: Vec<RemotePath>,
destination: PathBuf,
}
impl DownloadRequest {
pub fn new(sources: Vec<RemotePath>, destination: PathBuf) -> Result<Self> {
if sources.is_empty() {
bail!("请选择要下载的文件或目录");
}
if !destination.is_absolute() {
bail!("下载目标必须是绝对路径");
}
Ok(Self {
sources,
destination,
})
}
pub fn sources(&self) -> &[RemotePath] {
&self.sources
}
pub fn destination(&self) -> &Path {
&self.destination
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UploadPhase {
pub enum TransferPhase {
Scanning,
Uploading,
Transferring,
Waiting,
Reconnecting,
Stopped,
Completed,
}
#[derive(Clone, Debug, PartialEq)]
pub struct UploadProgress {
pub(crate) phase: UploadPhase,
pub struct TransferProgress {
pub(crate) direction: TransferDirection,
pub(crate) phase: TransferPhase,
pub(crate) current: String,
pub(crate) completed_bytes: u64,
pub(crate) total_bytes: u64,
@@ -166,10 +236,11 @@ pub struct UploadProgress {
pub(crate) bytes_per_second: u64,
pub(crate) details: Vec<String>,
}
impl Default for UploadProgress {
impl Default for TransferProgress {
fn default() -> Self {
Self {
phase: UploadPhase::Scanning,
direction: TransferDirection::Upload,
phase: TransferPhase::Scanning,
current: String::new(),
completed_bytes: 0,
total_bytes: 0,
@@ -182,14 +253,21 @@ impl Default for UploadProgress {
}
}
}
impl UploadProgress {
pub fn new(phase: UploadPhase) -> Self {
impl TransferProgress {
pub fn new(phase: TransferPhase) -> Self {
Self {
phase,
..Self::default()
}
}
pub fn phase(&self) -> UploadPhase {
pub fn with_direction(mut self, direction: TransferDirection) -> Self {
self.direction = direction;
self
}
pub fn direction(&self) -> TransferDirection {
self.direction
}
pub fn phase(&self) -> TransferPhase {
self.phase
}
pub fn current(&self) -> &str {
@@ -220,27 +298,30 @@ impl UploadProgress {
&self.details
}
pub fn is_active(&self) -> bool {
!matches!(self.phase, UploadPhase::Stopped | UploadPhase::Completed)
!matches!(
self.phase,
TransferPhase::Stopped | TransferPhase::Completed
)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UploadQuestionKind {
pub enum TransferQuestionKind {
Conflict,
Resume,
InvalidResume,
Error,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UploadQuestion {
pub struct TransferQuestion {
id: u64,
kind: UploadQuestionKind,
kind: TransferQuestionKind,
path: String,
message: String,
}
impl UploadQuestion {
impl TransferQuestion {
pub fn new(
id: u64,
kind: UploadQuestionKind,
kind: TransferQuestionKind,
path: impl Into<String>,
message: impl Into<String>,
) -> Self {
@@ -254,7 +335,7 @@ impl UploadQuestion {
pub fn id(&self) -> u64 {
self.id
}
pub fn kind(&self) -> UploadQuestionKind {
pub fn kind(&self) -> TransferQuestionKind {
self.kind
}
pub fn path(&self) -> &str {
@@ -265,7 +346,7 @@ impl UploadQuestion {
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
pub enum UploadChoice {
pub enum TransferChoice {
Overwrite,
Skip,
Cancel,
@@ -274,18 +355,18 @@ pub enum UploadChoice {
Restart,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
pub struct UploadAnswer {
choice: UploadChoice,
pub struct TransferAnswer {
choice: TransferChoice,
apply_to_all: bool,
}
impl UploadAnswer {
pub fn new(choice: UploadChoice, apply_to_all: bool) -> Self {
impl TransferAnswer {
pub fn new(choice: TransferChoice, apply_to_all: bool) -> Self {
Self {
choice,
apply_to_all,
}
}
pub fn choice(&self) -> UploadChoice {
pub fn choice(&self) -> TransferChoice {
self.choice
}
pub fn apply_to_all(&self) -> bool {
@@ -293,10 +374,74 @@ impl UploadAnswer {
}
}
/// One permission change applied to many items, as WinSCP's properties
/// dialog does for a multi-selection: bits in `set` turn on, bits in `clear`
/// turn off, and every other bit (file type, setuid, setgid, sticky, and the
/// rwx bits the user left alone) keeps its value per item.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
pub struct PermissionEdit {
set: u32,
clear: u32,
}
impl PermissionEdit {
pub fn new(set: u32, clear: u32) -> Self {
let set = set & 0o777;
Self {
set,
clear: clear & 0o777 & !set,
}
}
/// Replace all nine rwx bits with `mode`.
pub fn exact(mode: u32) -> Self {
Self::new(mode, !mode)
}
pub fn set(&self) -> u32 {
self.set
}
pub fn clear(&self) -> u32 {
self.clear
}
pub fn is_empty(&self) -> bool {
self.set == 0 && self.clear == 0
}
/// The new mode for an item whose current mode is `mode`. With
/// `add_x_to_dirs`, a directory also becomes searchable by whoever can
/// read it, so a recursive `644` does not lock the tree.
pub fn apply(&self, mode: u32, is_dir: bool, add_x_to_dirs: bool) -> u32 {
let mode = (mode & !self.clear) | self.set;
if is_dir && add_x_to_dirs {
mode | ((mode & 0o444) >> 2)
} else {
mode
}
}
}
/// 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>;
/// Well-known folders for the 目录列表 select, home first. Looked up
/// from the platform, without touching the file system.
fn places(&self) -> Vec<(String, PathBuf)> {
Vec::new()
}
/// Move items to the system trash. Links go as links.
fn trash(&self, paths: &[PathBuf]) -> Result<()>;
/// Rename without replacing an existing item.
fn rename(&self, from: &Path, to: &Path) -> Result<()>;
fn create_dir(&self, path: &Path) -> Result<()>;
/// Create an empty file; fails if the name exists.
fn create_file(&self, path: &Path) -> Result<()>;
/// Apply `edit` to each path (and, when `recursive`, everything under the
/// directories), skipping links.
fn set_permissions(
&self,
paths: &[PathBuf],
edit: PermissionEdit,
recursive: bool,
add_x_to_dirs: bool,
) -> Result<()>;
}
pub type SharedLocalDirectoryProvider = std::sync::Arc<dyn LocalDirectoryProvider>;
#[derive(Default)]
@@ -305,6 +450,17 @@ impl LocalDirectoryProvider for SystemLocalDirectoryProvider {
fn home(&self) -> PathBuf {
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
}
fn places(&self) -> Vec<(String, PathBuf)> {
[
("主目录", dirs::home_dir()),
("桌面", dirs::desktop_dir()),
("文稿", dirs::document_dir()),
("下载", dirs::download_dir()),
]
.into_iter()
.filter_map(|(title, path)| Some((title.to_string(), path?)))
.collect()
}
fn list(&self, path: &Path) -> Result<DirectoryListing> {
let path = std::fs::canonicalize(path)?;
let mut entries = Vec::new();
@@ -315,7 +471,15 @@ impl LocalDirectoryProvider for SystemLocalDirectoryProvider {
.into_string()
.map_err(|_| anyhow::anyhow!("目录包含无法用 UTF-8 表示的文件名"))?;
let metadata = std::fs::symlink_metadata(entry.path())?;
entries.push(DirectoryEntry::new(name, local_metadata(&metadata)));
let mut row = DirectoryEntry::new(name, local_metadata(&metadata));
if metadata.is_symlink() {
row = row.with_target_kind(
std::fs::metadata(entry.path())
.ok()
.map(|target| local_metadata(&target).kind()),
);
}
entries.push(row);
}
Ok(DirectoryListing::new(
path.to_str()
@@ -323,7 +487,95 @@ impl LocalDirectoryProvider for SystemLocalDirectoryProvider {
entries,
))
}
fn trash(&self, paths: &[PathBuf]) -> Result<()> {
#[allow(unused_mut)]
let mut context = trash::TrashContext::default();
// Finder's method asks for Automation permission and plays a sound;
// the file manager call needs neither.
#[cfg(target_os = "macos")]
{
use trash::macos::{DeleteMethod, TrashContextExtMacos as _};
context.set_delete_method(DeleteMethod::NsFileManager);
}
context
.delete_all(paths)
.map_err(|error| anyhow::anyhow!("无法移到废纸篓:{error}"))
}
fn rename(&self, from: &Path, to: &Path) -> Result<()> {
// POSIX rename silently replaces the target.
if std::fs::symlink_metadata(to).is_ok() {
bail!("已有名为「{}」的项目", display_name(to));
}
std::fs::rename(from, to).map_err(|error| describe_io(error, to))
}
fn create_dir(&self, path: &Path) -> Result<()> {
std::fs::create_dir(path).map_err(|error| describe_io(error, path))
}
fn create_file(&self, path: &Path) -> Result<()> {
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.map(drop)
.map_err(|error| describe_io(error, path))
}
#[cfg(unix)]
fn set_permissions(
&self,
paths: &[PathBuf],
edit: PermissionEdit,
recursive: bool,
add_x_to_dirs: bool,
) -> Result<()> {
use std::os::unix::fs::PermissionsExt as _;
let mut stack: Vec<PathBuf> = paths.iter().rev().cloned().collect();
while let Some(path) = stack.pop() {
let metadata =
std::fs::symlink_metadata(&path).map_err(|error| describe_io(error, &path))?;
if metadata.is_symlink() {
continue;
}
let mode = metadata.permissions().mode();
let next = edit.apply(mode, metadata.is_dir(), add_x_to_dirs) & 0o7777;
if next != mode & 0o7777 {
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(next))
.map_err(|error| describe_io(error, &path))?;
}
if recursive && metadata.is_dir() {
let mut children = std::fs::read_dir(&path)
.map_err(|error| describe_io(error, &path))?
.map(|entry| entry.map(|entry| entry.path()))
.collect::<std::io::Result<Vec<_>>>()
.map_err(|error| describe_io(error, &path))?;
children.sort_by(|a, b| b.cmp(a));
stack.extend(children);
}
}
Ok(())
}
#[cfg(not(unix))]
fn set_permissions(&self, _: &[PathBuf], _: PermissionEdit, _: bool, _: bool) -> Result<()> {
bail!("此系统不支持修改权限")
}
}
fn display_name(path: &Path) -> String {
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string())
}
/// A local I/O failure in the interface's language, naming the item.
fn describe_io(error: std::io::Error, path: &Path) -> anyhow::Error {
let name = display_name(path);
match error.kind() {
std::io::ErrorKind::AlreadyExists => anyhow::anyhow!("已有名为「{name}」的项目"),
std::io::ErrorKind::PermissionDenied => anyhow::anyhow!("没有权限操作「{name}」"),
std::io::ErrorKind::NotFound => anyhow::anyhow!("「{name}」不存在"),
_ => anyhow::anyhow!("「{name}」:{error}"),
}
}
pub(crate) fn local_metadata(metadata: &std::fs::Metadata) -> FileMetadata {
let kind = if metadata.is_symlink() {
EntryKind::Symlink
+92
View File
@@ -0,0 +1,92 @@
//! One-shot remote file operations: delete, rename, create, change
//! permissions. Trees are walked with an explicit stack and symbolic links
//! are never followed.
use super::{EntryKind, RemoteOperation, RemotePath, client::RemoteFs};
use anyhow::{Result, bail};
pub(crate) async fn run<F: RemoteFs>(fs: &F, operation: &RemoteOperation) -> Result<()> {
match operation {
RemoteOperation::Delete { paths } => {
for path in paths {
delete_tree(fs, path).await?;
}
Ok(())
}
RemoteOperation::Rename { from, to } => {
if fs.metadata(to).await?.is_some() {
bail!("已有名为「{}」的项目", file_name(to));
}
fs.rename(from, to, false).await
}
RemoteOperation::CreateDirectory { path } => {
if fs.metadata(path).await?.is_some() {
bail!("已有名为「{}」的项目", file_name(path));
}
fs.mkdir(path).await
}
RemoteOperation::CreateFile { path } => {
let handle = fs.open(path, true).await?;
fs.close(&handle).await
}
RemoteOperation::SetPermissions {
paths,
edit,
recursive,
add_x_to_dirs,
} => {
// Parents first, so a change that grants access lets the walk in.
let mut stack: Vec<RemotePath> = paths.iter().rev().cloned().collect();
while let Some(path) = stack.pop() {
let Some(metadata) = fs.metadata(&path).await? else {
continue;
};
let is_dir = metadata.kind() == EntryKind::Directory;
if metadata.kind() == EntryKind::Symlink {
continue;
}
if let Some(mode) = metadata.permissions() {
let next = edit.apply(mode, is_dir, *add_x_to_dirs);
if next != mode {
fs.set_permissions(&path, next & 0o7777).await?;
}
}
if *recursive && is_dir {
let mut children = fs.read_dir(&path).await?;
children.sort_by(|a, b| b.name().cmp(a.name()));
for child in children {
stack.push(path.join(child.name())?);
}
}
}
Ok(())
}
}
}
/// Delete a path and, for a directory, everything under it: children before
/// their directory. A link is removed as a link.
async fn delete_tree<F: RemoteFs>(fs: &F, root: &RemotePath) -> Result<()> {
let mut stack = vec![(root.clone(), false)];
while let Some((path, emptied)) = stack.pop() {
if emptied {
fs.rmdir(&path).await?;
continue;
}
match fs.metadata(&path).await? {
None => {}
Some(metadata) if metadata.kind() == EntryKind::Directory => {
stack.push((path.clone(), true));
for child in fs.read_dir(&path).await? {
stack.push((path.join(child.name())?, false));
}
}
Some(_) => fs.remove(&path).await?,
}
}
Ok(())
}
fn file_name(path: &RemotePath) -> &str {
path.as_str().rsplit('/').next().unwrap_or(path.as_str())
}
+63 -8
View File
@@ -121,7 +121,8 @@ impl server::Handler for Handler {
}
let mut bytes = vec![0; size as usize];
reader.read_exact(&mut bytes).await?;
if bytes.first() == Some(&6) {
// SSH_FXP_WRITE (6) for uploads, SSH_FXP_READ (5) for downloads.
if matches!(bytes.first(), Some(&5) | Some(&6)) {
writes += 1;
if writes == 2
&& interrupts
@@ -220,6 +221,7 @@ fn worker(port: u16, data: &std::path::Path) -> Worker {
Arc::new(InMemorySecretStore::default()),
),
data.join("upload-resume"),
data.join("download-resume"),
);
let session = Session::new(
SessionId(1),
@@ -306,14 +308,14 @@ fn worker_uses_sftp_without_shell_and_reconnects_after_interrupted_write() {
let mut reconnecting = false;
loop {
match next(&worker).await {
SftpEvent::Progress(p) if p.phase() == UploadPhase::Completed => {
SftpEvent::Progress(p) if p.phase() == TransferPhase::Completed => {
assert_eq!(p.succeeded(), 1);
break;
}
SftpEvent::Progress(p) if p.phase() == UploadPhase::Stopped => {
SftpEvent::Progress(p) if p.phase() == TransferPhase::Stopped => {
panic!("stopped unexpectedly: {p:?}")
}
SftpEvent::Progress(p) if p.phase() == UploadPhase::Reconnecting => {
SftpEvent::Progress(p) if p.phase() == TransferPhase::Reconnecting => {
reconnecting = true
}
SftpEvent::Question(q) => panic!("unexpected question: {q:?}"),
@@ -373,8 +375,8 @@ fn reconnect_exhaustion_waits_for_manual_resume_and_resets_budget() {
.unwrap();
loop {
match next(&worker).await {
SftpEvent::Progress(p) if p.phase() == UploadPhase::Stopped => break,
SftpEvent::Progress(p) if p.phase() == UploadPhase::Completed => {
SftpEvent::Progress(p) if p.phase() == TransferPhase::Stopped => break,
SftpEvent::Progress(p) if p.phase() == TransferPhase::Completed => {
panic!("exhaustion should stop")
}
SftpEvent::Question(q) => panic!("unexpected question {q:?}"),
@@ -387,8 +389,8 @@ fn reconnect_exhaustion_waits_for_manual_resume_and_resets_budget() {
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 => {
SftpEvent::Progress(p) if p.phase() == TransferPhase::Completed => break,
SftpEvent::Progress(p) if p.phase() == TransferPhase::Stopped => {
panic!("manual retry failed")
}
SftpEvent::Question(q) => panic!("unexpected question {q:?}"),
@@ -402,3 +404,56 @@ fn reconnect_exhaustion_waits_for_manual_resume_and_resets_budget() {
);
});
}
#[test]
fn worker_downloads_and_resumes_after_an_interrupted_read() {
runtime().block_on(async {
let temp = tempfile::tempdir().unwrap();
let remote = temp.path().join("remote");
std::fs::create_dir(&remote).unwrap();
let data: Vec<u8> = (0..300_007).map(|i| (i % 241) as u8).collect();
std::fs::write(remote.join("文件.bin"), &data).unwrap();
let out = temp.path().join("out");
std::fs::create_dir(&out).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}"),
_ => {}
}
};
worker
.commands
.send(SftpCommand::Download(
DownloadRequest::new(vec![home.join("文件.bin").unwrap()], out.clone()).unwrap(),
))
.await
.unwrap();
let mut reconnecting = false;
loop {
match next(&worker).await {
SftpEvent::Progress(p) if p.phase() == TransferPhase::Completed => {
assert_eq!(p.succeeded(), 1);
assert_eq!(p.direction(), TransferDirection::Download);
break;
}
SftpEvent::Progress(p) if p.phase() == TransferPhase::Stopped => {
panic!("stopped unexpectedly: {p:?}")
}
SftpEvent::Progress(p) if p.phase() == TransferPhase::Reconnecting => {
reconnecting = true
}
SftpEvent::Question(q) => panic!("unexpected question: {q:?}"),
_ => {}
}
}
assert!(reconnecting);
assert_eq!(server.connections.load(Ordering::SeqCst), 2);
assert_eq!(std::fs::read(out.join("文件.bin")).unwrap(), data);
assert!(!out.join("文件.bin.filepart").exists());
});
}
+700 -32
View File
@@ -1,7 +1,8 @@
use super::{
client::RemoteFs,
control::UploadControl,
journal::{Journal, SourceMetadata},
control::TransferControl,
download::DownloadBatch,
journal::{DownloadJournal, Journal, SourceMetadata, partial_path},
upload::UploadBatch,
*,
};
@@ -23,6 +24,7 @@ struct Node {
#[derive(Clone, Copy, PartialEq)]
enum Fault {
Write(usize),
Read(usize),
Rename(usize),
Close,
RejectRename(usize),
@@ -35,6 +37,8 @@ struct Remote {
in_flight: Cell<usize>,
max_in_flight: Cell<usize>,
writes: Cell<usize>,
reads: Cell<usize>,
short_reads: Cell<bool>,
renames: Cell<usize>,
atomic: bool,
denied: RefCell<Option<String>>,
@@ -47,6 +51,8 @@ impl Remote {
in_flight: Cell::new(0),
max_in_flight: Cell::new(0),
writes: Cell::new(0),
reads: Cell::new(0),
short_reads: Cell::new(false),
renames: Cell::new(0),
atomic,
denied: RefCell::new(None),
@@ -96,6 +102,40 @@ impl RemoteFs for Remote {
.get(path.as_str())
.map(|n| n.metadata.clone()))
}
async fn stat(&self, path: &RemotePath) -> Result<Option<FileMetadata>> {
let nodes = self.nodes.borrow();
let mut current = path.to_string();
for _ in 0..8 {
let Some(node) = nodes.get(&current) else {
return Ok(None);
};
if node.metadata.kind() != EntryKind::Symlink {
return Ok(Some(node.metadata.clone()));
}
current = if node.link.starts_with('/') {
node.link.clone()
} else {
RemotePath::new(current.as_str())?
.parent()
.join(&node.link)?
.to_string()
};
}
bail!("too many levels of symbolic links")
}
async fn read_dir(&self, path: &RemotePath) -> Result<Vec<DirectoryEntry>> {
let prefix = format!("{}/", path.as_str().trim_end_matches('/'));
Ok(self
.nodes
.borrow()
.iter()
.filter_map(|(key, node)| {
let name = key.strip_prefix(&prefix)?;
(!name.is_empty() && !name.contains('/'))
.then(|| DirectoryEntry::new(name, node.metadata.clone()))
})
.collect())
}
async fn open(&self, path: &RemotePath, create: bool) -> Result<String> {
if create {
if self.nodes.borrow().contains_key(path.as_str()) {
@@ -107,6 +147,37 @@ impl RemoteFs for Remote {
}
Ok(path.to_string())
}
async fn open_read(&self, path: &RemotePath) -> Result<String> {
match self.nodes.borrow().get(path.as_str()) {
Some(node) if node.metadata.kind() == EntryKind::File => Ok(path.to_string()),
_ => bail!("no such file"),
}
}
async fn read(&self, handle: &str, offset: u64, len: u32) -> Result<Option<Vec<u8>>> {
let reads = self.reads.get() + 1;
self.reads.set(reads);
tokio::task::yield_now().await;
// Replies arrive out of order: odd reads yield once more.
if reads % 2 == 1 {
tokio::task::yield_now().await;
}
let data = self
.nodes
.borrow()
.get(handle)
.map(|node| node.data.clone())
.ok_or_else(|| anyhow::anyhow!("file absent"))?;
self.fail(Fault::Read(reads))?;
let start = offset as usize;
if start >= data.len() {
return Ok(None);
}
let mut len = len as usize;
if self.short_reads.get() {
len = len.min(1000);
}
Ok(Some(data[start..(start + len).min(data.len())].to_vec()))
}
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);
@@ -206,19 +277,72 @@ impl RemoteFs for Remote {
self.nodes.borrow_mut().remove(path.as_str());
Ok(())
}
async fn rmdir(&self, path: &RemotePath) -> Result<()> {
let prefix = format!("{}/", path.as_str());
let mut nodes = self.nodes.borrow_mut();
if nodes.keys().any(|key| key.starts_with(&prefix)) {
bail!("directory not empty");
}
match nodes.get(path.as_str()) {
Some(node) if node.metadata.kind() == EntryKind::Directory => {
nodes.remove(path.as_str());
Ok(())
}
_ => bail!("not a directory"),
}
}
async fn set_permissions(&self, path: &RemotePath, permissions: u32) -> Result<()> {
let mut nodes = self.nodes.borrow_mut();
let node = nodes
.get_mut(path.as_str())
.ok_or_else(|| anyhow::anyhow!("no such file"))?;
let m = &node.metadata;
node.metadata = FileMetadata::new(
m.kind(),
m.size(),
m.modified(),
Some((m.permissions().unwrap_or(0) & !0o7777) | permissions),
);
Ok(())
}
fn atomic_replace(&self) -> bool {
self.atomic
}
}
impl Remote {
fn dir(&self, path: &str, mode: u32) {
self.nodes.borrow_mut().insert(
path.into(),
Node {
metadata: FileMetadata::new(EntryKind::Directory, 0, None, Some(0o040_000 | mode)),
data: vec![],
link: String::new(),
},
);
}
fn link(&self, path: &str, target: &str) {
self.nodes.borrow_mut().insert(
path.into(),
Node {
metadata: FileMetadata::new(EntryKind::Symlink, 0, None, Some(0o120_777)),
data: vec![],
link: target.into(),
},
);
}
fn mode(&self, path: &str) -> u32 {
self.nodes.borrow()[path].metadata.permissions().unwrap() & 0o7777
}
}
struct Answers {
control: UploadControl,
control: TransferControl,
cancel: watch::Sender<bool>,
questions: Arc<Mutex<Vec<UploadQuestionKind>>>,
progress: Arc<Mutex<Vec<UploadProgress>>>,
questions: Arc<Mutex<Vec<TransferQuestionKind>>>,
progress: Arc<Mutex<Vec<TransferProgress>>>,
task: tokio::task::JoinHandle<()>,
}
impl Answers {
fn new(choices: Vec<UploadChoice>) -> Self {
fn new(choices: Vec<TransferChoice>) -> Self {
let (events, receiver) = async_channel::unbounded();
let (answers, rx) = async_channel::unbounded();
let (cancel, watch) = watch::channel(false);
@@ -235,8 +359,8 @@ impl Answers {
let _ = answers
.send((
q.id(),
UploadAnswer::new(
choices.next().unwrap_or(UploadChoice::Cancel),
TransferAnswer::new(
choices.next().unwrap_or(TransferChoice::Cancel),
false,
),
))
@@ -250,7 +374,7 @@ impl Answers {
}
});
Self {
control: UploadControl {
control: TransferControl {
events,
cancel: watch,
answers: rx,
@@ -273,7 +397,7 @@ fn runtime() -> tokio::runtime::Runtime {
.build()
.unwrap()
}
async fn batch(source: &Path, journal: Journal, control: &UploadControl) -> UploadBatch {
async fn batch(source: &Path, journal: Journal, control: &TransferControl) -> UploadBatch {
let request =
UploadRequest::new(vec![source.into()], RemotePath::new("/dest").unwrap()).unwrap();
UploadBatch::scan(
@@ -289,7 +413,7 @@ async fn batch(source: &Path, journal: Journal, control: &UploadControl) -> Uplo
async fn run(
batch: &mut UploadBatch,
remote: &impl RemoteFs,
control: &UploadControl,
control: &TransferControl,
) -> Result<()> {
tokio::time::timeout(
std::time::Duration::from_secs(10),
@@ -380,7 +504,7 @@ fn winscp_style_upload_finishes_without_a_verification_phase() {
let progress = answers.progress.lock().unwrap();
assert!(progress.iter().all(|snapshot| matches!(
snapshot.phase(),
UploadPhase::Uploading | UploadPhase::Completed
TransferPhase::Transferring | TransferPhase::Completed
)));
});
}
@@ -396,7 +520,7 @@ fn resume_uses_remote_filepart_size_and_preserves_old_target() {
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 answers = Answers::new(vec![TransferChoice::Overwrite]);
let mut first = batch(&source, journal.clone(), &answers.control).await;
assert!(super::client::is_network_error(
&run(&mut first, &remote, &answers.control)
@@ -412,7 +536,7 @@ fn resume_uses_remote_filepart_size_and_preserves_old_target() {
.unwrap()
.unwrap();
remote.file(record.temporary.as_str(), &data[..65_536]);
let answers = Answers::new(vec![UploadChoice::Resume]);
let answers = Answers::new(vec![TransferChoice::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);
@@ -450,15 +574,15 @@ fn filepart_larger_than_source_requires_explicit_restart() {
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 answers = Answers::new(vec![TransferChoice::Resume, TransferChoice::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
TransferQuestionKind::Resume,
TransferQuestionKind::InvalidResume
]
);
assert_eq!(next.progress.skipped(), 1);
@@ -487,7 +611,7 @@ fn winscp_style_resume_does_not_prevalidate_the_source_version() {
.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 answers = Answers::new(vec![TransferChoice::Resume]);
let mut resumed = batch(&source, journal, &answers.control).await;
run(&mut resumed, &remote, &answers.control).await.unwrap();
@@ -497,7 +621,7 @@ fn winscp_style_resume_does_not_prevalidate_the_source_version() {
assert_eq!(remote.bytes("/dest/file"), expected);
assert_eq!(
*answers.questions.lock().unwrap(),
vec![UploadQuestionKind::Resume]
vec![TransferQuestionKind::Resume]
);
assert_eq!(resumed.progress.succeeded(), 1);
});
@@ -512,7 +636,7 @@ fn winscp_style_resume_detects_filepart_without_a_local_record() {
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 answers = Answers::new(vec![TransferChoice::Resume]);
let mut upload = batch(
&source,
Journal::new(tmp.path().join("journal")),
@@ -525,7 +649,7 @@ fn winscp_style_resume_detects_filepart_without_a_local_record() {
assert_eq!(remote.bytes("/dest/file"), data);
assert_eq!(
*answers.questions.lock().unwrap(),
vec![UploadQuestionKind::Resume]
vec![TransferQuestionKind::Resume]
);
assert_eq!(upload.progress.succeeded(), 1);
});
@@ -547,10 +671,10 @@ fn lost_replies_at_backup_publish_and_close_are_reconciled() {
let remote = Remote::new(atomic);
remote.file("/dest/file", b"original");
remote.fault.set(Some(fault));
let answers = Answers::new(vec![UploadChoice::Overwrite]);
let answers = Answers::new(vec![TransferChoice::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 answers = Answers::new(vec![TransferChoice::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");
@@ -568,13 +692,13 @@ fn cancellation_retains_journal_and_skip_does_not_overwrite() {
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 answers = Answers::new(vec![TransferChoice::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 answers = Answers::new(vec![TransferChoice::Overwrite]);
let mut canceled = batch(&source, journal, &answers.control).await;
answers.cancel.send_replace(true);
assert!(
@@ -595,7 +719,7 @@ fn directory_permission_error_is_reported_as_failure() {
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 answers = Answers::new(vec![TransferChoice::Skip]);
let mut batch = batch(
&source,
Journal::new(tmp.path().join("journal")),
@@ -724,7 +848,7 @@ fn openssh_protocol_uploads_offsets_links_metadata_and_replaces_readonly_targets
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 answers = Answers::new(vec![TransferChoice::Overwrite]);
let mut upload = UploadBatch::scan(
&request,
"local-protocol",
@@ -768,7 +892,7 @@ fn failed_compatibility_publish_restores_old_target_and_reports_failure() {
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 answers = Answers::new(vec![TransferChoice::Overwrite, TransferChoice::Skip]);
let mut upload = batch(
&source,
Journal::new(temp.path().join("journal")),
@@ -799,7 +923,7 @@ fn target_changed_during_upload_requires_another_conflict_answer() {
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 answers = Answers::new(vec![TransferChoice::Overwrite, TransferChoice::Skip]);
let mut upload = batch(
&source,
Journal::new(temp.path().join("journal")),
@@ -811,7 +935,10 @@ fn target_changed_during_upload_requires_another_conflict_answer() {
assert_eq!(upload.progress.skipped(), 1);
assert_eq!(
*answers.questions.lock().unwrap(),
vec![UploadQuestionKind::Conflict, UploadQuestionKind::Conflict]
vec![
TransferQuestionKind::Conflict,
TransferQuestionKind::Conflict
]
);
assert_eq!(remote.nodes.borrow().len(), 1);
});
@@ -839,7 +966,7 @@ fn target_removed_during_upload_still_requires_confirmation() {
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 answers = Answers::new(vec![TransferChoice::Overwrite, TransferChoice::Skip]);
let mut upload = batch(
&source,
Journal::new(temp.path().join("journal")),
@@ -850,8 +977,549 @@ fn target_removed_during_upload_still_requires_confirmation() {
assert!(remote.nodes.borrow().is_empty());
assert_eq!(
*answers.questions.lock().unwrap(),
vec![UploadQuestionKind::Conflict, UploadQuestionKind::Conflict]
vec![
TransferQuestionKind::Conflict,
TransferQuestionKind::Conflict
]
);
assert_eq!(upload.progress.skipped(), 1);
});
}
fn remote_path(path: &str) -> RemotePath {
RemotePath::new(path).unwrap()
}
#[test]
fn deleting_a_tree_removes_links_but_never_what_they_point_to() {
runtime().block_on(async {
let remote = Remote::new(true);
remote.dir("/keep", 0o755);
remote.file("/keep/important", b"data");
remote.dir("/gone", 0o755);
remote.dir("/gone/nested", 0o755);
remote.file("/gone/nested/file", b"x");
remote.link("/gone/link-to-keep", "/keep");
remote.file("/lone", b"y");
super::operations::run(
&remote,
&RemoteOperation::Delete {
paths: vec![
remote_path("/gone"),
remote_path("/lone"),
remote_path("/missing"),
],
},
)
.await
.unwrap();
let keys: Vec<_> = remote.nodes.borrow().keys().cloned().collect();
assert_eq!(keys, ["/keep", "/keep/important"]);
});
}
#[test]
fn recursive_permissions_skip_links_and_can_keep_directories_searchable() {
runtime().block_on(async {
let remote = Remote::new(true);
remote.dir("/site", 0o700);
remote.dir("/site/css", 0o700);
remote.file("/site/css/a.css", b"a");
remote.file("/site/index.html", b"i");
remote.link("/site/outside", "/etc");
remote.dir("/etc", 0o755);
let chmod = |recursive, add_x_to_dirs| RemoteOperation::SetPermissions {
paths: vec![remote_path("/site")],
edit: PermissionEdit::exact(0o644),
recursive,
add_x_to_dirs,
};
super::operations::run(&remote, &chmod(true, true))
.await
.unwrap();
assert_eq!(remote.mode("/site"), 0o755);
assert_eq!(remote.mode("/site/css"), 0o755);
assert_eq!(remote.mode("/site/css/a.css"), 0o644);
assert_eq!(remote.mode("/site/index.html"), 0o644);
assert_eq!(remote.mode("/site/outside"), 0o777, "links are skipped");
assert_eq!(remote.mode("/etc"), 0o755, "and never followed");
super::operations::run(&remote, &chmod(false, false))
.await
.unwrap();
assert_eq!(remote.mode("/site"), 0o644);
assert_eq!(remote.mode("/site/css"), 0o755, "not recursive this time");
});
}
#[test]
fn rename_and_create_refuse_existing_names() {
runtime().block_on(async {
let remote = Remote::new(true);
remote.file("/a", b"a");
remote.file("/b", b"b");
let error = super::operations::run(
&remote,
&RemoteOperation::Rename {
from: remote_path("/a"),
to: remote_path("/b"),
},
)
.await
.unwrap_err();
assert_eq!(error.to_string(), "已有名为「b」的项目");
assert_eq!(remote.bytes("/b"), b"b");
super::operations::run(
&remote,
&RemoteOperation::Rename {
from: remote_path("/a"),
to: remote_path("/c"),
},
)
.await
.unwrap();
assert_eq!(remote.bytes("/c"), b"a");
for operation in [
RemoteOperation::CreateDirectory {
path: remote_path("/c"),
},
RemoteOperation::CreateFile {
path: remote_path("/c"),
},
] {
assert!(super::operations::run(&remote, &operation).await.is_err());
}
super::operations::run(
&remote,
&RemoteOperation::CreateDirectory {
path: remote_path("/new"),
},
)
.await
.unwrap();
super::operations::run(
&remote,
&RemoteOperation::CreateFile {
path: remote_path("/new/empty.txt"),
},
)
.await
.unwrap();
assert_eq!(remote.bytes("/new/empty.txt"), b"");
});
}
async fn download_batch(
sources: &[&str],
destination: &Path,
journal: DownloadJournal,
remote: &Remote,
control: &TransferControl,
) -> DownloadBatch {
let request = DownloadRequest::new(
sources.iter().map(|source| remote_path(source)).collect(),
destination.to_path_buf(),
)
.unwrap();
DownloadBatch::scan(
&request,
"test@remote",
"host-key",
journal,
remote,
control,
)
.await
.unwrap()
}
async fn run_download(
batch: &mut DownloadBatch,
remote: &Remote,
control: &TransferControl,
) -> Result<()> {
tokio::time::timeout(
std::time::Duration::from_secs(10),
batch.run(remote, control),
)
.await
.expect("download did not finish")
}
#[test]
fn download_copies_trees_links_and_empty_directories_in_order() {
runtime().block_on(async {
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let data: Vec<u8> = (0..2 * 1024 * 1024 + 13).map(|i| (i % 251) as u8).collect();
for short_reads in [false, true] {
let out = tmp.path().join(format!("out-{short_reads}"));
std::fs::create_dir(&out).unwrap();
let remote = Remote::new(true);
remote.short_reads.set(short_reads);
remote.dir("/srv/中文 目录", 0o755);
remote.file("/srv/中文 目录/large.bin", &data);
remote.file("/srv/中文 目录/零 字节", &[]);
remote.dir("/srv/中文 目录/空", 0o755);
remote.dir("/srv/中文 目录/嵌套", 0o755);
remote.file("/srv/中文 目录/嵌套/b.txt", b"nested");
remote.link("/srv/中文 目录/链接", "large.bin");
let answers = Answers::new(vec![]);
let journal = DownloadJournal::new(tmp.path().join(format!("journal-{short_reads}")));
let mut batch = download_batch(
&["/srv/中文 目录", "/srv/中文 目录/嵌套"],
&out,
journal,
&remote,
&answers.control,
)
.await;
run_download(&mut batch, &remote, &answers.control)
.await
.unwrap();
let root = out.join("中文 目录");
assert_eq!(std::fs::read(root.join("large.bin")).unwrap(), data);
assert_eq!(std::fs::read(root.join("零 字节")).unwrap(), b"");
assert_eq!(std::fs::read(root.join("嵌套/b.txt")).unwrap(), b"nested");
assert!(root.join("空").is_dir());
#[cfg(unix)]
assert_eq!(
std::fs::read_link(root.join("链接")).unwrap(),
Path::new("large.bin")
);
assert!(!partial_path(&root.join("large.bin")).exists());
assert_eq!(batch.progress.failed(), 0);
assert_eq!(batch.progress.direction(), TransferDirection::Download);
assert!(answers.questions.lock().unwrap().is_empty());
let modified = std::fs::metadata(root.join("large.bin"))
.unwrap()
.modified()
.unwrap();
assert_eq!(
modified,
std::time::UNIX_EPOCH + std::time::Duration::from_secs(100)
);
}
});
}
#[test]
fn download_resumes_from_the_partial_file_after_a_lost_connection() {
runtime().block_on(async {
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let data: Vec<u8> = (0..300_000).map(|i| (i % 253) as u8).collect();
let remote = Remote::new(true);
remote.file("/big", &data);
remote.fault.set(Some(Fault::Read(5)));
let journal = DownloadJournal::new(tmp.path().join("journal"));
let answers = Answers::new(vec![]);
let mut first = download_batch(
&["/big"],
tmp.path(),
journal.clone(),
&remote,
&answers.control,
)
.await;
let error = run_download(&mut first, &remote, &answers.control)
.await
.unwrap_err();
assert!(super::client::is_network_error(&error));
let partial = partial_path(&tmp.path().join("big"));
let kept = std::fs::read(&partial).unwrap();
assert!(!kept.is_empty() && kept.len() < data.len());
assert_eq!(kept, data[..kept.len()], "written strictly in order");
assert!(!tmp.path().join("big").exists());
let answers = Answers::new(vec![TransferChoice::Resume]);
let mut second =
download_batch(&["/big"], tmp.path(), journal, &remote, &answers.control).await;
run_download(&mut second, &remote, &answers.control)
.await
.unwrap();
assert_eq!(std::fs::read(tmp.path().join("big")).unwrap(), data);
assert!(!partial.exists());
assert_eq!(
*answers.questions.lock().unwrap(),
vec![TransferQuestionKind::Resume]
);
assert!(
std::fs::read_dir(tmp.path().join("journal"))
.unwrap()
.next()
.is_none()
);
});
}
#[test]
fn download_asks_before_overwriting_and_keeps_the_replaced_permissions() {
runtime().block_on(async {
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let target = tmp.path().join("file");
std::fs::write(&target, b"old").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap();
}
let remote = Remote::new(true);
remote.file("/file", b"new contents");
let journal = DownloadJournal::new(tmp.path().join("journal"));
let answers = Answers::new(vec![TransferChoice::Skip]);
let mut skipped = download_batch(
&["/file"],
tmp.path(),
journal.clone(),
&remote,
&answers.control,
)
.await;
run_download(&mut skipped, &remote, &answers.control)
.await
.unwrap();
assert_eq!(std::fs::read(&target).unwrap(), b"old");
assert_eq!(skipped.progress.skipped(), 1);
let answers = Answers::new(vec![TransferChoice::Overwrite]);
let mut replaced =
download_batch(&["/file"], tmp.path(), journal, &remote, &answers.control).await;
run_download(&mut replaced, &remote, &answers.control)
.await
.unwrap();
assert_eq!(std::fs::read(&target).unwrap(), b"new contents");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
assert_eq!(
std::fs::metadata(&target).unwrap().permissions().mode() & 0o777,
0o600
);
}
assert_eq!(
*answers.questions.lock().unwrap(),
vec![TransferQuestionKind::Conflict]
);
});
}
#[test]
fn a_changed_remote_file_is_not_resumed_and_discard_cleans_up() {
runtime().block_on(async {
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let remote = Remote::new(true);
remote.file("/file", &vec![7; 200_000]);
remote.fault.set(Some(Fault::Read(4)));
let journal = DownloadJournal::new(tmp.path().join("journal"));
let answers = Answers::new(vec![]);
let mut first = download_batch(
&["/file"],
tmp.path(),
journal.clone(),
&remote,
&answers.control,
)
.await;
assert!(
run_download(&mut first, &remote, &answers.control)
.await
.is_err()
);
let partial = partial_path(&tmp.path().join("file"));
assert!(partial.exists());
// Discarding a stopped batch removes the partial file and its record.
first.discard().await.unwrap();
assert!(!partial.exists());
assert!(
std::fs::read_dir(tmp.path().join("journal"))
.unwrap()
.next()
.is_none()
);
// A partial file from a remote file that has since changed.
remote.reads.set(0);
remote.fault.set(Some(Fault::Read(4)));
let answers = Answers::new(vec![]);
let mut again = download_batch(
&["/file"],
tmp.path(),
journal.clone(),
&remote,
&answers.control,
)
.await;
assert!(
run_download(&mut again, &remote, &answers.control)
.await
.is_err()
);
remote.file("/file", b"rewritten");
let answers = Answers::new(vec![TransferChoice::Restart]);
let mut restarted =
download_batch(&["/file"], tmp.path(), journal, &remote, &answers.control).await;
run_download(&mut restarted, &remote, &answers.control)
.await
.unwrap();
assert_eq!(
std::fs::read(tmp.path().join("file")).unwrap(),
b"rewritten"
);
assert_eq!(
*answers.questions.lock().unwrap(),
vec![TransferQuestionKind::InvalidResume]
);
});
}
#[test]
fn download_cancellation_keeps_the_partial_file() {
runtime().block_on(async {
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let remote = Remote::new(true);
remote.file("/file", &vec![1; 100_000]);
let answers = Answers::new(vec![]);
let mut batch = download_batch(
&["/file"],
tmp.path(),
DownloadJournal::new(tmp.path().join("journal")),
&remote,
&answers.control,
)
.await;
answers.cancel.send_replace(true);
let error = run_download(&mut batch, &remote, &answers.control)
.await
.unwrap_err();
assert!(error.is::<super::control::Cancelled>());
assert!(!tmp.path().join("file").exists());
});
}
#[cfg(unix)]
#[test]
fn openssh_protocol_downloads_lists_owners_and_changes_files_without_following_links() {
runtime().block_on(async {
use std::os::unix::fs::PermissionsExt as _;
let temp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let served = temp.path().join("remote");
let tree = served.join("树");
std::fs::create_dir_all(tree.join("空目录")).unwrap();
let data: Vec<u8> = (0..1024 * 1024 + 7).map(|i| (i % 249) as u8).collect();
std::fs::write(tree.join("文件.bin"), &data).unwrap();
std::os::unix::fs::symlink("文件.bin", tree.join("链接")).unwrap();
std::fs::create_dir(served.join("keep")).unwrap();
std::fs::write(served.join("keep/important"), b"keep").unwrap();
std::os::unix::fs::symlink("../keep", tree.join("外部")).unwrap();
let (client, mut process) = super::client::SftpClient::local_test_server(&served)
.await
.unwrap();
let root = client
.canonicalize(&RemotePath::new(".").unwrap())
.await
.unwrap();
// Listing: owners come from the server's long names, links resolve.
let listing = client.list(&root.join("树").unwrap()).await.unwrap();
let user = std::env::var("USER").unwrap_or_default();
let entry = |name: &str| {
listing
.entries()
.iter()
.find(|entry| entry.name() == name)
.unwrap()
.clone()
};
if !user.is_empty() {
assert_eq!(entry("文件.bin").owner(), Some(user.as_str()));
}
assert_eq!(entry("外部").target_kind(), Some(EntryKind::Directory));
assert_eq!(entry("链接").target_kind(), Some(EntryKind::File));
// Download the tree.
let out = temp.path().join("out");
std::fs::create_dir(&out).unwrap();
let answers = Answers::new(vec![]);
let request = DownloadRequest::new(vec![root.join("树").unwrap()], out.clone()).unwrap();
let mut download = DownloadBatch::scan(
&request,
"local-protocol",
client.fingerprint(),
DownloadJournal::new(temp.path().join("journal")),
&client,
&answers.control,
)
.await
.unwrap();
tokio::time::timeout(
std::time::Duration::from_secs(20),
download.run(&client, &answers.control),
)
.await
.unwrap()
.unwrap();
assert_eq!(std::fs::read(out.join("树/文件.bin")).unwrap(), data);
assert!(out.join("树/空目录").is_dir());
assert_eq!(
std::fs::read_link(out.join("树/链接")).unwrap(),
Path::new("文件.bin")
);
// Operations on the real server.
let path =
|relative: &str| RemotePath::new(format!("{}/{relative}", root.as_str())).unwrap();
let mode = |p: &Path| std::fs::symlink_metadata(p).unwrap().permissions().mode() & 0o777;
let keep_mode = mode(&served.join("keep"));
super::operations::run(
&client,
&RemoteOperation::SetPermissions {
paths: vec![path("树")],
edit: PermissionEdit::exact(0o640),
recursive: true,
add_x_to_dirs: true,
},
)
.await
.unwrap();
assert_eq!(mode(&tree.join("文件.bin")), 0o640);
assert_eq!(mode(&tree.join("空目录")), 0o750);
assert_eq!(
mode(&served.join("keep")),
keep_mode,
"a linked directory is not followed"
);
super::operations::run(
&client,
&RemoteOperation::Rename {
from: path("树/文件.bin"),
to: path("树/改名.bin"),
},
)
.await
.unwrap();
assert!(tree.join("改名.bin").exists());
super::operations::run(
&client,
&RemoteOperation::CreateFile {
path: path("树/空目录/新文件.txt"),
},
)
.await
.unwrap();
assert_eq!(std::fs::read(tree.join("空目录/新文件.txt")).unwrap(), b"");
super::operations::run(
&client,
&RemoteOperation::Delete {
paths: vec![path("树")],
},
)
.await
.unwrap();
assert!(!tree.exists());
assert_eq!(
std::fs::read(served.join("keep/important")).unwrap(),
b"keep"
);
drop(client);
process.kill().await.ok();
process.wait().await.unwrap();
});
}
+44 -4
View File
@@ -1,5 +1,6 @@
use super::{
DirectoryListing, RemotePath, UploadAnswer, UploadProgress, UploadQuestion, UploadRequest,
DirectoryListing, DownloadRequest, PermissionEdit, RemotePath, TransferAnswer,
TransferProgress, TransferQuestion, UploadRequest,
};
use crate::{
connection::{ConnectionPrompt, ConnectionPromptReply},
@@ -9,20 +10,55 @@ use anyhow::Result;
use async_channel::{Receiver, Sender};
use std::sync::Arc;
/// A one-shot change to remote files. None of them follows symbolic links:
/// deleting a link removes the link, and permission changes skip links.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RemoteOperation {
/// Delete files, links, and directories with everything inside them.
Delete {
paths: Vec<RemotePath>,
},
/// Rename without replacing an existing item.
Rename {
from: RemotePath,
to: RemotePath,
},
CreateDirectory {
path: RemotePath,
},
/// Create an empty file; fails if the name exists.
CreateFile {
path: RemotePath,
},
SetPermissions {
paths: Vec<RemotePath>,
edit: PermissionEdit,
recursive: bool,
add_x_to_dirs: bool,
},
}
#[derive(Debug)]
pub enum SftpCommand {
List {
request_id: u64,
path: RemotePath,
},
/// Runs alongside listings and transfers; answered by `Operated`.
Operate {
request_id: u64,
operation: RemoteOperation,
},
Upload(UploadRequest),
/// A download batch; like an upload, one batch runs at a time.
Download(DownloadRequest),
PromptReply {
request_id: u64,
reply: ConnectionPromptReply,
},
Answer {
request_id: u64,
answer: UploadAnswer,
answer: TransferAnswer,
},
Cancel,
Resume,
@@ -42,8 +78,12 @@ pub enum SftpEvent {
request_id: u64,
result: Result<DirectoryListing, String>,
},
Progress(UploadProgress),
Question(UploadQuestion),
Operated {
request_id: u64,
result: Result<(), String>,
},
Progress(TransferProgress),
Question(TransferQuestion),
Notice(String),
Idle,
}
+47 -69
View File
@@ -1,8 +1,8 @@
use super::{
EntryKind, FileMetadata, RemotePath, SftpEvent, UploadChoice, UploadPhase, UploadProgress,
UploadQuestionKind, UploadRequest,
EntryKind, FileMetadata, RemotePath, SftpEvent, TransferChoice, TransferPhase,
TransferProgress, TransferQuestionKind, UploadRequest,
client::RemoteFs,
control::UploadControl,
control::{TargetGuard, TransferControl},
journal::{Journal, PublishPhase, ResumeRecord, SourceMetadata},
model::local_metadata,
};
@@ -27,11 +27,11 @@ struct UploadItem {
pub(crate) struct UploadBatch {
items: Vec<UploadItem>,
cursor: usize,
pub progress: UploadProgress,
pub progress: TransferProgress,
endpoint: String,
host_key: String,
journal: Journal,
all_conflicts: Option<UploadChoice>,
all_conflicts: Option<TransferChoice>,
approved_resumes: HashSet<RemotePath>,
blocked_directories: Vec<RemotePath>,
started: Instant,
@@ -45,7 +45,7 @@ impl UploadBatch {
endpoint: &str,
host_key: &str,
journal: Journal,
control: &UploadControl,
control: &TransferControl,
) -> Result<Self> {
let mut sources = Vec::new();
for source in request.sources() {
@@ -125,10 +125,10 @@ impl UploadBatch {
error: scan_error,
});
}
let progress = UploadProgress {
let progress = TransferProgress {
total: items.len(),
total_bytes,
..UploadProgress::default()
..TransferProgress::default()
};
Ok(Self {
items,
@@ -155,23 +155,26 @@ impl UploadBatch {
pub fn is_complete(&self) -> bool {
self.cursor >= self.items.len()
}
pub fn emit(&self, control: &UploadControl) {
pub fn emit(&self, control: &TransferControl) {
let _ = control
.events
.try_send(SftpEvent::Progress(self.progress.clone()));
}
pub fn phase(&mut self, phase: UploadPhase, control: &UploadControl) {
pub fn phase(&mut self, phase: TransferPhase, control: &TransferControl) {
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);
pub async fn run<F: RemoteFs>(&mut self, fs: &F, control: &TransferControl) -> Result<()> {
self.phase(TransferPhase::Transferring, 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 _target_guard = TargetGuard::acquire(
format!("{}\0{}", self.endpoint, item.target),
"其他会话正在上传同一目标,请稍后继续上传",
)?;
let result = if self
.blocked_directories
.iter()
@@ -207,15 +210,15 @@ impl UploadBatch {
return Err(error);
}
Err(error) => {
self.phase(UploadPhase::Waiting, control);
self.phase(TransferPhase::Waiting, control);
let answer = control
.ask(
UploadQuestionKind::Error,
TransferQuestionKind::Error,
item.target.as_str(),
&format!("无法上传:{error:#}"),
)
.await?;
if answer.choice() == UploadChoice::Retry {
if answer.choice() == TransferChoice::Retry {
if item.error.is_some() {
let request = UploadRequest::new(
vec![item.source.clone()],
@@ -251,9 +254,9 @@ impl UploadBatch {
}
self.cursor += 1;
self.progress.completed_bytes = self.completed_bytes;
self.phase(UploadPhase::Uploading, control);
self.phase(TransferPhase::Transferring, control);
}
self.phase(UploadPhase::Completed, control);
self.phase(TransferPhase::Completed, control);
Ok(())
}
async fn approve(
@@ -261,7 +264,7 @@ impl UploadBatch {
target: &RemotePath,
original: &Option<FileMetadata>,
changed: bool,
control: &UploadControl,
control: &TransferControl,
) -> Result<bool> {
if original.is_none() && !changed {
return Ok(true);
@@ -273,9 +276,9 @@ impl UploadBatch {
bail!("目录与文件类型冲突,不会删除远程目录");
}
if !changed && let Some(choice) = self.all_conflicts {
return Ok(choice == UploadChoice::Overwrite);
return Ok(choice == TransferChoice::Overwrite);
}
self.phase(UploadPhase::Waiting, control);
self.phase(TransferPhase::Waiting, control);
let message = match original {
Some(metadata) => format!(
"{}远程项目已存在({} 字节,修改时间 {:?})。覆盖会替换这个文件或链接。",
@@ -290,19 +293,19 @@ impl UploadBatch {
None => "原目标在上传期间已被删除或移走。是否继续发布此文件?".into(),
};
let answer = control
.ask(UploadQuestionKind::Conflict, target.as_str(), &message)
.ask(TransferQuestionKind::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)
self.phase(TransferPhase::Transferring, control);
Ok(answer.choice() == TransferChoice::Overwrite)
}
async fn upload_item<F: RemoteFs>(
&mut self,
fs: &F,
item: &UploadItem,
control: &UploadControl,
control: &TransferControl,
) -> Result<bool> {
let existing_record = self
.journal
@@ -311,15 +314,15 @@ impl UploadBatch {
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);
self.phase(TransferPhase::Waiting, control);
let answer = control
.ask(
UploadQuestionKind::InvalidResume,
TransferQuestionKind::InvalidResume,
item.target.as_str(),
"来源已变为目录,请丢弃旧文件进度后重新上传,或跳过。",
)
.await?;
if answer.choice() != UploadChoice::Restart {
if answer.choice() != TransferChoice::Restart {
return Ok(false);
}
self.discard_record(fs, record).await?;
@@ -339,17 +342,17 @@ impl UploadBatch {
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);
self.phase(TransferPhase::Waiting, control);
let answer = control
.ask(
UploadQuestionKind::Resume,
TransferQuestionKind::Resume,
item.target.as_str(),
"发现未完成的 .filepart 文件。续传会按它的现有大小跳过本地文件前缀,请确认本地文件仍是同一版本。",
)
.await?;
match answer.choice() {
UploadChoice::Skip => return Ok(false),
UploadChoice::Restart => {
TransferChoice::Skip => return Ok(false),
TransferChoice::Restart => {
self.discard_record(fs, &record).await?;
return Box::pin(self.upload_item(fs, item, control)).await;
}
@@ -376,22 +379,22 @@ impl UploadBatch {
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);
self.phase(TransferPhase::Waiting, control);
let answer = control
.ask(
UploadQuestionKind::Resume,
TransferQuestionKind::Resume,
item.target.as_str(),
"发现未完成的 .filepart 文件。续传会按它的现有大小跳过本地文件前缀,请确认本地文件仍是同一版本。",
)
.await?;
match answer.choice() {
UploadChoice::Skip => return Ok(false),
UploadChoice::Restart => {
TransferChoice::Skip => return Ok(false),
TransferChoice::Restart => {
control.run(fs.remove(&record.temporary)).await?;
}
_ => {}
}
self.phase(UploadPhase::Uploading, control);
self.phase(TransferPhase::Transferring, control);
}
} else {
record.link_target = Some(
@@ -431,15 +434,15 @@ impl UploadBatch {
};
if let Err(error) = uploaded {
if error.is::<InvalidResume>() {
self.phase(UploadPhase::Waiting, control);
self.phase(TransferPhase::Waiting, control);
let answer = control
.ask(
UploadQuestionKind::InvalidResume,
TransferQuestionKind::InvalidResume,
item.target.as_str(),
&error.to_string(),
)
.await?;
if answer.choice() == UploadChoice::Restart {
if answer.choice() == TransferChoice::Restart {
self.discard_record(fs, &record).await?;
return Box::pin(self.upload_item(fs, item, control)).await;
}
@@ -456,7 +459,7 @@ impl UploadBatch {
fs: &F,
item: &UploadItem,
record: &mut ResumeRecord,
control: &UploadControl,
control: &TransferControl,
) -> Result<()> {
let source_metadata = record
.source_metadata
@@ -476,7 +479,7 @@ impl UploadBatch {
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);
self.phase(TransferPhase::Transferring, control);
let mut next_offset = resume_offset;
let mut uploaded = resume_offset;
let mut writes = FuturesUnordered::new();
@@ -532,7 +535,7 @@ impl UploadBatch {
fs: &F,
path: &RemotePath,
record: &ResumeRecord,
control: &UploadControl,
control: &TransferControl,
) -> Result<bool> {
let Some(metadata) = control.run(fs.metadata(path)).await? else {
return Ok(false);
@@ -550,7 +553,7 @@ impl UploadBatch {
&mut self,
fs: &F,
record: &mut ResumeRecord,
control: &UploadControl,
control: &TransferControl,
) -> Result<bool> {
let temporary = control.run(fs.metadata(&record.temporary)).await?;
if temporary.is_none() {
@@ -696,28 +699,3 @@ impl std::fmt::Display for InvalidResume {
}
}
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);
}
}
}
+155 -36
View File
@@ -1,8 +1,11 @@
use super::{
RemotePath, SftpCommand, SftpEvent, SftpTransport, SftpTransportProvider, UploadPhase,
RemotePath, SftpCommand, SftpEvent, SftpTransport, SftpTransportProvider, TransferDirection,
TransferPhase, TransferProgress,
client::{SftpClient, is_network_error},
control::{Cancelled, UploadControl},
journal::Journal,
control::{Cancelled, TransferControl},
download::DownloadBatch,
journal::{DownloadJournal, Journal},
operations,
upload::UploadBatch,
};
use crate::{
@@ -24,12 +27,15 @@ use tokio::sync::{RwLock, watch};
pub struct SshSftpTransportProvider {
connector: SshConnector,
resume_dir: PathBuf,
download_resume_dir: PathBuf,
}
impl SshSftpTransportProvider {
pub fn new(connector: SshConnector, resume_dir: PathBuf) -> Self {
/// Upload and download resume records live in separate directories.
pub fn new(connector: SshConnector, resume_dir: PathBuf, download_resume_dir: PathBuf) -> Self {
Self {
connector,
resume_dir,
download_resume_dir,
}
}
}
@@ -39,6 +45,7 @@ impl SftpTransportProvider for SshSftpTransportProvider {
connector: self.connector.clone(),
config: SshConnectionConfig::from(session),
journal: Journal::new(self.resume_dir.clone()),
download_journal: DownloadJournal::new(self.download_resume_dir.clone()),
})
}
}
@@ -46,6 +53,51 @@ struct SshSftpTransport {
connector: SshConnector,
config: SshConnectionConfig,
journal: Journal,
download_journal: DownloadJournal,
}
/// The session's one transfer batch, whichever way it goes.
enum TransferBatch {
Upload(UploadBatch),
Download(DownloadBatch),
}
impl TransferBatch {
fn verify_host(&self, fingerprint: &str) -> Result<()> {
match self {
Self::Upload(batch) => batch.verify_host(fingerprint),
Self::Download(batch) => batch.verify_host(fingerprint),
}
}
fn is_complete(&self) -> bool {
match self {
Self::Upload(batch) => batch.is_complete(),
Self::Download(batch) => batch.is_complete(),
}
}
fn phase(&mut self, phase: TransferPhase, control: &TransferControl) {
match self {
Self::Upload(batch) => batch.phase(phase, control),
Self::Download(batch) => batch.phase(phase, control),
}
}
fn set_current(&mut self, current: String) {
match self {
Self::Upload(batch) => batch.progress.current = current,
Self::Download(batch) => batch.progress.current = current,
}
}
async fn run(&mut self, client: &SftpClient, control: &TransferControl) -> Result<()> {
match self {
Self::Upload(batch) => batch.run(client, control).await,
Self::Download(batch) => batch.run(client, control).await,
}
}
async fn discard(&self, client: &SftpClient) -> Result<()> {
match self {
Self::Upload(batch) => batch.discard(client).await,
Self::Download(batch) => batch.discard().await,
}
}
}
impl SftpTransport for SshSftpTransport {
fn run(
@@ -131,20 +183,7 @@ impl SshSftpTransport {
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;
}
}
drop_broken(&shared, &connected, &events, &error).await;
Err(format!("无法读取目录:{error:#}"))
}
},
@@ -153,10 +192,37 @@ impl SshSftpTransport {
let _ = events.send(SftpEvent::Listed { request_id, result }).await;
});
}
SftpCommand::Operate {
request_id,
operation,
} => {
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 operations::run(connected.as_ref(), &operation).await
{
Ok(()) => Ok(()),
Err(error) => {
drop_broken(&shared, &connected, &events, &error)
.await;
Err(format!("{error:#}"))
}
}
}
None => Err("SFTP 未连接,请重新连接".into()),
};
let _ = events
.send(SftpEvent::Operated { request_id, result })
.await;
});
}
command => {
if busy.swap(true, Ordering::AcqRel) {
let _ = events
.send(SftpEvent::Notice("已有上传批次正在处理".into()))
.send(SftpEvent::Notice("已有传输批次正在处理".into()))
.await;
} else {
cancel_tx.send_replace(false);
@@ -172,12 +238,12 @@ impl SshSftpTransport {
prompts.cancel_all();
})
};
let control = UploadControl {
let control = TransferControl {
events: events.clone(),
cancel,
answers,
};
let mut batch: Option<UploadBatch> = None;
let mut batch: Option<TransferBatch> = None;
match control
.run(self.connect(prompts.clone(), &events, &shutdown, &client))
.await
@@ -209,17 +275,28 @@ impl SshSftpTransport {
}
}
let connected = client.read().await.clone().expect("connected above");
let direction = match &operation {
SftpCommand::Download(_) => TransferDirection::Download,
SftpCommand::Upload(_) => TransferDirection::Upload,
_ => batch
.as_ref()
.map(|batch| match batch {
TransferBatch::Upload(_) => TransferDirection::Upload,
TransferBatch::Download(_) => TransferDirection::Download,
})
.unwrap_or_default(),
};
let prepared: Result<()> = async {
if let SftpCommand::Upload(request) = &operation {
batch = None;
events
.send(SftpEvent::Progress(super::UploadProgress::default()))
.send(SftpEvent::Progress(TransferProgress::default()))
.await?;
let target = control
.run(connected.canonicalize(request.destination()))
.await?;
let request = super::UploadRequest::new(request.sources().to_vec(), target)?;
batch = Some(
batch = Some(TransferBatch::Upload(
control
.run(UploadBatch::scan(
&request,
@@ -229,7 +306,27 @@ impl SshSftpTransport {
&control,
))
.await?,
);
));
}
if let SftpCommand::Download(request) = &operation {
batch = None;
events
.send(SftpEvent::Progress(TransferProgress {
direction: TransferDirection::Download,
..TransferProgress::default()
}))
.await?;
batch = Some(TransferBatch::Download(
DownloadBatch::scan(
request,
&self.config.endpoint(),
connected.fingerprint(),
self.download_journal.clone(),
connected.as_ref(),
&control,
)
.await?,
));
}
if let Some(batch) = &batch {
batch.verify_host(connected.fingerprint())?;
@@ -240,8 +337,9 @@ impl SshSftpTransport {
}
batch = None;
events
.send(SftpEvent::Progress(super::UploadProgress {
phase: UploadPhase::Completed,
.send(SftpEvent::Progress(TransferProgress {
direction,
phase: TransferPhase::Completed,
..Default::default()
}))
.await?;
@@ -252,11 +350,12 @@ impl SshSftpTransport {
if let Err(error) = prepared {
events.send(SftpEvent::Notice(error.to_string())).await?;
if let Some(batch) = &mut batch {
batch.phase(UploadPhase::Stopped, &control);
batch.phase(TransferPhase::Stopped, &control);
} else {
events
.send(SftpEvent::Progress(super::UploadProgress {
phase: UploadPhase::Stopped,
.send(SftpEvent::Progress(TransferProgress {
direction,
phase: TransferPhase::Stopped,
details: vec![error.to_string()],
..Default::default()
}))
@@ -290,16 +389,16 @@ impl SshSftpTransport {
Err(error) if is_network_error(&error) => {
*client.write().await = None;
events
.send(SftpEvent::Disconnected("连接中断,上传进度已保留".into()))
.send(SftpEvent::Disconnected("连接中断,传输进度已保留".into()))
.await?;
let mut recovered = false;
while reconnects < 3 {
batch.progress.current = format!(
batch.set_current(format!(
"将在 {} 秒后重连({}/3)",
[1, 3, 10][reconnects],
reconnects + 1
);
batch.phase(UploadPhase::Reconnecting, &control);
));
batch.phase(TransferPhase::Reconnecting, &control);
let delay = Duration::from_secs([1, 3, 10][reconnects]);
reconnects += 1;
if control
@@ -343,20 +442,20 @@ impl SshSftpTransport {
if recovered {
continue;
}
batch.phase(UploadPhase::Stopped, &control);
batch.phase(TransferPhase::Stopped, &control);
break;
}
Err(error) => {
if !error.is::<Cancelled>() {
events.send(SftpEvent::Notice(error.to_string())).await?;
}
batch.phase(UploadPhase::Stopped, &control);
batch.phase(TransferPhase::Stopped, &control);
break;
}
}
}
if batch.is_complete() {
batch.phase(UploadPhase::Completed, &control);
batch.phase(TransferPhase::Completed, &control);
}
}
busy.store(false, Ordering::Release);
@@ -367,3 +466,23 @@ impl SshSftpTransport {
Ok(())
}
}
/// A request failed because the connection broke: forget that client (unless
/// a newer one already replaced it) and tell the panel once.
async fn drop_broken(
shared: &RwLock<Option<Arc<SftpClient>>>,
connected: &Arc<SftpClient>,
events: &Sender<SftpEvent>,
error: &anyhow::Error,
) {
if !is_network_error(error) {
return;
}
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;
}
}
+32 -42
View File
@@ -23,14 +23,13 @@ use crate::app::{
CloseTerminal, CollapseAllGroups, ConnectGroup, ConnectSession, CopySessionHost, CopyTerminal,
DeleteGroup, DeleteSession, DisconnectSession, DisconnectTerminal, DismissTerminalFind,
DuplicateSession, EditSession, ExpandAllGroups, ExplorerAction, ExplorerCommand,
FindInTerminal, FindNextInTerminal, FindPreviousInTerminal, FocusSearch, MoveSessionNode,
NewChildGroup, NewGroup, NewLocalTerminal, NewSession, NewSessionInGroup, OpenExplorer,
PasteTerminal, ReconnectTerminal, RenameGroup, RenameTerminal, RestartLocalTerminal,
SelectAllUploadFiles, ToggleSessionPanel, ToggleTheme, ToggleUploadSelection,
UploadSelectedFiles, ZoomIn, ZoomOut, ZoomReset,
ExplorerShortcut, FindInTerminal, FindNextInTerminal, FindPreviousInTerminal, FocusSearch,
MoveSessionNode, NewChildGroup, NewGroup, NewLocalTerminal, NewSession, NewSessionInGroup,
OpenExplorer, PasteTerminal, ReconnectTerminal, RenameGroup, RenameTerminal,
RestartLocalTerminal, ToggleSessionPanel, ToggleTheme, ZoomIn, ZoomOut, ZoomReset,
};
use crate::connection::SharedConnectionTester;
use crate::explorer::{ExplorerPanel, ExplorerPanelEvent, confirm_close_upload};
use crate::explorer::{ExplorerPanel, ExplorerPanelEvent, confirm_close_transfer};
use crate::session::{
ConnectionState, GroupId, SessionId, SessionPanel, SessionStore, SessionStoreEvent,
confirm_delete_group, confirm_delete_session, open_group_dialog, open_session_dialog,
@@ -141,6 +140,7 @@ impl Workspace {
let sftp = Arc::new(SshSftpTransportProvider::new(
connector,
crate::app::data_dir().join("upload-resume"),
crate::app::data_dir().join("download-resume"),
));
Self::new_with_services(
store,
@@ -171,6 +171,7 @@ impl Workspace {
let sftp = Arc::new(SshSftpTransportProvider::new(
connector,
crate::app::data_dir().join("upload-resume"),
crate::app::data_dir().join("download-resume"),
));
Self::new_with_services(
store,
@@ -866,8 +867,16 @@ impl Workspace {
) {
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);
if panel.read(cx).is_transferring() {
let direction = panel.read(cx).transfer_direction();
confirm_close_transfer(
action.0,
generation,
direction,
self.focus_handle.clone(),
window,
cx,
);
} else {
self.remove_explorer(action.0, window, cx);
}
@@ -900,7 +909,7 @@ impl Workspace {
{
return;
}
if matches!(action.command(), ExplorerCommand::CancelUpload) {
if matches!(action.command(), ExplorerCommand::CancelTransfer) {
self.cancel_prompts_for_owner(
PromptOwner::Sftp(action.session(), panel.read(cx).generation()),
window,
@@ -913,40 +922,23 @@ impl Workspace {
panel.update(cx, |panel, cx| panel.execute(action.command(), window, cx));
}
}
fn explorer_shortcut(
/// File-list shortcuts go to the explorer holding focus, not the tab
/// activated last: two SFTP tabs can sit side by side in split groups.
fn on_explorer_shortcut(
&mut self,
command: ExplorerCommand,
action: &ExplorerShortcut,
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);
let focused = self
.explorers
.iter()
.find(|(_, panel)| panel.read(cx).contains_focus(window, cx))
.map(|(id, _)| *id);
if let Some(id) = focused {
self.on_explorer_action(&ExplorerAction::new(id, action.0.clone()), 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,
@@ -1301,7 +1293,7 @@ impl Workspace {
usize::from(
self.explorers
.get(&id)
.is_some_and(|p| p.read(cx).is_uploading()),
.is_some_and(|p| p.read(cx).is_transferring()),
),
),
Rc::new(move |window, cx| {
@@ -1441,7 +1433,7 @@ impl Workspace {
.filter(|id| {
self.explorers
.get(id)
.is_some_and(|p| p.read(cx).is_uploading())
.is_some_and(|p| p.read(cx).is_transferring())
})
.count(),
),
@@ -1763,9 +1755,7 @@ impl Render for Workspace {
.on_action(cx.listener(Self::on_disconnect_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_explorer_shortcut))
.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))
+738 -99
View File
File diff suppressed because it is too large. Load diff