From 4badde667fb53d70bf88f10dcfc1b8d5ff320ebc Mon Sep 17 00:00:00 2001 From: Xuz Date: Fri, 25 Sep 2026 01:54:32 +0800 Subject: [PATCH] =?UTF-8?q?SFTP=20=E5=8F=8C=E6=A0=8F=E5=AF=B9=E6=A0=87=20W?= =?UTF-8?q?inSCP=EF=BC=9A=E4=B8=A4=E8=A1=8C=E5=B7=A5=E5=85=B7=E6=A0=8F?= =?UTF-8?q?=E3=80=81WinSCP=20=E5=88=97=E3=80=81=E5=A4=9A=E9=80=89=E3=80=81?= =?UTF-8?q?=E5=8F=B3=E9=94=AE=E8=8F=9C=E5=8D=95=E3=80=81=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E4=B8=8E=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 21 +- Cargo.lock | 25 + Cargo.toml | 1 + README.md | 95 +- src/app/actions.rs | 112 ++- src/app/assets.rs | 10 +- src/app/mod.rs | 133 ++- src/explorer/explorer_panel.rs | 375 +++++-- src/explorer/file_dialogs.rs | 300 ++++++ src/explorer/file_listing.rs | 473 +++++---- src/explorer/file_pane.rs | 940 ++++++++++++------ src/explorer/history.rs | 102 ++ src/explorer/mod.rs | 14 +- src/explorer/model.rs | 280 +++++- src/explorer/pane_menu.rs | 205 ++++ src/explorer/pane_operations.rs | 218 ++++ src/explorer/properties_dialog.rs | 454 +++++++++ src/explorer/selection.rs | 269 +++++ .../{upload_dialog.rs => transfer_dialog.rs} | 256 ++++- src/session/database.rs | 120 ++- src/session/group_dialog.rs | 2 +- src/session/model.rs | 29 + src/session/session_dialog.rs | 2 +- src/session/store.rs | 113 ++- src/sftp/client.rs | 189 +++- src/sftp/control.rs | 45 +- src/sftp/download.rs | 658 ++++++++++++ src/sftp/journal.rs | 176 +++- src/sftp/mod.rs | 2 + src/sftp/model.rs | 298 +++++- src/sftp/operations.rs | 92 ++ src/sftp/protocol_tests.rs | 71 +- src/sftp/tests.rs | 732 +++++++++++++- src/sftp/transport.rs | 48 +- src/sftp/upload.rs | 116 +-- src/sftp/worker.rs | 191 +++- src/workspace/workspace_view.rs | 74 +- tests/ui.rs | 837 ++++++++++++++-- 38 files changed, 7022 insertions(+), 1056 deletions(-) create mode 100644 src/explorer/file_dialogs.rs create mode 100644 src/explorer/history.rs create mode 100644 src/explorer/pane_menu.rs create mode 100644 src/explorer/pane_operations.rs create mode 100644 src/explorer/properties_dialog.rs create mode 100644 src/explorer/selection.rs rename src/explorer/{upload_dialog.rs => transfer_dialog.rs} (54%) create mode 100644 src/sftp/download.rs create mode 100644 src/sftp/operations.rs diff --git a/CLAUDE.md b/CLAUDE.md index 0c853e0..1d96aaa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/.md` 为准。 @@ -43,15 +43,22 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - `ssh/` — `probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`:真实的 russh 客户端。known_hosts 校验写 `/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`:生产环境由 `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`:生产环境由 `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` 初始为已连接并已打开终端标签。 diff --git a/Cargo.lock b/Cargo.lock index 8e0460f..1bfd9bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 8f6fb53..20fca0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/README.md b/README.md index 992d40c..71974e5 100644 --- a/README.md +++ b/README.md @@ -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 窗口中补充人工验收;无头测试覆盖原生文件拖入事件和面板内部拖放。 diff --git a/src/app/actions.rs b/src/app/actions.rs index 7c32210..2e8b79f 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -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, + target: String, + }, + BeginDownload { + paths: Vec, + target: String, + }, + /// Ask to delete the pane's selection. + Delete { + remote: bool, + }, + BeginDelete { + remote: bool, + names: Vec, + }, + 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, + edit: crate::sftp::PermissionEdit, + recursive: bool, + add_x_to_dirs: bool, + }, ChooseFiles, UploadPaths { paths: Vec, @@ -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. diff --git a/src/app/assets.rs b/src/app/assets.rs index c79bff5..9b8e673 100644 --- a/src/app/assets.rs +++ b/src/app/assets.rs @@ -20,7 +20,15 @@ icon_assets!( FolderTree, FolderPlus, Eraser, - ClipboardPaste + ClipboardPaste, + House, + FolderUp, + FolderRoot, + FolderSymlink, + FileSymlink, + FilePlus, + Bookmark, + SquarePen ] ); diff --git a/src/app/mod.rs b/src/app/mod.rs index 8067219..239d728 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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::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 { ]); } 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 { + 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 } diff --git a/src/explorer/explorer_panel.rs b/src/explorer/explorer_panel.rs index acacf20..f4e00cb 100644 --- a/src/explorer/explorer_panel.rs +++ b/src/explorer/explorer_panel.rs @@ -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, local: Entity, - remote: Entity, - local_provider: SharedLocalDirectoryProvider, + pub(super) remote: Entity, + pub(super) local_provider: SharedLocalDirectoryProvider, + /// File operations waiting for their result, by request id. + pub(super) operations: HashMap, + pub(super) next_operation: u64, commands: async_channel::Sender, state: ConnectionState, message: String, - progress: Option, + progress: Option, details: bool, - upload_pending: bool, - pub(super) question: Option, + transfer_pending: bool, + pub(super) question: Option, pub(super) dialog_open: bool, pub(super) dispatch: FocusHandle, focus_handle: FocusHandle, tab_group: Option>, + /// The pane that held focus last, which takes it back on activation. + last_remote: bool, _subscriptions: Vec, _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 { &self.remote } + pub fn pane(&self, remote: bool) -> &Entity { + 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 { + [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) { - 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) { 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, ) { - 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) { + 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::>>() + .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) -> 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() diff --git a/src/explorer/file_dialogs.rs b/src/explorer/file_dialogs.rs new file mode 100644 index 0000000..f1e7d37 --- /dev/null +++ b/src/explorer/file_dialogs.rs @@ -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, + label: &'static str, + error: Option, +} +impl Render for NameForm { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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, + ) { + 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, + ) { + 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.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, + ) { + if !self.can_modify(remote, cx) || window.has_active_dialog(cx) { + return; + } + let existing: Vec = 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()); + } +} diff --git a/src/explorer/file_listing.rs b/src/explorer/file_listing.rs index b57d47f..d04de1d 100644 --- a/src/explorer/file_listing.rs +++ b/src/explorer/file_listing.rs @@ -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, + pub transfer_enabled: bool, + pub dispatch: Option, + pub pane: Option>, + /// The row under the last right-click, read by the list's context menu. + pub menu_hit: Rc>>, +} -/// 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, rows: Vec, sort: Option<(usize, ColumnSort)>, - active_name: Option, side: PaneSide, - session: SessionId, - path: String, - checked: Rc>, - enabled: bool, - dispatch: Option, + context: ListingContext, } impl FileListing { pub fn new(side: PaneSide, rows: Vec) -> 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) { - self.active_name = name; - } - pub fn configure( - &mut self, - session: SessionId, - path: String, - checked: Rc>, - 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 { + self.rows.iter().position(|row| row.name.as_ref() == name) + } + pub fn set_rows(&mut self, rows: Vec) { 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>, + ) -> 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>, + cx: &mut Context>, ) -> Stateful
{ 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::>() - } 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, } +/// Remote rows dragged toward the local pane, as remote paths. +#[derive(Clone)] +pub(super) struct RemoteFilesDrag { + pub paths: Vec, +} 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)) } } diff --git a/src/explorer/file_pane.rs b/src/explorer/file_pane.rs index 2311959..20d5a4d 100644 --- a/src/explorer/file_pane.rs +++ b/src/explorer/file_pane.rs @@ -1,21 +1,33 @@ -use super::{FileEntry, FileListing}; +use super::{ + ClickMode, CursorMotion, FileEntry, FileListing, LoadIntent, NavigationHistory, Selection, + file_listing::ListingContext, + pane_menu::{PaneMenuState, bookmark_menu, directory_menu, item_menu, new_menu}, + path_ancestors, +}; use crate::app::ExplorerDispatch as _; use crate::{ - app::{CatalogIcon, ExplorerAction, ExplorerCommand}, - session::SessionId, - sftp::{DirectoryListing, EntryKind, SharedLocalDirectoryProvider}, + app::{ + CatalogIcon, ExplorerAction, ExplorerCommand, ExplorerShortcut, LOCAL_FILE_LIST_CONTEXT, + REMOTE_FILE_LIST_CONTEXT, + }, + session::{BookmarkSide, SessionId, SessionStore}, + sftp::{DirectoryListing, SharedLocalDirectoryProvider}, }; use gpui_kit::component::{ ActiveTheme as _, Disableable as _, Icon, IconName, Sizable as _, - button::{Button, ButtonVariants as _}, + button::{Button, ButtonVariants as _, DropdownButton}, h_flex, input::{Input, InputEvent, InputState}, - table::{DataTable, TableEvent, TableState}, + menu::{ContextMenuExt as _, DropdownMenu as _}, + searchable_list::{SearchableGroup, SearchableListItem, SearchableVec}, + select::{Select, SelectEvent, SelectState}, + separator::Separator, + table::{DataTable, TableDelegate as _, TableState}, v_flex, }; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; -use std::{collections::HashSet, path::PathBuf, rc::Rc}; +use std::{cell::RefCell, path::PathBuf, rc::Rc}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PaneSide { @@ -44,151 +56,255 @@ impl PaneSide { "remote-path" } } + fn key_context(self) -> &'static str { + if self == Self::Local { + LOCAL_FILE_LIST_CONTEXT + } else { + REMOTE_FILE_LIST_CONTEXT + } + } + fn bookmark_side(self) -> BookmarkSide { + BookmarkSide::from_remote(self == Self::Remote) + } } + +/// One entry of the 目录列表 select: a directory on the way to the current +/// one, or (local only) a well-known place. +#[derive(Clone)] +pub struct PathChoice { + title: SharedString, + path: SharedString, + depth: usize, + home: bool, + side: PaneSide, +} +impl SearchableListItem for PathChoice { + type Value = SharedString; + fn title(&self) -> SharedString { + self.title.clone() + } + fn value(&self) -> &SharedString { + &self.path + } + fn render(&self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let icon = if self.home { + Icon::new(CatalogIcon::House) + } else { + Icon::new(IconName::Folder) + }; + h_flex() + .gap_2() + .pl(rems(0.75 * self.depth as f32)) + .child(icon.small().text_color(cx.theme().muted_foreground)) + .child(self.title.clone()) + } + /// The trigger names the side, since the panes carry no other label. + fn display_title(&self) -> Option { + let icon = if self.side == PaneSide::Remote { + Icon::new(CatalogIcon::Server) + } else { + Icon::new(IconName::HardDrive) + }; + Some( + h_flex() + .gap_2() + .min_w_0() + .child(icon.small()) + .child(div().min_w_0().text_ellipsis().child(self.title.clone())) + .into_any_element(), + ) + } +} +type PathChoices = SearchableVec>; + pub struct FilePane { side: PaneSide, session_id: SessionId, path: String, home: String, + /// Well-known local places for the 目录列表 select; empty for remote. + places: Vec<(SharedString, String)>, table: Entity>, path_input: Entity, - selected: Option, - checked: HashSet, - anchor: Option, + path_select: Entity>, + selection: Selection, + menu_hit: Rc>>, + history: NavigationHistory, + /// The load in flight, why it started, and where it started from. + pending: Option<(u64, LoadIntent, String)>, + /// A row to select once the next listing arrives (after create/rename). + select_after_load: Option, request_id: u64, loading: bool, error: Option, + connected: bool, transfer_enabled: bool, + /// A file operation on this pane is running. + busy: bool, + store: Entity, dispatch: FocusHandle, _subscriptions: Vec, load_task: Option>, } impl FilePane { + #[allow(clippy::too_many_arguments)] pub fn new( side: PaneSide, session_id: SessionId, home: String, + places: Vec<(SharedString, String)>, + store: Entity, dispatch: FocusHandle, window: &mut Window, cx: &mut Context, ) -> Self { - let mut listing = FileListing::new(side, Vec::new()); - listing.configure( - session_id, - home.clone(), - Rc::new(HashSet::new()), - false, - dispatch.clone(), - ); + let listing = FileListing::new(side, Vec::new()); + // The pane owns selection (multi-select by name); the table's single + // row selection stays off so it never paints a competing highlight. let table = cx.new(|cx| { TableState::new(listing, window, cx) - .row_selectable(true) + .row_selectable(false) .col_selectable(false) .cell_selectable(false) }); let path_input = cx.new(|cx| InputState::new(window, cx).default_value(&home)); + let path_select = cx.new(|cx| SelectState::new(PathChoices::new(vec![]), None, window, cx)); let subscriptions = vec![ - cx.subscribe_in( - &table, - window, - |this, table, event: &TableEvent, window, cx| match event { - TableEvent::SelectRow(ix) => { - this.selected = table - .read(cx) - .delegate() - .entry(*ix) - .map(|e| e.name.to_string()); - table.update(cx, |table, _| { - table.delegate_mut().set_active_name(this.selected.clone()) - }); - cx.notify(); - } - TableEvent::DoubleClickedRow(ix) => { - let entry = table.read(cx).delegate().entry(*ix).cloned(); - if let Some(entry) = entry - && entry.is_dir() - { - let command = if entry.is_parent() { - ExplorerCommand::Up { - remote: this.side == PaneSide::Remote, - } - } else { - ExplorerCommand::Navigate { - remote: this.side == PaneSide::Remote, - path: this.child_path(&entry.name), - } - }; - this.dispatch.dispatch_explorer_action( - &ExplorerAction::new(this.session_id, command), - window, - cx, - ); - } - } - TableEvent::ClearSelection => { - this.selected = None; - cx.notify(); - } - _ => {} - }, - ), cx.subscribe_in( &path_input, window, |this, state, event: &InputEvent, window, cx| { if matches!(event, InputEvent::PressEnter { .. }) { - this.dispatch.dispatch_explorer_action( - &ExplorerAction::new( - this.session_id, - ExplorerCommand::Navigate { - remote: this.side == PaneSide::Remote, - path: state.read(cx).value().to_string(), - }, - ), + let path = state.read(cx).value().to_string(); + let remote = this.is_remote(); + this.dispatch_command( + ExplorerCommand::Navigate { remote, path }, window, cx, ); } }, ), + cx.subscribe_in( + &path_select, + window, + |this, _, event: &SelectEvent, window, cx| { + let SelectEvent::Confirm(Some(path)) = event else { + return; + }; + if path.as_ref() != this.path { + let path = path.to_string(); + let remote = this.is_remote(); + this.dispatch_command( + ExplorerCommand::Navigate { remote, path }, + window, + cx, + ); + } + }, + ), + cx.observe(&store, |_, _, cx| cx.notify()), ]; - Self { + let mut pane = Self { side, session_id, path: home.clone(), home, + places, table, path_input, - selected: None, - checked: HashSet::new(), - anchor: None, + path_select, + selection: Selection::default(), + menu_hit: Rc::default(), + history: NavigationHistory::default(), + pending: None, + select_after_load: None, request_id: 0, loading: false, error: None, + connected: side == PaneSide::Local, transfer_enabled: false, + busy: false, + store, dispatch, _subscriptions: subscriptions, load_task: None, - } + }; + pane.sync_listing(cx); + pane } pub fn side(&self) -> PaneSide { self.side } + fn is_remote(&self) -> bool { + self.side == PaneSide::Remote + } pub fn path(&self) -> String { self.path.clone() } - pub fn selected_entry(&self, cx: &App) -> Option { - self.selected.as_ref().and_then(|name| { - self.table - .read(cx) - .delegate() - .rows() - .iter() - .find(|e| e.name.as_ref() == name) - .cloned() - }) + pub fn home(&self) -> String { + self.home.clone() } - fn child_path(&self, name: &str) -> String { + pub fn is_connected(&self) -> bool { + self.connected + } + fn dispatch_command(&self, command: ExplorerCommand, window: &mut Window, cx: &mut App) { + self.dispatch.dispatch_explorer_action( + &ExplorerAction::new(self.session_id, command), + window, + cx, + ); + } + /// The selected names in display order: what file commands act on. + pub fn selected_names(&self, cx: &App) -> Vec { + let table = self.table.read(cx); + self.selection.targets(&table.delegate().order()) + } + pub fn selected_entries(&self, cx: &App) -> Vec { + let table = self.table.read(cx); + let listing = table.delegate(); + self.selection + .targets(&listing.order()) + .iter() + .filter_map(|name| listing.entry(listing.position(name)?).cloned()) + .collect() + } + /// The row under the keyboard cursor. + pub fn cursor_entry(&self, cx: &App) -> Option { + let cursor = self.selection.cursor()?; + let table = self.table.read(cx); + let listing = table.delegate(); + listing.entry(listing.position(cursor)?).cloned() + } + pub fn entries(&self, cx: &App) -> Vec { + self.table.read(cx).delegate().rows().to_vec() + } + pub fn column_names(&self, cx: &App) -> Vec { + let table = self.table.read(cx); + let listing = table.delegate(); + (0..listing.columns_count(cx)) + .map(|ix| listing.column(ix, cx).name) + .collect() + } + pub fn focus_handle(&self, cx: &App) -> FocusHandle { + self.table.read(cx).focus_handle(cx) + } + pub fn menu_hit(&self) -> Option { + self.menu_hit.borrow().clone() + } + pub fn bookmarks(&self, cx: &App) -> Vec { + self.store + .read(cx) + .bookmarks(self.session_id, self.side.bookmark_side()) + .to_vec() + } + pub fn back_target(&self) -> Option { + self.history.back_target().map(str::to_string) + } + pub fn forward_target(&self) -> Option { + self.history.forward_target().map(str::to_string) + } + pub fn child_path_of(&self, name: &str) -> String { if self.side == PaneSide::Local { PathBuf::from(&self.path) .join(name) @@ -210,6 +326,19 @@ impl FilePane { .unwrap_or_else(|_| "/".into()) } } + /// `/` on the server; the drive or volume root of the current path locally. + pub fn root_path(&self) -> String { + if self.side == PaneSide::Local { + std::path::Path::new(&self.path) + .ancestors() + .last() + .map(|p| p.to_string_lossy().into_owned()) + .filter(|p| !p.is_empty()) + .unwrap_or_else(|| "/".into()) + } else { + "/".into() + } + } pub fn expanded_path(&self, path: &str) -> String { if path == "~" { self.home.clone() @@ -218,7 +347,7 @@ impl FilePane { } else if (self.side == PaneSide::Local && !PathBuf::from(path).is_absolute()) || (self.side == PaneSide::Remote && !path.starts_with('/')) { - self.child_path(path) + self.child_path_of(path) } else { path.to_string() } @@ -229,94 +358,128 @@ impl FilePane { } self.home = home; } - pub fn set_available(&mut self, available: bool, cx: &mut Context) { - self.transfer_enabled = available; + pub fn set_available(&mut self, connected: bool, transfer: bool, cx: &mut Context) { + self.connected = connected; + self.transfer_enabled = transfer; self.sync_listing(cx); cx.notify(); } + pub fn set_busy(&mut self, busy: bool, cx: &mut Context) { + self.busy = busy; + cx.notify(); + } + pub fn is_busy(&self) -> bool { + self.busy + } + /// Select this row once the next listing arrives. + pub fn select_after_load(&mut self, name: String) { + self.select_after_load = Some(name); + } fn sync_listing(&mut self, cx: &mut Context) { - let checked = Rc::new(self.checked.clone()); + let context = ListingContext { + session: self.session_id, + path: self.path.clone(), + selection: Rc::new(self.selection.clone()), + transfer_enabled: self.transfer_enabled, + dispatch: Some(self.dispatch.clone()), + pane: Some(cx.entity().downgrade()), + menu_hit: self.menu_hit.clone(), + }; self.table.update(cx, |table, cx| { - table.delegate_mut().configure( - self.session_id, - self.path.clone(), - checked, - self.transfer_enabled, - self.dispatch.clone(), - ); + table.delegate_mut().configure(context); cx.notify(); }); } - pub fn check(&mut self, name: &str, checked: bool, extend: bool, cx: &mut Context) { - let names: Vec<_> = self + fn update_selection( + &mut self, + cx: &mut Context, + change: impl FnOnce(&mut Selection, &[&str]), + ) { + let order: Vec = self .table .read(cx) .delegate() - .rows() - .iter() - .filter(|e| !e.is_parent()) - .map(|e| e.name.to_string()) + .order() + .into_iter() + .map(str::to_string) .collect(); - if !names.iter().any(|n| n == name) { - return; - } - let mut chosen = vec![name.to_string()]; - if extend - && let (Some(start), Some(end)) = ( - self.anchor - .as_ref() - .and_then(|anchor| names.iter().position(|n| n == anchor)), - names.iter().position(|n| n == name), - ) - { - chosen = names[start.min(end)..=start.max(end)].to_vec(); - } - for name in chosen { - if checked { - self.checked.insert(name); - } else { - self.checked.remove(&name); - } - } - self.anchor = Some(name.into()); + let order: Vec<&str> = order.iter().map(String::as_str).collect(); + change(&mut self.selection, &order); self.sync_listing(cx); cx.notify(); } - pub fn toggle_selected(&mut self, cx: &mut Context) { - if let Some(name) = self.selected.clone() { - self.check(&name, !self.checked.contains(&name), false, cx); + /// A pointer click on a row, with the modifiers already resolved. + pub fn click_row(&mut self, name: &str, mode: ClickMode, cx: &mut Context) { + self.clear_menu_row(cx); + self.update_selection(cx, |selection, order| selection.click(name, mode, order)); + } + /// Right-clicking an unselected row selects it first, as in WinSCP, so the + /// context menu acts on what is highlighted. + pub fn select_for_menu(&mut self, name: &str, cx: &mut Context) { + if !self.selection.contains(name) { + self.update_selection(cx, |selection, order| { + selection.click(name, ClickMode::Replace, order) + }); } } + /// The table outlines the right-clicked row until something clears it; + /// selection is ours, so the next click or move does. + fn clear_menu_row(&mut self, cx: &mut Context) { + self.table + .update(cx, |table, cx| table.set_right_clicked_row(None, cx)); + } + pub fn select_only(&mut self, name: &str, cx: &mut Context) { + self.update_selection(cx, |selection, order| selection.select_only(name, order)); + self.scroll_to_cursor(cx); + } + pub fn move_cursor(&mut self, motion: CursorMotion, extend: bool, cx: &mut Context) { + self.clear_menu_row(cx); + let page = self.table.read(cx).visible_range().rows().len(); + self.update_selection(cx, |selection, order| { + selection.move_cursor(motion, page, extend, order); + }); + self.scroll_to_cursor(cx); + } + /// Scroll only when the cursor row leaves the visible rows; the table's + /// own `scroll_to_row` always puts the row at the top. + fn scroll_to_cursor(&mut self, cx: &mut Context) { + let Some(cursor) = self.selection.cursor().map(str::to_string) else { + return; + }; + self.table.update(cx, |table, cx| { + let Some(ix) = table.delegate().position(&cursor) else { + return; + }; + let visible = table.visible_range().rows().clone(); + if visible.is_empty() || ix < visible.start { + table.scroll_to_row(ix, cx); + } else if ix + 1 >= visible.end { + table.scroll_to_row(ix + 2 - visible.len().max(1), cx); + } + }); + } + pub fn toggle_selection(&mut self, cx: &mut Context) { + self.update_selection(cx, |selection, order| selection.toggle_cursor(order)); + } pub fn select_all(&mut self, cx: &mut Context) { - self.checked = self - .table - .read(cx) - .delegate() - .rows() - .iter() - .filter(|e| !e.is_parent()) - .map(|e| e.name.to_string()) - .collect(); - self.sync_listing(cx); - cx.notify(); + self.update_selection(cx, |selection, order| selection.select_all(order)); } - pub fn upload_sources(&self) -> Vec { - let mut sources: Vec<_> = self - .checked - .iter() + pub fn upload_sources(&self, cx: &App) -> Vec { + self.selected_names(cx) + .into_iter() .map(|name| PathBuf::from(&self.path).join(name)) - .collect(); - sources.sort(); - sources + .collect() } pub fn disconnected(&mut self, message: String, cx: &mut Context) { self.request_id += 1; + self.pending = None; self.loading = false; self.error = Some(message); cx.notify(); } - pub fn begin_load(&mut self, cx: &mut Context) -> u64 { + pub fn begin_load(&mut self, intent: LoadIntent, cx: &mut Context) -> u64 { self.request_id += 1; + self.pending = Some((self.request_id, intent, self.path.clone())); self.loading = true; self.error = None; cx.notify(); @@ -333,65 +496,33 @@ impl FilePane { return; } self.loading = false; + let pending = self.pending.take(); match result { Ok(listing) => { + if let Some((_, intent, from)) = pending { + self.history.record(intent, &from, listing.path()); + } if listing.path() != self.path { - self.checked.clear(); - self.selected = None; - self.anchor = None; + self.selection.clear(); } self.path = listing.path().into(); self.error = None; let mut rows: Vec<_> = listing .entries() .iter() - .map(|entry| { - let metadata = entry.metadata(); - let modified = metadata - .modified() - .and_then(|t| chrono::DateTime::from_timestamp(t as i64, 0)) - .map(|t| { - t.with_timezone(&chrono::Local) - .format("%Y-%m-%d %H:%M") - .to_string() - }) - .unwrap_or_else(|| "—".into()); - let permissions = metadata - .permissions() - .map(|p| format!("{:03o}", p & 0o777)) - .unwrap_or_else(|| "—".into()); - match metadata.kind() { - EntryKind::Directory => { - FileEntry::dir(entry.name(), &modified, &permissions) - } - EntryKind::Symlink => { - FileEntry::symlink(entry.name(), &modified, &permissions) - } - _ => FileEntry::file( - entry.name(), - metadata.size(), - &modified, - &permissions, - ), - } - }) + .map(FileEntry::from_directory_entry) .collect(); - rows.sort_by(|a, b| { - b.is_dir() - .cmp(&a.is_dir()) - .then(a.name.to_lowercase().cmp(&b.name.to_lowercase())) - }); - self.checked - .retain(|name| rows.iter().any(|e| e.name.as_ref() == name)); if self.parent_path() != self.path { rows.insert(0, FileEntry::parent()); } self.table.update(cx, |table, cx| { table.delegate_mut().set_rows(rows); - table.clear_selection(cx); table.refresh(cx); }); - self.sync_listing(cx); + self.update_selection(cx, |selection, order| selection.retain(order)); + if let Some(name) = self.select_after_load.take() { + self.select_only(&name, cx); + } let display = if self.path == self.home { "~".into() } else if let Some(rest) = self @@ -404,19 +535,59 @@ impl FilePane { }; self.path_input .update(cx, |input, cx| input.set_value(display, window, cx)); + self.sync_path_select(window, cx); + } + Err(error) => { + self.select_after_load = None; + self.error = Some(error); } - Err(error) => self.error = Some(error), } cx.notify(); } + /// Rebuild the 目录列表 select: the path from the root to here, then the + /// local well-known places. + fn sync_path_select(&mut self, window: &mut Window, cx: &mut Context) { + let side = self.side; + let chain = path_ancestors(&self.path, side == PaneSide::Remote); + let mut groups = vec![ + SearchableGroup::new("当前路径").items(chain.into_iter().enumerate().map( + |(depth, (title, path))| PathChoice { + title: title.into(), + path: path.into(), + depth, + home: false, + side, + }, + )), + ]; + if !self.places.is_empty() { + groups.push( + SearchableGroup::new("位置").items(self.places.iter().enumerate().map( + |(ix, (title, path))| PathChoice { + title: title.clone(), + path: path.clone().into(), + depth: 0, + home: ix == 0, + side, + }, + )), + ); + } + let current: SharedString = self.path.clone().into(); + self.path_select.update(cx, |select, cx| { + select.set_items(PathChoices::new(groups), window, cx); + select.set_selected_value(¤t, window, cx); + }); + } pub fn load_local( &mut self, path: String, + intent: LoadIntent, provider: SharedLocalDirectoryProvider, window: &mut Window, cx: &mut Context, ) { - let id = self.begin_load(cx); + let id = self.begin_load(intent, cx); self.load_task = Some(cx.spawn_in(window, async move |this, cx| { let result = cx .background_spawn(async move { @@ -430,28 +601,54 @@ impl FilePane { }); })); } + /// What the menus show, read when a menu opens. + fn menu_state(&self, cx: &App) -> PaneMenuState { + let targets = self.selected_names(cx); + let entries = self.selected_entries(cx); + PaneMenuState { + remote: self.is_remote(), + session: self.session_id, + opens_directory: entries.len() == 1 && entries[0].is_dir(), + targets, + can_go_up: self.parent_path() != self.path, + can_go_home: !self.home.is_empty() && self.home != self.path, + can_go_back: self.history.back_target().is_some(), + can_go_forward: self.history.forward_target().is_some(), + path: self.path.clone(), + bookmarks: self.bookmarks(cx), + can_modify: self.connected && !self.busy, + can_transfer: self.transfer_enabled, + } + } } + impl Render for FilePane { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let remote = self.side == PaneSide::Remote; + let remote = self.is_remote(); let sid = self.session_id; - let button = - |id: &'static str, icon: IconName, tip: &'static str, command: ExplorerCommand| { - let dispatch = self.dispatch.clone(); - Button::new(id) - .ghost() - .small() - .icon(icon) - .tooltip(tip) - .on_click(move |_, window, cx| { - dispatch.dispatch_explorer_action( - &ExplorerAction::new(sid, command.clone()), - window, - cx, - ) - }) - }; - let dispatch = self.dispatch.clone(); + let context = self.side.key_context(); + let state = self.menu_state(cx); + let selected = state.targets.len(); + let navigable = self.connected; + // A toolbar button dispatches the same command as its key binding, + // and its tooltip shows that binding. + let tool = |id: &'static str, icon: Icon, tip: &'static str, command: ExplorerCommand| { + let dispatch = self.dispatch.clone(); + let shortcut = ExplorerShortcut(command.clone()); + Button::new(id) + .ghost() + .small() + .icon(icon) + .accessibility_label(tip) + .tooltip_with_action(tip, &shortcut, Some(context)) + .on_click(move |_, window, cx| { + dispatch.dispatch_explorer_action( + &ExplorerAction::new(sid, command.clone()), + window, + cx, + ) + }) + }; let file_count = self .table .read(cx) @@ -460,106 +657,207 @@ impl Render for FilePane { .iter() .filter(|e| !e.is_parent()) .count(); + let toolbar = || { + h_flex() + .flex_wrap() + .gap_1() + .px_2() + .py_1() + .border_b_1() + .border_color(cx.theme().border) + }; + let navigation = toolbar() + // `Select` fills its parent (`size_full`), so it needs a sized + // box of its own or it covers the whole toolbar row. + .child( + div().w(rems(10.)).flex_shrink_0().child( + Select::new(&self.path_select) + .id("path-select") + .small() + .menu_width(rems(18.)) + .accessibility_label(format!("{}目录", self.side.label())) + .disabled(!navigable), + ), + ) + .child({ + let menu = state.clone(); + Button::new("bookmarks") + .ghost() + .small() + .icon(Icon::new(CatalogIcon::Bookmark)) + .dropdown_caret(true) + .accessibility_label("书签") + .tooltip("书签") + .disabled(!navigable) + .dropdown_menu(move |popup, _, _| bookmark_menu(popup, &menu)) + }) + .child(Separator::vertical()) + .child( + tool( + "up", + Icon::new(CatalogIcon::FolderUp), + "上级目录", + ExplorerCommand::Up { remote }, + ) + .disabled(!navigable || !state.can_go_up), + ) + .child( + tool( + "root", + Icon::new(CatalogIcon::FolderRoot), + "根目录", + ExplorerCommand::Root { remote }, + ) + .disabled(!navigable || !state.can_go_up), + ) + .child( + tool( + "home", + Icon::new(CatalogIcon::House), + "主目录", + ExplorerCommand::Home { remote }, + ) + .disabled(!navigable || !state.can_go_home), + ) + .child( + tool( + "refresh", + Icon::new(CatalogIcon::RefreshCw), + "刷新", + ExplorerCommand::Refresh { remote }, + ) + .disabled(!navigable), + ) + .child(Separator::vertical()) + .child( + tool( + "back", + Icon::new(IconName::ArrowLeft), + "后退", + ExplorerCommand::Back { remote }, + ) + .disabled(!navigable || !state.can_go_back), + ) + .child( + tool( + "forward", + Icon::new(IconName::ArrowRight), + "前进", + ExplorerCommand::Forward { remote }, + ) + .disabled(!navigable || !state.can_go_forward), + ); + let transfer_shortcut = ExplorerShortcut(ExplorerCommand::Transfer { remote }); + let transfer = if remote { + let dispatch = self.dispatch.clone(); + Button::new("download") + .ghost() + .small() + .icon(Icon::new(CatalogIcon::Download)) + .label("下载…") + .tooltip_with_action("下载所选项目", &transfer_shortcut, Some(context)) + .disabled(selected == 0 || !state.can_transfer) + .on_click(move |_, window, cx| { + dispatch.dispatch_explorer_action( + &ExplorerAction::new(sid, ExplorerCommand::Transfer { remote }), + window, + cx, + ) + }) + .into_any_element() + } else { + let dispatch = self.dispatch.clone(); + DropdownButton::new("upload-menu") + .ghost() + .small() + .button( + Button::new("upload") + .icon(Icon::new(CatalogIcon::Upload)) + .label("上传…") + .tooltip_with_action("上传所选项目", &transfer_shortcut, Some(context)) + .disabled(selected == 0 || !state.can_transfer) + .on_click(move |_, window, cx| { + dispatch.dispatch_explorer_action( + &ExplorerAction::new(sid, ExplorerCommand::Transfer { remote }), + window, + cx, + ) + }), + ) + .disabled(!state.can_transfer) + .dropdown_menu(move |menu, _, _| { + menu.menu( + "选择文件上传…", + Box::new(ExplorerAction::new(sid, ExplorerCommand::ChooseFiles)), + ) + }) + .into_any_element() + }; + let operations = toolbar() + .child(transfer) + .child(Separator::vertical()) + .child( + tool( + "delete", + Icon::new(CatalogIcon::Trash), + "删除", + ExplorerCommand::Delete { remote }, + ) + .disabled(selected == 0 || !state.can_modify), + ) + .child( + tool( + "rename", + Icon::new(CatalogIcon::SquarePen), + "重命名…", + ExplorerCommand::Rename { remote }, + ) + .disabled(selected != 1 || !state.can_modify), + ) + .child( + tool( + "properties", + Icon::new(IconName::Info), + "属性…", + ExplorerCommand::Properties { remote }, + ) + .disabled(selected == 0 || !state.can_modify), + ) + .child(Separator::vertical()) + .child({ + let menu = state.clone(); + Button::new("new") + .ghost() + .small() + .icon(Icon::new(CatalogIcon::FolderPlus)) + .label("新建") + .dropdown_caret(true) + .disabled(!state.can_modify) + .dropdown_menu(move |popup, _, _| new_menu(popup, &menu)) + }); + let pane = cx.entity().downgrade(); + let menu_hit = self.menu_hit.clone(); v_flex() .id((self.side.pane_id(), sid.0)) .test_support() .size_full() .min_w_0() + .child(navigation) + .child(operations) .child( h_flex() - .gap_1() .px_2() .py_1() .border_b_1() .border_color(cx.theme().border) - .child( - div() - .w_10() - .flex_shrink_0() - .text_sm() - .font_weight(FontWeight::MEDIUM) - .child(self.side.label()), - ) .child( Input::new(&self.path_input) .id(self.side.path_id()) .small() .flex_1() .min_w_0(), - ) - .child( - button( - "up", - IconName::ArrowUp, - "上级目录", - ExplorerCommand::Up { remote }, - ) - .disabled(self.parent_path() == self.path), - ) - .child(button( - "refresh", - IconName::Redo, - "刷新", - ExplorerCommand::Refresh { remote }, - )), + ), ) - .when(!remote, |this| { - this.child( - h_flex() - .gap_2() - .px_2() - .py_1() - .border_b_1() - .border_color(cx.theme().border) - .child( - Button::new("upload") - .outline() - .small() - .icon(Icon::new(CatalogIcon::Upload)) - .label("上传所选…") - .tooltip("上传所选文件(F5)") - .disabled(!self.transfer_enabled || self.checked.is_empty()) - .on_click({ - let dispatch = dispatch.clone(); - move |_, w, cx| { - dispatch.dispatch_explorer_action( - &ExplorerAction::new( - sid, - ExplorerCommand::UploadSelected, - ), - w, - cx, - ) - } - }), - ) - .child( - Button::new("choose-upload") - .ghost() - .small() - .label("选择文件上传…") - .disabled(!self.transfer_enabled) - .on_click(move |_, w, cx| { - dispatch.dispatch_explorer_action( - &ExplorerAction::new(sid, ExplorerCommand::ChooseFiles), - w, - cx, - ) - }), - ), - ) - }) - .when(remote, |this| { - this.child( - h_flex() - .px_2() - .py_1() - .h_8() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child("拖入文件或目录以上传"), - ) - }) .when(self.loading, |this| { this.child(h_flex().px_2().py_1().text_sm().child("正在读取目录…")) }) @@ -577,12 +875,19 @@ impl Render for FilePane { }) .child( div() + .id(("file-list", u64::from(remote))) .flex_1() .min_h_0() - .key_context(if remote { - "RemoteFileList" - } else { - "LocalFileList" + .key_context(context) + // The row's right-click handler records the hit after + // this capture-phase reset, so empty space finds none. + .capture_any_mouse_down({ + let menu_hit = menu_hit.clone(); + move |event, _, _| { + if event.button == MouseButton::Right { + *menu_hit.borrow_mut() = None; + } + } }) .when(remote, |this| { let dispatch = self.dispatch.clone(); @@ -628,6 +933,45 @@ impl Render for FilePane { } }) }) + .when(!remote, |this| { + let dispatch = self.dispatch.clone(); + let path = self.path.clone(); + let enabled = self.transfer_enabled; + this.drag_over::(|style, _, _, cx| { + style.bg(cx.theme().muted) + }) + .on_drop( + move |drag: &super::file_listing::RemoteFilesDrag, window, cx| { + if enabled { + dispatch.dispatch_explorer_action( + &ExplorerAction::new( + sid, + ExplorerCommand::DownloadPaths { + paths: drag.paths.clone(), + target: path.clone(), + }, + ), + window, + cx, + ); + cx.stop_propagation(); + } + }, + ) + }) + // One menu for rows and empty space, on the container: + // see `SessionPanel` for why menus stay off virtual rows. + .context_menu(move |menu, window, cx| { + let Some(state) = pane.upgrade().map(|pane| pane.read(cx).menu_state(cx)) + else { + return menu; + }; + if menu_hit.borrow().is_some() { + item_menu(menu, &state) + } else { + directory_menu(menu, &state, window, cx) + } + }) .child( DataTable::new(&self.table) .stripe(false) @@ -643,10 +987,10 @@ impl Render for FilePane { .border_color(cx.theme().border) .text_xs() .text_color(cx.theme().muted_foreground) - .child(if remote { + .child(if selected == 0 { format!("{file_count} 个项目") } else { - format!("{file_count} 个项目 · 已选择 {} 项", self.checked.len()) + format!("{file_count} 个项目 · 已选择 {selected} 项") }), ) } diff --git a/src/explorer/history.rs b/src/explorer/history.rs new file mode 100644 index 0000000..587cd9d --- /dev/null +++ b/src/explorer/history.rs @@ -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, + forward: Vec, +} + +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, 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"); + } +} diff --git a/src/explorer/mod.rs b/src/explorer/mod.rs index 280e44f..681816b 100644 --- a/src/explorer/mod.rs +++ b/src/explorer/mod.rs @@ -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; diff --git a/src/explorer/model.rs b/src/explorer/model.rs index a10c261..31c937f 100644 --- a/src/explorer/model.rs +++ b/src/explorer/model.rs @@ -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 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, pub size: u64, - pub modified: SharedString, - pub permissions: SharedString, + /// Seconds since the Unix epoch. + pub modified: Option, + /// The raw mode, type bits included. + pub permissions: Option, + pub owner: Option, + pub group: Option, } 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()); } } diff --git a/src/explorer/pane_menu.rs b/src/explorer/pane_menu.rs new file mode 100644 index 0000000..b795d34 --- /dev/null +++ b/src/explorer/pane_menu.rs @@ -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, + /// 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, + /// 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 { + 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 { + 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(), + ) + } +} diff --git a/src/explorer/pane_operations.rs b/src/explorer/pane_operations.rs new file mode 100644 index 0000000..c35dbc0 --- /dev/null +++ b/src/explorer/pane_operations.rs @@ -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), + Rename { + from: String, + to: String, + }, + Create { + kind: NewEntryKind, + name: String, + }, + Permissions { + names: Vec, + 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, +} + +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 { + match self { + PaneOperation::Rename { to, .. } => Some(to.clone()), + PaneOperation::Create { name, .. } => Some(name.clone()), + _ => None, + } + } + + fn to_remote(&self, directory: &str) -> Result { + let directory = RemotePath::new(directory)?; + let paths = |names: &[String]| -> Result> { + 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 { + 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, + ) { + 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, + ) { + 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.operations.retain(|_, pending| !pending.remote); + self.remote.update(cx, |pane, cx| pane.set_busy(false, cx)); + } +} diff --git a/src/explorer/properties_dialog.rs b/src/explorer/properties_dialog.rs new file mode 100644 index 0000000..6b2f9cd --- /dev/null +++ b/src/explorer/properties_dialog.rs @@ -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 { + (text.len() == 3) + .then(|| u32::from_str_radix(text, 8).ok()) + .flatten() +} + +struct PropertiesForm { + items: Vec, + 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, + _subscription: Subscription, +} + +impl PropertiesForm { + fn new( + items: Vec, + location: String, + remote: bool, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let modes: Vec = 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>) -> 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) -> 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, + ) { + 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 = 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); + } +} diff --git a/src/explorer/selection.rs b/src/explorer/selection.rs new file mode 100644 index 0000000..b2dfb57 --- /dev/null +++ b/src/explorer/selection.rs @@ -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, + cursor: Option, + anchor: Option, +} + +impl Selection { + pub fn names(&self) -> &HashSet { + &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 { + 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 { + order + .iter() + .filter(|name| self.names.contains(**name)) + .map(|name| name.to_string()) + .collect() + } +} + +fn selectable(name: &str) -> Option { + (name != PARENT).then(|| name.to_string()) +} + +fn range(order: &[&str], from: &str, to: &str) -> HashSet { + 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 { + 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()); + } +} diff --git a/src/explorer/upload_dialog.rs b/src/explorer/transfer_dialog.rs similarity index 54% rename from src/explorer/upload_dialog.rs rename to src/explorer/transfer_dialog.rs index 0f3ab08..84ceb9e 100644 --- a/src/explorer/upload_dialog.rs +++ b/src/explorer/transfer_dialog.rs @@ -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, ) { 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, + target: String, + window: &mut Window, + cx: &mut Context, + ) { + 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) { 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, + target: Entity, + endpoint: String, + error: Option, +} +impl DownloadForm { + fn browse(&mut self, window: &mut Window, cx: &mut Context) { + 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) -> 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) -> 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({ diff --git a/src/session/database.rs b/src/session/database.rs index 3202bb9..ab21709 100644 --- a/src/session/database.rs +++ b/src/session/database.rs @@ -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, /// Sessions that have ever connected, most recently connected first. pub recent: Vec, + /// SFTP bookmarks in the order they were added. + pub bookmarks: Vec<(SessionId, BookmarkSide, String)>, } pub struct SessionDatabase { @@ -174,10 +192,30 @@ impl SessionDatabase { })? .collect::>>()?; + 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::>>()?; + 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) -> 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(); diff --git a/src/session/group_dialog.rs b/src/session/group_dialog.rs index 46eeae3..5160fc5 100644 --- a/src/session/group_dialog.rs +++ b/src/session/group_dialog.rs @@ -217,7 +217,7 @@ pub fn confirm_delete_group( if uploads > 0 { description = Some( format!( - "{}将停止 {uploads} 个上传批次并保留续传进度。", + "{}将停止 {uploads} 个传输批次并保留续传进度。", description.unwrap_or_default() ) .into(), diff --git a/src/session/model.rs b/src/session/model.rs index 451f35e..55022b6 100644 --- a/src/session/model.rs +++ b/src/session/model.rs @@ -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 { + 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. /// diff --git a/src/session/session_dialog.rs b/src/session/session_dialog.rs index e7f58a3..fbfa409 100644 --- a/src/session/session_dialog.rs +++ b/src/session/session_dialog.rs @@ -807,7 +807,7 @@ pub fn confirm_delete_session( alert.description(format!( "会一并关闭该会话已打开的终端和 SFTP 标签。{}", if uploads > 0 { - format!("将停止 {uploads} 个上传批次并保留续传进度。") + format!("将停止 {uploads} 个传输批次并保留续传进度。") } else { String::new() } diff --git a/src/session/store.rs b/src/session/store.rs index 4253e7a..0be8683 100644 --- a/src/session/store.rs +++ b/src/session/store.rs @@ -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, /// Sessions in the order they last connected, most recent first. recent: Vec, + /// SFTP bookmarks per session and pane, in the order they were added. + bookmarks: HashMap<(SessionId, BookmarkSide), Vec>, /// `None` for a memory-only store, as used by tests. database: Option, /// 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> = 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, + ) -> 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, + ) -> 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(); diff --git a/src/sftp/client.rs b/src/sftp/client.rs index 9a4620f..9f9acfe 100644 --- a/src/sftp/client.rs +++ b/src/sftp/client.rs @@ -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>; + /// `stat`: follows links. `None` when the path or the link target is missing. + async fn stat(&self, path: &RemotePath) -> Result>; + /// The entries of a directory without `.` and `..`, kinds as `lstat` reports them. + async fn read_dir(&self, path: &RemotePath) -> Result>; async fn open(&self, path: &RemotePath, create: bool) -> Result; + /// Open an existing file for reading. + async fn open_read(&self, path: &RemotePath) -> Result; + /// 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>>; async fn sync(&self, handle: &str) -> Result<()>; async fn write(&self, handle: &str, offset: u64, bytes: Vec) -> Result<()>; async fn close(&self, handle: &str) -> Result<()>; @@ -25,6 +35,10 @@ pub(crate) trait RemoteFs { async fn readlink(&self, path: &RemotePath) -> Result; 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 { 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::>() + .await + .into_iter() + .collect::>>()?; + Ok(DirectoryListing::new(path.as_str(), entries)) + } +} +impl RemoteFs for SftpClient { + async fn metadata(&self, path: &RemotePath) -> Result> { + 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> { + 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> { let handle = self.raw.opendir(path.as_str()).await?.handle; - let result: Result = async { + let result: Result> = 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> { - 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 { let flags = if create { @@ -164,6 +226,20 @@ impl RemoteFs for SftpClient { .await? .handle) } + async fn open_read(&self, path: &RemotePath) -> Result { + Ok(self + .raw + .open(path.as_str(), OpenFlags::READ, FileAttributes::empty()) + .await? + .handle) + } + async fn read(&self, handle: &str, offset: u64, len: u32) -> Result>> { + 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::().ok()?; + let owner = fields.next()?; + let group = fields.next()?; + fields.next()?.parse::().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 { @@ -320,3 +435,31 @@ fn checked_text(value: &str) -> Result { } 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 + ); + } +} diff --git a/src/sftp/control.rs b/src/sftp/control.rs index 58f8288..ee051a7 100644 --- a/src/sftp/control.rs +++ b/src/sftp/control.rs @@ -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, pub cancel: watch::Receiver, - 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 { + ) -> Result { 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>> = std::sync::Mutex::new(None); +impl TargetGuard { + pub fn acquire(key: String, busy: &str) -> Result { + 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); + } + } +} diff --git a/src/sftp/download.rs b/src/sftp/download.rs new file mode 100644 index 0000000..0c1a57c --- /dev/null +++ b/src/sftp/download.rs @@ -0,0 +1,658 @@ +//! Downloading remote files and trees, the mirror of `upload`: each file is +//! written to `.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, +} + +pub(crate) struct DownloadBatch { + items: Vec, + cursor: usize, + pub progress: TransferProgress, + endpoint: String, + host_key: String, + journal: DownloadJournal, + all_conflicts: Option, + approved_resumes: HashSet, + blocked_directories: Vec, + 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( + request: &DownloadRequest, + endpoint: &str, + host_key: &str, + journal: DownloadJournal, + fs: &F, + control: &TransferControl, + ) -> Result { + 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 = 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::() => + { + 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::() => + { + 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(&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::() || 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 { + 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( + &mut self, + fs: &F, + item: &DownloadItem, + control: &TransferControl, + ) -> Result { + 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( + &mut self, + fs: &F, + item: &DownloadItem, + existing: Option, + control: &TransferControl, + ) -> Result { + 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( + &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> = 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> { + 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!("此系统不支持创建符号链接") +} diff --git a/src/sftp/journal.rs b/src/sftp/journal.rs index e92043a..41863a7 100644 --- a/src/sftp/journal.rs +++ b/src/sftp/journal.rs @@ -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(()) + } +} + +/// `.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> { + 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()), + } + } +} diff --git a/src/sftp/mod.rs b/src/sftp/mod.rs index 26cc0da..9a13034 100644 --- a/src/sftp/mod.rs +++ b/src/sftp/mod.rs @@ -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; diff --git a/src/sftp/model.rs b/src/sftp/model.rs index 08d186e..db1e29e 100644 --- a/src/sftp/model.rs +++ b/src/sftp/model.rs @@ -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, + group: Option, + target_kind: Option, } impl DirectoryEntry { pub fn new(name: impl Into, metadata: FileMetadata) -> Self { Self { name: name.into(), metadata, + owner: None, + group: None, + target_kind: None, } } + pub fn with_owner(mut self, owner: Option, group: Option) -> 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) -> 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 { + 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, + destination: PathBuf, +} +impl DownloadRequest { + pub fn new(sources: Vec, destination: PathBuf) -> Result { + 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, } -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, message: impl Into, ) -> 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; + /// 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; #[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 { 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 = 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::>>() + .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 diff --git a/src/sftp/operations.rs b/src/sftp/operations.rs new file mode 100644 index 0000000..2270be9 --- /dev/null +++ b/src/sftp/operations.rs @@ -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(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 = 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(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()) +} diff --git a/src/sftp/protocol_tests.rs b/src/sftp/protocol_tests.rs index 0c893f3..a20b47c 100644 --- a/src/sftp/protocol_tests.rs +++ b/src/sftp/protocol_tests.rs @@ -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 = (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()); + }); +} diff --git a/src/sftp/tests.rs b/src/sftp/tests.rs index 3b4b235..3346515 100644 --- a/src/sftp/tests.rs +++ b/src/sftp/tests.rs @@ -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, max_in_flight: Cell, writes: Cell, + reads: Cell, + short_reads: Cell, renames: Cell, atomic: bool, denied: RefCell>, @@ -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> { + 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> { + 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 { 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 { + 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>> { + 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) -> 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, - questions: Arc>>, - progress: Arc>>, + questions: Arc>>, + progress: Arc>>, task: tokio::task::JoinHandle<()>, } impl Answers { - fn new(choices: Vec) -> Self { + fn new(choices: Vec) -> 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 = (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 = (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::()); + 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 = (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(); + }); +} diff --git a/src/sftp/transport.rs b/src/sftp/transport.rs index c4ae0bc..c475c96 100644 --- a/src/sftp/transport.rs +++ b/src/sftp/transport.rs @@ -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, + }, + /// 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, + 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, }, - Progress(UploadProgress), - Question(UploadQuestion), + Operated { + request_id: u64, + result: Result<(), String>, + }, + Progress(TransferProgress), + Question(TransferQuestion), Notice(String), Idle, } diff --git a/src/sftp/upload.rs b/src/sftp/upload.rs index bceb574..97adb54 100644 --- a/src/sftp/upload.rs +++ b/src/sftp/upload.rs @@ -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, cursor: usize, - pub progress: UploadProgress, + pub progress: TransferProgress, endpoint: String, host_key: String, journal: Journal, - all_conflicts: Option, + all_conflicts: Option, approved_resumes: HashSet, blocked_directories: Vec, started: Instant, @@ -45,7 +45,7 @@ impl UploadBatch { endpoint: &str, host_key: &str, journal: Journal, - control: &UploadControl, + control: &TransferControl, ) -> Result { 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(&mut self, fs: &F, control: &UploadControl) -> Result<()> { - self.phase(UploadPhase::Uploading, control); + pub async fn run(&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, changed: bool, - control: &UploadControl, + control: &TransferControl, ) -> Result { 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( &mut self, fs: &F, item: &UploadItem, - control: &UploadControl, + control: &TransferControl, ) -> Result { 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::() { - 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 { 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 { 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>> = std::sync::Mutex::new(None); -impl TargetGuard { - fn acquire(endpoint: &str, target: &RemotePath) -> Result { - 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); - } - } -} diff --git a/src/sftp/worker.rs b/src/sftp/worker.rs index 4978ec2..bd84d4c 100644 --- a/src/sftp/worker.rs +++ b/src/sftp/worker.rs @@ -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 = None; + let mut batch: Option = 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::() { 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>>, + connected: &Arc, + events: &Sender, + 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; + } +} diff --git a/src/workspace/workspace_view.rs b/src/workspace/workspace_view.rs index cfdeeb2..779dd0e 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -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, ) { - 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.explorer_shortcut(ExplorerCommand::UploadSelected, window, cx); - } - fn on_toggle_upload_selection( - &mut self, - _: &ToggleUploadSelection, - window: &mut Window, - cx: &mut Context, - ) { - self.explorer_shortcut(ExplorerCommand::ToggleSelection, window, cx); - } - fn on_select_all_upload_files( - &mut self, - _: &SelectAllUploadFiles, - window: &mut Window, - cx: &mut Context, - ) { - 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)) diff --git a/tests/ui.rs b/tests/ui.rs index fdffa67..55e21ce 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -81,7 +81,7 @@ fn open_workspace_with_tester( remote, Arc::new(FakeTerminalFactory::default()), Arc::new(FakeSftpProvider::default()), - Arc::new(FakeLocalDirectory), + Arc::new(FakeLocalDirectory::default()), tester.clone(), window, cx, @@ -333,7 +333,7 @@ fn open_workspace_with_factory( remote, factory.clone(), Arc::new(FakeSftpProvider::default()), - Arc::new(FakeLocalDirectory), + Arc::new(FakeLocalDirectory::default()), Arc::new(FakeConnectionTester::default()), window, cx, @@ -361,7 +361,7 @@ fn open_workspace_with_remote_factory( remote, Arc::new(FakeTerminalFactory::default()), Arc::new(FakeSftpProvider::default()), - Arc::new(FakeLocalDirectory), + Arc::new(FakeLocalDirectory::default()), Arc::new(FakeConnectionTester::default()), window, cx, @@ -3324,18 +3324,24 @@ async fn the_start_page_marks_recent_hosts_with_their_operating_system(cx: &mut #[derive(Default)] struct FakeSftpProvider { requests: Arc>>, + downloads: Arc>>, + operations: Arc>>, events: Arc>>>, } impl SftpTransportProvider for FakeSftpProvider { fn create(&self, _: &shellr::session::Session) -> Box { Box::new(FakeSftpTransport { requests: self.requests.clone(), + downloads: self.downloads.clone(), + operations: self.operations.clone(), events: self.events.clone(), }) } } struct FakeSftpTransport { requests: Arc>>, + downloads: Arc>>, + operations: Arc>>, events: Arc>>>, } impl SftpTransport for FakeSftpTransport { @@ -3344,7 +3350,7 @@ impl SftpTransport for FakeSftpTransport { commands: async_channel::Receiver, events: async_channel::Sender, ) -> anyhow::Result<()> { - use shellr::sftp::{UploadChoice, UploadPhase, UploadProgress}; + use shellr::sftp::{TransferChoice, TransferPhase, TransferProgress}; self.events.lock().unwrap().push(events.clone()); events.send_blocking(SftpEvent::Connected { home: RemotePath::new("/home/tester")?, @@ -3362,26 +3368,48 @@ impl SftpTransport for FakeSftpTransport { }; events.send_blocking(SftpEvent::Listed { request_id, result })?; } + SftpCommand::Operate { + request_id, + operation, + } => { + let refused = format!("{operation:?}").contains("denied"); + self.operations.lock().unwrap().push(operation); + events.send_blocking(SftpEvent::Operated { + request_id, + result: if refused { + Err("权限不足".into()) + } else { + Ok(()) + }, + })?; + } + SftpCommand::Download(request) => { + self.downloads.lock().unwrap().push(request); + events.send_blocking(SftpEvent::Progress( + TransferProgress::new(TransferPhase::Transferring) + .with_direction(shellr::sftp::TransferDirection::Download), + ))?; + } SftpCommand::Upload(request) => { self.requests.lock().unwrap().push(request); events.send_blocking(SftpEvent::Progress(Default::default()))?; } SftpCommand::Cancel => { - events.send_blocking(SftpEvent::Progress(UploadProgress::new( - UploadPhase::Stopped, + events.send_blocking(SftpEvent::Progress(TransferProgress::new( + TransferPhase::Stopped, )))?; } SftpCommand::Resume => { - events.send_blocking(SftpEvent::Progress(UploadProgress::new( - UploadPhase::Uploading, + events.send_blocking(SftpEvent::Progress(TransferProgress::new( + TransferPhase::Transferring, )))?; } SftpCommand::Answer { answer, .. } => { - events.send_blocking(SftpEvent::Progress(UploadProgress::new( - if answer.choice() == UploadChoice::Cancel { - UploadPhase::Stopped + events.send_blocking(SftpEvent::Progress(TransferProgress::new( + if answer.choice() == TransferChoice::Cancel { + TransferPhase::Stopped } else { - UploadPhase::Completed + TransferPhase::Completed }, )))?; } @@ -3392,7 +3420,18 @@ impl SftpTransport for FakeSftpTransport { Ok(()) } } -struct FakeLocalDirectory; +/// The local file system of the tests: every directory lists +/// `fake_listing`, and changes are recorded, never made. +#[derive(Clone, Default)] +struct FakeLocalDirectory { + calls: Arc>>, +} +impl FakeLocalDirectory { + fn record(&self, call: String) -> anyhow::Result<()> { + self.calls.lock().unwrap().push(call); + Ok(()) + } +} impl LocalDirectoryProvider for FakeLocalDirectory { fn home(&self) -> std::path::PathBuf { "/local/tester".into() @@ -3400,6 +3439,31 @@ impl LocalDirectoryProvider for FakeLocalDirectory { fn list(&self, path: &std::path::Path) -> anyhow::Result { Ok(fake_listing(path.to_str().unwrap())) } + fn trash(&self, paths: &[std::path::PathBuf]) -> anyhow::Result<()> { + self.record(format!("trash {paths:?}")) + } + fn rename(&self, from: &std::path::Path, to: &std::path::Path) -> anyhow::Result<()> { + self.record(format!("rename {} -> {}", from.display(), to.display())) + } + fn create_dir(&self, path: &std::path::Path) -> anyhow::Result<()> { + self.record(format!("mkdir {}", path.display())) + } + fn create_file(&self, path: &std::path::Path) -> anyhow::Result<()> { + self.record(format!("touch {}", path.display())) + } + fn set_permissions( + &self, + paths: &[std::path::PathBuf], + edit: shellr::sftp::PermissionEdit, + recursive: bool, + add_x_to_dirs: bool, + ) -> anyhow::Result<()> { + self.record(format!( + "chmod {paths:?} +{:o} -{:o} recursive={recursive} x={add_x_to_dirs}", + edit.set(), + edit.clear() + )) + } } fn fake_listing(path: &str) -> DirectoryListing { DirectoryListing::new( @@ -3417,15 +3481,33 @@ fn fake_listing(path: &str) -> DirectoryListing { "目录", FileMetadata::new(EntryKind::Directory, 0, None, Some(0o755)), ), - ], + DirectoryEntry::new( + "链接目录", + FileMetadata::new(EntryKind::Symlink, 7, Some(300), Some(0o120_777)), + ) + .with_target_kind(Some(EntryKind::Directory)), + ] + .into_iter() + .map(|entry| entry.with_owner(Some("root".into()), Some("wheel".into()))) + .collect(), ) } fn open_workspace_with_sftp( cx: &mut TestAppContext, provider: Arc, +) -> (WindowHandle, Entity) { + open_workspace_with_services(cx, provider, FakeLocalDirectory::default()) +} +fn open_workspace_with_services( + cx: &mut TestAppContext, + provider: Arc, + local: FakeLocalDirectory, ) -> (WindowHandle, Entity) { cx.update(shellr::init); + // Dialogs slide in over real time; small targets such as checkboxes + // would move between the frame that locates them and the click. + cx.update(|cx| cx.set_reduce_motion(true)); let mut workspace = None; let handle = cx.open_window(size(px(1280.), px(800.)), |window, cx| { let store = cx.new(|_| SessionStore::seed()); @@ -3438,7 +3520,7 @@ fn open_workspace_with_sftp( remote, Arc::new(FakeTerminalFactory::default()), provider, - Arc::new(FakeLocalDirectory), + Arc::new(local), Arc::new(FakeConnectionTester::default()), window, cx, @@ -3468,6 +3550,63 @@ async fn open_test_explorer(cx: &mut TestAppContext, handle: WindowHandle) .await; } +/// `TestWindowExt::click` sends no modifiers, and file lists need ⌘ and +/// Shift clicks. +fn modified_click( + window: &mut gpui_kit::Window, + pane: (&'static str, u64), + row: &str, + modifiers: gpui_kit::Modifiers, + cx: &mut App, +) { + use gpui_kit::{MouseDownEvent, MouseUpEvent}; + let row = ElementId::Name(row.to_string().into()); + let position = window.within(pane).find(row).bounds().center(); + window.dispatch_event( + MouseMoveEvent { + position, + pressed_button: None, + modifiers, + } + .to_platform_input(), + cx, + ); + window.render_frame(cx); + window.dispatch_event( + MouseDownEvent { + button: MouseButton::Left, + position, + modifiers, + click_count: 1, + first_mouse: false, + } + .to_platform_input(), + cx, + ); + window.dispatch_event( + MouseUpEvent { + button: MouseButton::Left, + position, + modifiers, + click_count: 1, + } + .to_platform_input(), + cx, + ); +} + +/// The selected names of one explorer pane, in display order. +fn pane_selection(workspace: &Entity, remote: bool, cx: &App) -> Vec { + workspace + .read(cx) + .explorer(SessionId(DB_01)) + .unwrap() + .read(cx) + .pane(remote) + .read(cx) + .selected_names(cx) +} + #[gpui_kit::test] async fn sftp_multi_selection_keyboard_upload_freezes_paths_and_cancel_resumes( cx: &mut TestAppContext, @@ -3476,32 +3615,28 @@ async fn sftp_multi_selection_keyboard_upload_freezes_paths_and_cancel_resumes( let provider = Arc::new(FakeSftpProvider::default()); let (handle, workspace) = open_workspace_with_sftp(cx, provider.clone()); open_test_explorer(cx, handle).await; + // Names sort by code point: 文件 乙 comes before 文件 甲. cx.update_window(handle.into(), |_, window, cx| { window .within(("local-pane", DB_01)) - .click("file:文件 甲.txt", cx); + .click("file:文件 乙.txt", cx); }) .unwrap(); cx.run_until_parked(); - cx.update_window(handle.into(), |_, window, cx| { - window.render_frame(cx); - window.press("space", cx); - }) - .unwrap(); - cx.run_until_parked(); - cx.update(|cx| { - assert_eq!( - workspace - .read(cx) - .explorer(SessionId(DB_01)) - .unwrap() - .read(cx) - .local() - .read(cx) - .upload_sources(), - vec![std::path::PathBuf::from("/local/tester/文件 甲.txt")] - ); - }); + cx.update(|cx| assert_eq!(pane_selection(&workspace, false, cx), ["文件 乙.txt"])); + // Shift+↓ extends to the next row; Space takes the cursor row back out. + for (key, expected) in [ + ("shift-down", &["文件 乙.txt", "文件 甲.txt"][..]), + ("space", &["文件 乙.txt"][..]), + ] { + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.press(key, cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update(|cx| assert_eq!(pane_selection(&workspace, false, cx), expected)); + } cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); window.press("cmd-a", cx); @@ -3517,9 +3652,9 @@ async fn sftp_multi_selection_keyboard_upload_freezes_paths_and_cancel_resumes( .read(cx) .local() .read(cx) - .upload_sources() + .upload_sources(cx) .len(), - 3 + 4 ); }); cx.update_window(handle.into(), |_, window, cx| { @@ -3539,14 +3674,14 @@ async fn sftp_multi_selection_keyboard_upload_freezes_paths_and_cancel_resumes( .unwrap(); cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { window.render_frame(cx); - window.try_find("cancel-upload").is_some() + window.try_find("cancel-transfer").is_some() }) .await; assert_eq!( provider.requests.lock().unwrap()[0].destination().as_str(), "/固定目标" ); - assert_eq!(provider.requests.lock().unwrap()[0].sources().len(), 3); + assert_eq!(provider.requests.lock().unwrap()[0].sources().len(), 4); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); window.dispatch_action( @@ -3559,22 +3694,22 @@ async fn sftp_multi_selection_keyboard_upload_freezes_paths_and_cancel_resumes( )), cx, ); - window.click("cancel-upload", cx); + window.click("cancel-transfer", cx); }) .unwrap(); cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { window.render_frame(cx); - window.try_find("resume-upload").is_some() + window.try_find("resume-transfer").is_some() }) .await; cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.click("resume-upload", cx); + window.click("resume-transfer", cx); }) .unwrap(); cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { window.render_frame(cx); - window.try_find("cancel-upload").is_some() + window.try_find("cancel-transfer").is_some() }) .await; assert_eq!(provider.requests.lock().unwrap().len(), 1); @@ -3589,9 +3724,17 @@ async fn sftp_native_picker_and_external_drop_share_confirmation(cx: &mut TestAp let provider = Arc::new(FakeSftpProvider::default()); let (handle, _) = open_workspace_with_sftp(cx, provider); open_test_explorer(cx, handle).await; + // 「选择文件上传…」 sits in the upload button's menu, which the tests do + // not open; dispatch what the item dispatches. cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.click("choose-upload", cx); + window.dispatch_action( + Box::new(shellr::app::ExplorerAction::new( + SessionId(DB_01), + shellr::app::ExplorerCommand::ChooseFiles, + )), + cx, + ); }) .unwrap(); cx.run_until_parked(); @@ -3644,7 +3787,7 @@ async fn sftp_native_picker_and_external_drop_share_confirmation(cx: &mut TestAp #[gpui_kit::test] async fn sftp_internal_drag_conflict_and_close_confirmation(cx: &mut TestAppContext) { - use shellr::sftp::{UploadQuestion, UploadQuestionKind}; + use shellr::sftp::{TransferQuestion, TransferQuestionKind}; let provider = Arc::new(FakeSftpProvider::default()); let (handle, workspace) = open_workspace_with_sftp(cx, provider.clone()); open_test_explorer(cx, handle).await; @@ -3675,41 +3818,41 @@ async fn sftp_internal_drag_conflict_and_close_confirmation(cx: &mut TestAppCont .unwrap(); cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { window.render_frame(cx); - window.try_find("cancel-upload").is_some() + window.try_find("cancel-transfer").is_some() }) .await; provider.events.lock().unwrap()[0] - .send_blocking(SftpEvent::Question(UploadQuestion::new( + .send_blocking(SftpEvent::Question(TransferQuestion::new( 900, - UploadQuestionKind::Conflict, + TransferQuestionKind::Conflict, "/home/tester/目录/文件 甲.txt", "目标已存在", ))) .unwrap(); cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { window.render_frame(cx); - window.try_find("upload-question-confirm").is_some() + window.try_find("transfer-question-confirm").is_some() }) .await; cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.click("upload-apply-all", cx); - window.click("upload-question-cancel", cx); + window.click("transfer-apply-all", cx); + window.click("transfer-question-cancel", cx); }) .unwrap(); cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { window.render_frame(cx); - window.try_find("resume-upload").is_some() + window.try_find("resume-transfer").is_some() }) .await; cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.click("resume-upload", cx); + window.click("resume-transfer", cx); }) .unwrap(); cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { window.render_frame(cx); - window.try_find("cancel-upload").is_some() + window.try_find("cancel-transfer").is_some() }) .await; cx.update_window(handle.into(), |_, window, cx| { @@ -3733,7 +3876,6 @@ async fn sftp_internal_drag_conflict_and_close_confirmation(cx: &mut TestAppCont async fn sftp_sort_range_selection_and_dialog_focus_preserve_path_identity( cx: &mut TestAppContext, ) { - use shellr::app::{ExplorerAction, ExplorerCommand}; let provider = Arc::new(FakeSftpProvider::default()); let (handle, workspace) = open_workspace_with_sftp(cx, provider); open_test_explorer(cx, handle).await; @@ -3741,26 +3883,22 @@ async fn sftp_sort_range_selection_and_dialog_focus_preserve_path_identity( window.render_frame(cx); window .within(("local-pane", DB_01)) - .click("check:文件 乙.txt", cx); - }) - .unwrap(); - cx.run_until_parked(); - cx.update_window(handle.into(), |_, window, cx| { - window.render_frame(cx); - window - .within(("local-pane", DB_01)) - .click(("col-header", 1usize), cx); - }) - .unwrap(); - cx.run_until_parked(); - cx.update_window(handle.into(), |_, window, cx| { - window.render_frame(cx); - window - .within(("local-pane", DB_01)) - .click(("col-header", 1usize), cx); + .click("file:文件 乙.txt", cx); }) .unwrap(); cx.run_until_parked(); + // Sorting twice (descending, then ascending by name) keeps the selection + // by name, not by row position. + for _ in 0..2 { + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window + .within(("local-pane", DB_01)) + .click(("col-header", 0usize), cx); + }) + .unwrap(); + cx.run_until_parked(); + } cx.update(|cx| { assert_eq!( workspace @@ -3770,40 +3908,68 @@ async fn sftp_sort_range_selection_and_dialog_focus_preserve_path_identity( .read(cx) .local() .read(cx) - .upload_sources(), + .upload_sources(cx), vec![std::path::PathBuf::from("/local/tester/文件 乙.txt")] ) }); + // ⌘-click adds a row; Shift-click selects the range from the anchor. cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - window.dispatch_action( - Box::new(ExplorerAction::new( - SessionId(DB_01), - ExplorerCommand::Check { - name: "文件 甲.txt".into(), - checked: true, - extend: true, - }, - )), + modified_click( + window, + ("local-pane", DB_01), + "file:目录", + gpui_kit::Modifiers::secondary_key(), cx, ); }) .unwrap(); cx.run_until_parked(); cx.update(|cx| { - assert_eq!( - workspace - .read(cx) - .explorer(SessionId(DB_01)) - .unwrap() - .read(cx) - .local() - .read(cx) - .upload_sources() - .len(), - 2 - ) + let mut selected = pane_selection(&workspace, false, cx); + selected.sort(); + assert_eq!(selected, ["文件 乙.txt", "目录"]); }); + // 目录 is the anchor and sorts first; Shift-click on the last row takes + // every row whatever the name order is. + let last = cx.update(|cx| { + workspace + .read(cx) + .explorer(SessionId(DB_01)) + .unwrap() + .read(cx) + .local() + .read(cx) + .entries(cx) + .last() + .unwrap() + .name + .to_string() + }); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + modified_click( + window, + ("local-pane", DB_01), + &format!("file:{last}"), + gpui_kit::Modifiers::shift(), + cx, + ); + }) + .unwrap(); + cx.run_until_parked(); + cx.update(|cx| assert_eq!(pane_selection(&workspace, false, cx).len(), 4)); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert_eq!( + window + .within(("local-pane", DB_01)) + .find("name:文件 甲.txt") + .selected(), + Some(true) + ); + }) + .unwrap(); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); window @@ -3834,6 +4000,457 @@ async fn sftp_sort_range_selection_and_dialog_focus_preserve_path_identity( .unwrap(); } +#[gpui_kit::test] +async fn sftp_panes_list_winscp_columns_and_open_links_to_directories(cx: &mut TestAppContext) { + use shellr::explorer::FileKind; + let provider = Arc::new(FakeSftpProvider::default()); + let (handle, workspace) = open_workspace_with_sftp(cx, provider); + open_test_explorer(cx, handle).await; + cx.update(|cx| { + let explorer = workspace + .read(cx) + .explorer(SessionId(DB_01)) + .unwrap() + .read(cx); + assert_eq!( + explorer.local().read(cx).column_names(cx), + ["名称", "大小", "类型", "修改时间"] + ); + assert_eq!( + explorer.remote().read(cx).column_names(cx), + ["名称", "大小", "修改时间", "权限", "所有者"] + ); + let rows = explorer.remote().read(cx).entries(cx); + let names: Vec<_> = rows.iter().map(|row| row.name.to_string()).collect(); + assert_eq!( + names, + ["..", "目录", "链接目录", "文件 乙.txt", "文件 甲.txt"] + ); + assert_eq!(rows[2].target, Some(FileKind::Dir)); + assert_eq!(rows[3].owner.as_deref(), Some("root")); + }); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window + .within(("remote-pane", DB_01)) + .double_click("file:链接目录", cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.find("remote-path").value() == Some("~/链接目录") + }) + .await; +} + +/// Whether a button accepts input. gpui-base does not report `disabled` for +/// buttons, but a disabled one also leaves the focus order. +fn enabled(button: &gpui_kit::test::ElementSnapshot) -> bool { + button.focused().is_some() +} + +/// Click a toolbar button of the remote pane and wait for the path it lands on. +async fn click_remote_tool( + cx: &mut TestAppContext, + handle: WindowHandle, + button: &'static str, + expected: &str, +) { + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.within(("remote-pane", DB_01)).click(button, cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.find("remote-path").value() == Some(expected) + }) + .await; +} + +#[gpui_kit::test] +async fn sftp_toolbar_goes_up_root_home_back_and_forward(cx: &mut TestAppContext) { + use shellr::app::{ExplorerAction, ExplorerCommand}; + use shellr::session::BookmarkSide; + let provider = Arc::new(FakeSftpProvider::default()); + let (handle, workspace) = open_workspace_with_sftp(cx, provider); + open_test_explorer(cx, handle).await; + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + let remote = window.within(("remote-pane", DB_01)); + assert!(!enabled(&remote.find("back"))); + assert!(!enabled(&remote.find("home")), "already home"); + assert!(enabled(&remote.find("up"))); + }) + .unwrap(); + click_remote_tool(cx, handle, "up", "/home").await; + click_remote_tool(cx, handle, "root", "/").await; + click_remote_tool(cx, handle, "back", "/home").await; + click_remote_tool(cx, handle, "back", "~").await; + click_remote_tool(cx, handle, "forward", "/home").await; + click_remote_tool(cx, handle, "home", "~").await; + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + let remote = window.within(("remote-pane", DB_01)); + assert!(!enabled(&remote.find("forward")), "a visit drops forward"); + assert!(enabled(&remote.find("back"))); + }) + .unwrap(); + // A failed load leaves both the path and the history alone. + cx.update_window(handle.into(), |_, window, cx| { + window.dispatch_action( + Box::new(ExplorerAction::new( + SessionId(DB_01), + ExplorerCommand::Navigate { + remote: true, + path: "/denied".into(), + }, + )), + cx, + ); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.try_find("directory-error").is_some() + }) + .await; + cx.update(|cx| { + let explorer = workspace + .read(cx) + .explorer(SessionId(DB_01)) + .unwrap() + .read(cx); + let remote = explorer.remote().read(cx); + assert_eq!(remote.path(), "/home/tester"); + assert_eq!(remote.back_target().as_deref(), Some("/home")); + }); + + // Bookmarks belong to the session and the pane. + cx.update_window(handle.into(), |_, window, cx| { + window.dispatch_action( + Box::new(ExplorerAction::new( + SessionId(DB_01), + ExplorerCommand::AddBookmark { remote: true }, + )), + cx, + ); + }) + .unwrap(); + cx.run_until_parked(); + cx.update(|cx| { + let store = workspace.read(cx).store().read(cx); + assert_eq!( + store.bookmarks(SessionId(DB_01), BookmarkSide::Remote), + ["/home/tester"] + ); + assert!( + store + .bookmarks(SessionId(DB_01), BookmarkSide::Local) + .is_empty() + ); + }); + cx.update_window(handle.into(), |_, window, cx| { + window.dispatch_action( + Box::new(ExplorerAction::new( + SessionId(DB_01), + ExplorerCommand::RemoveBookmark { + remote: true, + path: "/home/tester".into(), + }, + )), + cx, + ); + }) + .unwrap(); + cx.run_until_parked(); + cx.update(|cx| { + assert!( + workspace + .read(cx) + .store() + .read(cx) + .bookmarks(SessionId(DB_01), BookmarkSide::Remote) + .is_empty() + ) + }); +} + +/// Click a row in one pane, then press a key with the list focused. +fn press_on_row( + cx: &mut TestAppContext, + handle: WindowHandle, + pane: &'static str, + row: &str, + key: &str, +) { + let row = ElementId::Name(format!("file:{row}").into()); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.within((pane, DB_01)).click(row, cx); + window.press(key, cx); + }) + .unwrap(); + cx.run_until_parked(); +} + +#[gpui_kit::test] +async fn sftp_file_commands_confirm_validate_and_send_one_operation(cx: &mut TestAppContext) { + use shellr::app::{ExplorerAction, ExplorerCommand}; + use shellr::explorer::NewEntryKind; + use shellr::sftp::{PermissionEdit, RemoteOperation}; + let provider = Arc::new(FakeSftpProvider::default()); + let local = FakeLocalDirectory::default(); + let (handle, workspace) = open_workspace_with_services(cx, provider.clone(), local.clone()); + open_test_explorer(cx, handle).await; + let path = |name: &str| RemotePath::new(format!("/home/tester/{name}")).unwrap(); + let last_operation = || provider.operations.lock().unwrap().last().cloned(); + // A pane takes no new file command until the last one answers. + let idle = |cx: &App| { + let explorer = workspace + .read(cx) + .explorer(SessionId(DB_01)) + .unwrap() + .read(cx); + !explorer.remote().read(cx).is_busy() && !explorer.local().read(cx).is_busy() + }; + + // 删除 asks first, naming the item; remote deletes are permanent. + press_on_row(cx, handle, "remote-pane", "文件 甲.txt", "f8"); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click("ok", cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| { + last_operation().is_some() && idle(cx) + }) + .await; + assert_eq!( + last_operation(), + Some(RemoteOperation::Delete { + paths: vec![path("文件 甲.txt")] + }) + ); + + // Local deletes go to the Trash through the injected provider. + press_on_row(cx, handle, "local-pane", "文件 乙.txt", "delete"); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click("ok", cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| { + !local.calls.lock().unwrap().is_empty() && idle(cx) + }) + .await; + assert_eq!( + local.calls.lock().unwrap()[0], + r#"trash ["/local/tester/文件 乙.txt"]"# + ); + + // 重命名 checks the name against the listing before sending anything. + press_on_row(cx, handle, "remote-pane", "文件 乙.txt", "f2"); + for (typed, error) in [ + (None, "名称未改变"), + (Some("目录"), "已有名为「目录」的项目"), + (Some(""), "名称不能为空"), + ] { + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + if let Some(typed) = typed { + window.click("entry-name", cx); + window.press("cmd-a", cx); + window.press("backspace", cx); + window.input(typed, cx); + } + window.click("commit", cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert_eq!(window.find("form-error").label(), None); + assert!(window.find("form-error").visible(), "{error}"); + }) + .unwrap(); + } + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.click("entry-name", cx); + window.press("cmd-a", cx); + window.input("新名字.txt", cx); + window.click("commit", cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| { + matches!(last_operation(), Some(RemoteOperation::Rename { .. })) && idle(cx) + }) + .await; + assert_eq!( + last_operation(), + Some(RemoteOperation::Rename { + from: path("文件 乙.txt"), + to: path("新名字.txt") + }) + ); + + // 新建 › 文件夹… offers a default name. A closed dialog leaves no focus + // for `dispatch_action`, so click into the list first. + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + window.within(("remote-pane", DB_01)).click("file:目录", cx); + window.dispatch_action( + Box::new(ExplorerAction::new( + SessionId(DB_01), + ExplorerCommand::New { + remote: true, + kind: NewEntryKind::Folder, + }, + )), + cx, + ); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert_eq!(window.find("entry-name").value(), Some("新建文件夹")); + window.click("commit", cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| { + matches!( + last_operation(), + Some(RemoteOperation::CreateDirectory { .. }) + ) && idle(cx) + }) + .await; + assert_eq!( + last_operation(), + Some(RemoteOperation::CreateDirectory { + path: path("新建文件夹") + }) + ); + + // 属性: the grid and the octal field agree, and only the changed bit goes out. + press_on_row(cx, handle, "remote-pane", "文件 甲.txt", "f9"); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert_eq!(window.find("perm-octal").value(), Some("644")); + assert_eq!(window.find(("perm", 2usize)).checked(), Some(false)); + window.click(("perm", 2usize), cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert_eq!(window.find("perm-octal").value(), Some("744")); + window.click("commit", cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| { + matches!( + last_operation(), + Some(RemoteOperation::SetPermissions { .. }) + ) && idle(cx) + }) + .await; + assert_eq!( + last_operation(), + Some(RemoteOperation::SetPermissions { + paths: vec![path("文件 甲.txt")], + edit: PermissionEdit::new(0o100, 0), + recursive: false, + add_x_to_dirs: false, + }) + ); +} + +#[gpui_kit::test] +async fn sftp_downloads_the_selection_with_f5_into_a_chosen_folder(cx: &mut TestAppContext) { + let provider = Arc::new(FakeSftpProvider::default()); + let (handle, _) = open_workspace_with_sftp(cx, provider.clone()); + open_test_explorer(cx, handle).await; + // F5 in the remote list downloads the selection into the local directory. + press_on_row(cx, handle, "remote-pane", "文件 甲.txt", "f5"); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(window.find("download-confirm").visible()); + assert_eq!( + window.find("download-target").value(), + Some("/local/tester") + ); + window.click("download-browse", cx); + }) + .unwrap(); + cx.run_until_parked(); + assert!(cx.did_prompt_for_paths()); + cx.simulate_path_prompt_response(|options| { + assert!(options.directories && !options.files && !options.multiple); + Some(vec!["/picked".into()]) + }); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert_eq!(window.find("download-target").value(), Some("/picked")); + window.click("download-confirm", cx); + }) + .unwrap(); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window + .try_find("transfer-status") + .is_some_and(|status| status.visible()) + }) + .await; + { + let downloads = provider.downloads.lock().unwrap(); + assert_eq!(downloads.len(), 1); + assert_eq!( + downloads[0].sources(), + [RemotePath::new("/home/tester/文件 甲.txt").unwrap()] + ); + assert_eq!(downloads[0].destination(), std::path::Path::new("/picked")); + } + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert!(window.find("cancel-transfer").visible()); + }) + .unwrap(); +} + +#[gpui_kit::test] +async fn sftp_dragging_remote_rows_to_a_local_directory_asks_to_download(cx: &mut TestAppContext) { + let provider = Arc::new(FakeSftpProvider::default()); + let (handle, _) = open_workspace_with_sftp(cx, provider); + open_test_explorer(cx, handle).await; + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + let from = window + .within(("remote-pane", DB_01)) + .find("file:文件 乙.txt") + .bounds() + .center(); + let to = window + .within(("local-pane", DB_01)) + .find("file:目录") + .bounds() + .center(); + window.drag(from, to, cx); + }) + .unwrap(); + cx.run_until_parked(); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert_eq!( + window.find("download-target").value(), + Some("/local/tester/目录") + ); + }) + .unwrap(); +} + #[gpui_kit::test] async fn sftp_discards_stale_directory_replies(cx: &mut TestAppContext) { use shellr::app::{ExplorerAction, ExplorerCommand}; @@ -3924,11 +4541,33 @@ async fn sftp_controls_fit_small_window_in_light_dark_and_zoom(cx: &mut TestAppC cx.run_until_parked(); cx.update_window(handle.into(), |_, window, cx| { window.render_frame(cx); - let local = window.find(("local-pane", DB_01)).bounds(); - let upload = window.find("upload").bounds(); - let choose = window.find("choose-upload").bounds(); - assert!(upload.left() >= local.left() && choose.right() <= local.right()); - assert_eq!(upload.top(), choose.top()); + for (pane, transfer) in [("local-pane", "upload"), ("remote-pane", "download")] { + let bounds = window.find((pane, DB_01)).bounds(); + let scope = window.within((pane, DB_01)); + for id in [ + "path-select", + "bookmarks", + "up", + "root", + "home", + "refresh", + "back", + "forward", + transfer, + "delete", + "rename", + "properties", + "new", + ] { + let button = scope.find(id).bounds(); + assert!( + button.left() >= bounds.left() + && button.right() <= bounds.right() + && button.top() >= bounds.top(), + "{pane} {id} at zoom {zoom}" + ); + } + } assert!(window.find("remote-path").visible()); }) .unwrap();