SFTP 双栏对标 WinSCP:两行工具栏、WinSCP 列、多选、右键菜单、文件操作与下载
This commit is contained in:
1 parent
adbd1de688
commit
4badde667f
38 files changed
+7022
-1056
No files matched your search
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
@@ -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
@@ -20,7 +20,15 @@ icon_assets!(
|
||||
FolderTree,
|
||||
FolderPlus,
|
||||
Eraser,
|
||||
ClipboardPaste
|
||||
ClipboardPaste,
|
||||
House,
|
||||
FolderUp,
|
||||
FolderRoot,
|
||||
FolderSymlink,
|
||||
FileSymlink,
|
||||
FilePlus,
|
||||
Bookmark,
|
||||
SquarePen
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
+130
-3
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -217,7 +217,7 @@ pub fn confirm_delete_group(
|
||||
if uploads > 0 {
|
||||
description = Some(
|
||||
format!(
|
||||
"{}将停止 {uploads} 个上传批次并保留续传进度。",
|
||||
"{}将停止 {uploads} 个传输批次并保留续传进度。",
|
||||
description.unwrap_or_default()
|
||||
)
|
||||
.into(),
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) = ¤t
|
||||
&& 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
@@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
@@ -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(¤t) 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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user