Compare commits

...
7 Commits
Author SHA1 Message Date
XuzandClaude Opus 5.5 5fdfce239a 版本号改为 0.1.7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:18:22 +08:00
XuzandClaude Opus 5.5 3a9e72eab3 版本号改为 0.1.7-beta.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:47:24 +08:00
XuzandClaude Opus 5.5 27822e7315 SFTP 文件列表点列名即可排序,换目录后列宽和排序箭头不再复位
gpui-kit 只给列标题右边的箭头挂了排序,标题本身的点击用来选列,而文件列表关掉了选列,
所以只能点箭头。现在点列名和点箭头一样:降序、升序、恢复按名称。

表格每次 refresh(每读一次目录)都从委托重新取列,原来拖宽的列会弹回默认宽度,箭头也回到
「未排序」而行仍按原列排着。现在 FileListing 记下拖动后的列宽(ColumnWidthsChanged)和
当前排序,refresh 后照旧。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:46:54 +08:00
XuzandClaude Opus 5.5 e1600ce05c SFTP 本地目录列表在 Windows 上列出各个驱动器
Windows 上每个盘是一个独立的根,目录列表原来只有当前路径的各级和主目录、桌面、文稿、下载,
离不开起始的盘(只能在「打开目录/书签」里手动输入 D:\)。现在「位置」里按 WinSCP 的样子
列出所有盘符:「C: 本地磁盘」「E: 可移动磁盘」「Z: 网络驱动器」「F: CD 驱动器」。

盘符用 GetLogicalDrives 和 GetDriveTypeW 取,不读卷标。位置改在后台随每次读本地目录
一起重新取,所以新插上的 U 盘刷新一下就出现,也不在 UI 线程上碰网络驱动器。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:46:54 +08:00
XuzandClaude Opus 5.5 5be3f56b0c 版本号改为 0.1.7-beta.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:52:19 +08:00
XuzandClaude Opus 5.5 eadbe6481a SFTP 只用 WinSCP 也用的请求:修复经堡垒机上传失败、编辑器保存时连接断开
用户的堡垒机对 fsetstat 回 Failure,收到 fsync@openssh.com(即使宣称支持)就断开整个会话,
所以上传报「Failure: Failure」(.filepart 已写完整),编辑器保存时 SFTP 连接断开。现在:

- 上传关闭文件后才按路径 setstat 设修改时间和权限,不再对打开的句柄 fsetstat;
- 不再发 fsync@openssh.com;
- 覆盖已有文件也不用 posix-rename@openssh.com,一律先把旧文件改名成备份、发布后再删
  (不是原子的)。

测试服务器加了堡垒机模式(bastion_server):对 fsetstat 和所有扩展请求断开会话;新上传、
覆盖上传和编辑器保存都要在同一条连接上完成,并保留修改时间。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:51:51 +08:00
XuzandClaude Opus 5.5 ecf9d92e47 README 加上 LINUX DO 徽章
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:33:45 +08:00
21 changed files with 639 additions and 184 deletions

No files matched your search

+35
View File
@@ -2,6 +2,41 @@
每个版本一节,标题写成 `## [版本号]`,可以在后面加日期。发布流程(`.github/workflows/release.yml`)把与 tag 同版本的那一节原样作为更新说明:显示在 GitHub Release 和官网的更新日志里(ShellRS 的更新对话框用「查看更新内容」打开官网的更新日志),用 Markdown 书写,面向使用者。
## [0.1.7]
### 新增
- Windows 上 SFTP 本地目录列表列出各个驱动器,可切换到其他盘。
### 改进
- SFTP 文件列表点列名即可排序。
### 修复
- 修复经部分堡垒机上传文件失败、编辑器保存时 SFTP 连接断开。
- 修复 SFTP 换目录后拖宽的列恢复原宽。
## [0.1.7-beta.2]
### 新增
- Windows 上 SFTP 本地目录列表列出各个驱动器,可切换到其他盘。
### 改进
- SFTP 文件列表点列名即可排序。
### 修复
- 修复 SFTP 换目录后拖宽的列恢复原宽。
## [0.1.7-beta.1]
### 修复
- 修复经部分堡垒机上传文件失败、编辑器保存时 SFTP 连接断开。
## [0.1.6]
### 新增
+1
View File
@@ -280,6 +280,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- **每个标签独立连接、独立传输引擎。** 引擎一次只跑一批,其余在 `TransferQueue` 里排队,引擎的 `Idle` 推动队列前进;进度只归队首;停止的批次挡在队首,等用户继续或移出。
- **SFTP 独立于终端。** 关闭终端不停传输;断开或删除主机会停止传输,并保留续传数据。
- **下载严格按偏移顺序写 `.filepart`**,因为续传只信它的长度。
- **只发 WinSCP 也发的请求**:用户的堡垒机对 `fsetstat` 回 Failure,收到 `fsync@openssh.com`(即使宣称支持)就断开整个会话。所以上传关闭文件后才按路径 `setstat` 设时间和权限;不用任何扩展:不发 fsync,覆盖也不用 `posix-rename@openssh.com`,而是先把旧文件改名成备份、发布后再删(不是原子的)。`protocol_tests` 的 `bastion_server` 对 `fsetstat` 和所有扩展请求都断开会话,守着这条。
- **文件操作从不跟随符号链接**:删除只删链接本身,改权限跳过链接,传输原样创建链接。
- **空闲时也要发现断网**:通道流包一层 `WatchedStream`。等待时只持有 `Weak`,不能攥着 `Arc<SftpClient>`,否则「断开」关不掉连接。
- **选择归面板,不归表格。** `DataTable` 设成 `row_selectable(false)`,`FilePane` 持有按名称保存的 `Selection`。「名称」单元格才是项目,拖文件只从图标和文件名开始,其余地方按下拖动是框选。
Generated
+1 -1
View File
@@ -7738,7 +7738,7 @@ dependencies = [
[[package]]
name = "shellrs"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"alacritty_terminal",
"anyhow",
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "shellrs"
version = "0.1.6"
version = "0.1.7"
edition = "2024"
publish = false
description = "SSH client and host manager: terminal, SFTP, port forwarding"
@@ -113,6 +113,7 @@ windows-sys = { version = "0.61", features = [
"Win32_System_IO",
"Win32_System_Pipes",
"Win32_System_Threading",
"Win32_System_WindowsProgramming",
"Win32_UI_WindowsAndMessaging",
] }
+1
View File
@@ -13,6 +13,7 @@
<a href="#license"><img src="https://img.shields.io/badge/license-GPL--3.0-blue" alt="License: GPL-3.0"></a>
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey" alt="Platform: macOS | Windows | Linux">
<img src="https://img.shields.io/badge/rust-1.98-orange" alt="Rust 1.98">
<a href="https://linux.do"><img src="https://img.shields.io/badge/LINUX-DO-FFB003.svg?logo=data:image/svg%2bxml;base64,DQo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjEwMCIgaGVpZ2h0PSIxMDAiPjxwYXRoIGQ9Ik00Ni44Mi0uMDU1aDYuMjVxMjMuOTY5IDIuMDYyIDM4IDIxLjQyNmM1LjI1OCA3LjY3NiA4LjIxNSAxNi4xNTYgOC44NzUgMjUuNDV2Ni4yNXEtMi4wNjQgMjMuOTY4LTIxLjQzIDM4LTExLjUxMiA3Ljg4NS0yNS40NDUgOC44NzRoLTYuMjVxLTIzLjk3LTIuMDY0LTM4LjAwNC0yMS40M1EuOTcxIDY3LjA1Ni0uMDU0IDUzLjE4di02LjQ3M0MxLjM2MiAzMC43ODEgOC41MDMgMTguMTQ4IDIxLjM3IDguODE3IDI5LjA0NyAzLjU2MiAzNy41MjcuNjA0IDQ2LjgyMS0uMDU2IiBzdHlsZT0ic3Ryb2tlOm5vbmU7ZmlsbC1ydWxlOmV2ZW5vZGQ7ZmlsbDojZWNlY2VjO2ZpbGwtb3BhY2l0eToxIi8+PHBhdGggZD0iTTQ3LjI2NiAyLjk1N3EyMi41My0uNjUgMzcuNzc3IDE1LjczOGE0OS43IDQ5LjcgMCAwIDEgNi44NjcgMTAuMTU3cS00MS45NjQuMjIyLTgzLjkzIDAgOS43NS0xOC42MTYgMzAuMDI0LTI0LjM4N2E2MSA2MSAwIDAgMSA5LjI2Mi0xLjUwOCIgc3R5bGU9InN0cm9rZTpub25lO2ZpbGwtcnVsZTpldmVub2RkO2ZpbGw6IzE5MTkxOTtmaWxsLW9wYWNpdHk6MSIvPjxwYXRoIGQ9Ik03Ljk4IDcwLjkyNmMyNy45NzctLjAzNSA1NS45NTQgMCA4My45My4xMTNRODMuNDI2IDg3LjQ3MyA2Ni4xMyA5NC4wODZxLTE4LjgxIDYuNTQ0LTM2LjgzMi0xLjg5OC0xNC4yMDMtNy4wOS0yMS4zMTctMjEuMjYyIiBzdHlsZT0ic3Ryb2tlOm5vbmU7ZmlsbC1ydWxlOmV2ZW5vZGQ7ZmlsbDojZjlhZjAwO2ZpbGwtb3BhY2l0eToxIi8+PC9zdmc+" alt="LINUX DO"></a>
</p>
<p align="center"><b>English</b> | <a href="README.zh-CN.md">简体中文</a></p>
+1
View File
@@ -13,6 +13,7 @@
<a href="#许可证"><img src="https://img.shields.io/badge/license-GPL--3.0-blue" alt="License: GPL-3.0"></a>
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey" alt="Platform: macOS | Windows | Linux">
<img src="https://img.shields.io/badge/rust-1.98-orange" alt="Rust 1.98">
<a href="https://linux.do"><img src="https://img.shields.io/badge/LINUX-DO-FFB003.svg?logo=data:image/svg%2bxml;base64,DQo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjEwMCIgaGVpZ2h0PSIxMDAiPjxwYXRoIGQ9Ik00Ni44Mi0uMDU1aDYuMjVxMjMuOTY5IDIuMDYyIDM4IDIxLjQyNmM1LjI1OCA3LjY3NiA4LjIxNSAxNi4xNTYgOC44NzUgMjUuNDV2Ni4yNXEtMi4wNjQgMjMuOTY4LTIxLjQzIDM4LTExLjUxMiA3Ljg4NS0yNS40NDUgOC44NzRoLTYuMjVxLTIzLjk3LTIuMDY0LTM4LjAwNC0yMS40M1EuOTcxIDY3LjA1Ni0uMDU0IDUzLjE4di02LjQ3M0MxLjM2MiAzMC43ODEgOC41MDMgMTguMTQ4IDIxLjM3IDguODE3IDI5LjA0NyAzLjU2MiAzNy41MjcuNjA0IDQ2LjgyMS0uMDU2IiBzdHlsZT0ic3Ryb2tlOm5vbmU7ZmlsbC1ydWxlOmV2ZW5vZGQ7ZmlsbDojZWNlY2VjO2ZpbGwtb3BhY2l0eToxIi8+PHBhdGggZD0iTTQ3LjI2NiAyLjk1N3EyMi41My0uNjUgMzcuNzc3IDE1LjczOGE0OS43IDQ5LjcgMCAwIDEgNi44NjcgMTAuMTU3cS00MS45NjQuMjIyLTgzLjkzIDAgOS43NS0xOC42MTYgMzAuMDI0LTI0LjM4N2E2MSA2MSAwIDAgMSA5LjI2Mi0xLjUwOCIgc3R5bGU9InN0cm9rZTpub25lO2ZpbGwtcnVsZTpldmVub2RkO2ZpbGw6IzE5MTkxOTtmaWxsLW9wYWNpdHk6MSIvPjxwYXRoIGQ9Ik03Ljk4IDcwLjkyNmMyNy45NzctLjAzNSA1NS45NTQgMCA4My45My4xMTNRODMuNDI2IDg3LjQ3MyA2Ni4xMyA5NC4wODZxLTE4LjgxIDYuNTQ0LTM2LjgzMi0xLjg5OC0xNC4yMDMtNy4wOS0yMS4zMTctMjEuMjYyIiBzdHlsZT0ic3Ryb2tlOm5vbmU7ZmlsbC1ydWxlOmV2ZW5vZGQ7ZmlsbDojZjlhZjAwO2ZpbGwtb3BhY2l0eToxIi8+PC9zdmc+" alt="LINUX DO"></a>
</p>
<p align="center"><a href="README.md">English</a> | <b>简体中文</b></p>
+3 -3
View File
@@ -251,7 +251,7 @@ Open a host’s “SFTP” tab to browse local and remote folders. As with termi
Each pane has two rows of toolbar, with the path label below them. When a pane is too narrow, buttons hide whole, from right to left, rather than wrapping (their commands are still in the context menus and shortcuts). A new SFTP tab splits evenly between the local and remote sides; wherever you drag the dividers between the sides and the queue, they stay there until the tab closes, even when you switch to another tab and back:
- First row (navigation):
- The folder list: lists every level from the root to the current folder, plus Home, Desktop, Documents and Downloads on the local side; choose one to go there.
- The folder list: lists every level from the root to the current folder, plus Home, Desktop, Documents and Downloads on the local side, and on Windows every drive (such as “C: Local disk” and “D: Local disk”); choose one to go there.
- Bookmarks: the button opens the “Open folder” dialog (see below); the drop-down arrow next to it lists this host’s bookmarks for this side, which you click to go there, and can also add or remove the current path. Bookmarks are saved in the local database and deleted along with the host.
- Parent folder, Root folder, Home folder, Refresh, Back, Forward.
- Show / hide hidden files: files and folders whose names start with `.`. An open eye means they’re shown, a crossed-out eye that they’re hidden. Local and remote each have their own switch, independent of each other; each side’s choice applies to every SFTP tab and is saved in the settings; both are hidden by default. While they’re hidden, the bottom of the pane says “N hidden”; while they’re shown, hidden files’ text is lighter.
@@ -281,7 +281,7 @@ Works like WinSCP’s Open directory dialog. Open it from the path label, the bo
The status bar below the list normally shows the number of items and how many are selected; while connecting, or when reading a folder takes longer than 0.3 seconds, it shows “Connecting to SFTP…” or “Reading folder…”. When a folder can’t be read or the connection drops, the reason appears in red at the far bottom left of the window (where “Connected to” and the host’s name normally are). A dropped connection is noticed even while idle, without waiting for a refresh, as soon as the same host’s terminal would notice. To reconnect, use the “Reconnect” button in the tab bar or the tab menu. While disconnected, any action in the remote list (refreshing, opening a folder, deleting, uploading, downloading and so on) brings up a “Lost the SFTP connection to …” dialog that explains why and can “Reconnect” right away. None of this makes the list move.
The columns are the same as in WinSCP: Name, Size, Type and Modified on the local side; Name, Size, Modified, Permissions and Owner on the remote side.
The columns are the same as in WinSCP: Name, Size, Type and Modified on the local side; Name, Size, Modified, Permissions and Owner on the remote side. Click a column header (or the arrow at its right) to sort by that column: descending, then ascending, then back to by name; folders always come before files. The order and any column widths you dragged stay when you open another folder or refresh.
- Sizes are shown in KB by default (`4,008,960 KB`, rounded up; only empty files are 0 KB), as in WinSCP. Right-click the “Size” column header to switch to “Bytes” (`4,105,175,040 B`), “Kilobytes (KB)” or “Short (B, KB, MB, GB)”, which uses B, KB, MB or GB depending on the size, such as `3.8 GB`; the choice applies to every SFTP tab and is saved in the settings. Sorting by size always uses the exact number of bytes.
- Times look like `2026/4/22 12:44:53`.
@@ -385,7 +385,7 @@ Works like WinSCP’s queue panel: it appears below the two panes when there are
## Resuming and replacing
Files are first written to `<destination file name>.filepart`. Write requests go through a bounded pipeline, and the real file is published only after every write has been acknowledged and the file has closed successfully; when the server supports it, `fsync@openssh.com` is called first. Servers that support `posix-rename@openssh.com` replace the file atomically; on other servers, the old file is first moved to `.shellrs-<UUID>.backup` and then the new file is published, a compatibility path that isn’t guaranteed to be atomic. If the reply to a publish is lost, ShellRS recovers using the managed temporary path, the stage, the type and the size; when it can’t tell safely, it keeps the recovery files and reports it.
Files are first written to `<destination file name>.filepart`. Write requests go through a bounded pipeline, and once every write has been acknowledged and the file has closed successfully, its modification time (and, over an existing file, that file’s permissions) is set by path before the real file is published. Over an existing file, the old file is first moved to `.shellrs-<UUID>.backup`, then the new file is published and the backup deleted; this replacement isn’t atomic, as the destination is briefly missing between the two renames. Like WinSCP, transfers use no SFTP extensions (`fsync@openssh.com`, `posix-rename@openssh.com` and so on) and set no attributes on an open file: some bastion hosts refuse these requests, or even end the whole session over them. If the reply to a publish is lost, ShellRS recovers using the managed temporary path, the stage, the type and the size; when it can’t tell safely, it keeps the recovery files and reports it.
Resume records are kept in `upload-resume/` in the app’s data folder, as versioned JSON written atomically; they never contain passwords, passphrases or private keys, and they don’t change the database schema. A record ties together the endpoint, host fingerprint, source, destination and safe-replace stage. Resuming works as in WinSCP: ShellRS reads the current size of the remote `.filepart`, skips that much of the local file and carries on; it’s up to you to make sure the source is still the same version. Uploads never read the remote file back to verify it.
+3 -3
View File
@@ -257,7 +257,7 @@ bash 在 shell 退出时才把这次执行的命令写进历史文件,所以
每一侧面板都有两行工具栏,下方是路径标签。面板太窄放不下时,按钮从右往左整个隐藏,不会换行(对应的命令在右键菜单和快捷键里仍然可用)。新开的 SFTP 标签本地、远程两侧各占一半;两侧和队列之间的分隔线拖到哪里,在这个标签关闭之前都会记住,切到别的标签再回来也不变:
- 第一行(导航):
- 「目录列表」:列出从根到当前目录的每一级,本地另有主目录、桌面、文稿、下载,选中即前往。
- 「目录列表」:列出从根到当前目录的每一级,本地另有主目录、桌面、文稿、下载,Windows 上还有各个驱动器(如「C: 本地磁盘」「D: 本地磁盘」),选中即前往。
- 书签:点按钮打开「打开目录/书签」(见下);旁边的下拉箭头列出这台主机这一侧的书签,点击即前往,也可添加或移除当前路径。书签保存在本地数据库,删除主机时一并删除。
- 上级目录、根目录、主目录、刷新、后退、前进。
- 显示 / 不显示隐藏文件:名称以 `.` 开头的文件和目录。睁开的眼睛表示正在显示,划掉的眼睛表示不显示。本地和远程各自一个开关,互不影响;每一侧的选择对所有 SFTP 标签生效,保存在设置里,默认都不显示。不显示时面板底部写明「隐藏 N 项」;显示时隐藏文件的文字颜色浅一些。
@@ -287,7 +287,7 @@ bash 在 shell 退出时才把这次执行的命令写进历史文件,所以
列表下方的状态栏平时显示项目数和已选数量;连接中和读取目录超过 0.3 秒时显示「正在连接 SFTP…」或「正在读取目录…」。读不了目录或连接断开时,原因用红字显示在窗口最左下角(平时显示「已连接 主机的名称」的位置);空闲时断网也会自己发现,不用等到刷新,时机和同一台主机的终端一样;重新连接用标签栏的「重新连接」按钮或标签菜单;断开时在远程列表里做任何操作(刷新、打开目录、删除、上传下载等)会弹出「SFTP 连接已断开」对话框,说明原因,并可直接「重新连接」。这些都不会让列表移动。
列与 WinSCP 相同:本地为名称、大小、类型、修改时间,远程为名称、大小、修改时间、权限、所有者。
列与 WinSCP 相同:本地为名称、大小、类型、修改时间,远程为名称、大小、修改时间、权限、所有者。点列标题(或它右边的箭头)按这一列排序,依次是降序、升序、恢复按名称;目录始终排在文件前面。换目录、刷新后排序和拖动过的列宽都保持不变。
- 大小默认以 KB 显示(`4,008,960 KB`,向上取整,只有空文件是 0 KB),与 WinSCP 相同。右键「大小」列标题可切换为「字节」(`4,105,175,040 B`)、「千字节(KB)」或「简短格式」(按大小用 B、KB、MB、GB,如 `3.8 GB`),对所有 SFTP 标签生效并保存在设置里。按大小排序始终按实际字节数。
- 时间形如 `2026/4/22 12:44:53`。
@@ -391,7 +391,7 @@ bash 在 shell 退出时才把这次执行的命令写进历史文件,所以
## 断点续传和替换
文件先写入 `<目标文件名>.filepart`。写入请求采用有界流水线,收到每个写入确认并关闭成功后才发布正式文件;服务器支持时先调用 `fsync@openssh.com`。支持 `posix-rename@openssh.com` 的服务器使用原子替换;其他服务器先将旧文件移到 `.shellrs-<UUID>.backup`,再发布新文件,此兼容路径不保证原子性。发布响应丢失时根据受管理临时路径、阶段、类型和大小恢复,无法安全判断则保留恢复文件并报告。
文件先写入 `<目标文件名>.filepart`。写入请求采用有界流水线,收到每个写入确认并关闭成功后,按路径设置修改时间(覆盖时还有原文件的权限),再发布正式文件。覆盖已有文件时,先将旧文件移到 `.shellrs-<UUID>.backup`,再发布新文件,最后删除备份;这样替换不是原子的,两次改名之间目标短暂不存在。和 WinSCP 一样,传输不用任何 SFTP 扩展(`fsync@openssh.com`、`posix-rename@openssh.com` 等),也不对打开的文件设置属性:有的堡垒机拒绝这些请求,甚至因此断开整个会话。发布响应丢失时根据受管理临时路径、阶段、类型和大小恢复,无法安全判断则保留恢复文件并报告。
续传记录保存在应用数据目录的 `upload-resume/`,采用版本化 JSON 和原子写入,不写入密码、口令或私钥,不改变数据库结构。记录用于绑定端点、主机指纹、来源、目标和安全替换阶段。续传方式与 WinSCP 相同:读取远端 `.filepart` 的当前大小,在本地文件中跳过相同长度并继续传输;使用者需确保来源仍是同一版本。上传过程不会为了校验而回读远端文件内容。
+12 -3
View File
@@ -15,6 +15,18 @@ sftp.place.documents:
sftp.place.downloads:
zh-CN: 下载
en: Downloads
sftp.place.drive_local:
zh-CN: "%{drive} 本地磁盘"
en: "%{drive} Local disk"
sftp.place.drive_removable:
zh-CN: "%{drive} 可移动磁盘"
en: "%{drive} Removable disk"
sftp.place.drive_network:
zh-CN: "%{drive} 网络驱动器"
en: "%{drive} Network drive"
sftp.place.drive_optical:
zh-CN: "%{drive} CD 驱动器"
en: "%{drive} CD drive"
sftp.error.remote_path_invalid:
zh-CN: 远程路径不能为空或包含空字符
@@ -305,9 +317,6 @@ sftp.client.no_path:
sftp.client.no_link_target:
zh-CN: 无法读取链接目标
en: Couldn’t read the link target
sftp.client.bad_rename_reply:
zh-CN: 服务器返回了无效的重命名响应
en: The server sent an invalid reply to the rename
sftp.client.ambiguous_name:
zh-CN: 远端名称包含无法可靠表示的字符,已停止操作
en: A remote name has characters that can’t be shown reliably, so the operation stopped
-7
View File
@@ -117,18 +117,12 @@ impl ExplorerPanel {
.expect("workspace checked host");
let endpoint = login.endpoint();
let home = local_provider.home().to_string_lossy().into_owned();
let places = local_provider
.places()
.into_iter()
.map(|(place, path)| (place, path.to_string_lossy().into_owned()))
.collect();
let local = cx.new(|cx| {
FilePane::new(
PaneSide::Local,
id,
host_id,
home.clone(),
places,
store.clone(),
dispatch.clone(),
window,
@@ -141,7 +135,6 @@ impl ExplorerPanel {
id,
host_id,
String::new(),
Vec::new(),
store.clone(),
dispatch.clone(),
window,
+50 -8
View File
@@ -199,6 +199,43 @@ impl FileListing {
}
}
/// Keep the widths the columns were dragged to: the table takes the
/// columns again on each `refresh`, every listing among them.
pub(super) fn set_widths(&mut self, widths: &[Pixels]) {
for (column, width) in self.columns.iter_mut().zip(widths) {
column.width = *width;
}
}
/// A click on a column's title sorts as one on its arrow does:
/// descending, then ascending, then back to by name.
fn cycle_sort(&mut self, col_ix: usize) {
let sort = match self.sort {
Some((ix, ColumnSort::Descending)) if ix == col_ix => ColumnSort::Ascending,
Some((ix, ColumnSort::Ascending)) if ix == col_ix => ColumnSort::Default,
_ => ColumnSort::Descending,
};
self.set_sort(col_ix, sort);
}
/// Sort by a column, marking it on the columns too, which the table
/// takes again on each `refresh`: otherwise the arrows would go back to
/// unsorted with the next listing while the rows stayed sorted.
fn set_sort(&mut self, col_ix: usize, sort: ColumnSort) {
self.sort = match sort {
ColumnSort::Default => None,
sort => Some((col_ix, sort)),
};
for (ix, column) in self.columns.iter_mut().enumerate() {
column.sort = Some(if ix == col_ix {
sort
} else {
ColumnSort::Default
});
}
self.apply_sort();
}
/// The rows shown, in display order.
pub fn rows(&self) -> &[FileEntry] {
&self.rows
@@ -406,13 +443,14 @@ impl TableDelegate for FileListing {
}
/// `Column::text_right` is not applied by `DataTable`, so the size
/// column aligns itself. A right-click on a title is recorded for the
/// list's menu, which offers the size formats on 大小's, as WinSCP does.
/// column aligns itself. A click on a title sorts by the column, as in
/// WinSCP, rather than only one on the arrow. A right-click is recorded
/// for the list's menu, which offers the size formats on 大小's.
fn render_th(
&mut self,
col_ix: usize,
_: &mut Window,
_: &mut Context<TableState<Self>>,
cx: &mut Context<TableState<Self>>,
) -> impl IntoElement {
let column = &self.columns[col_ix];
let key = column.key.clone();
@@ -428,6 +466,14 @@ impl TableDelegate for FileListing {
*menu_hit.borrow_mut() = Some(MenuHit::Column(key.clone()));
})
.on_any_mouse_down(move |_, _, _| geometry.pressed.set(Pressed::Title))
// The table keeps the arrows in its columns, which only a
// `refresh` gives it again.
.on_click(cx.listener(move |table, _, _, cx| {
cx.stop_propagation();
table.delegate_mut().cycle_sort(col_ix);
table.refresh(cx);
cx.notify();
}))
.child(column.name.clone())
}
@@ -593,11 +639,7 @@ impl TableDelegate for FileListing {
_: &mut Window,
cx: &mut Context<TableState<Self>>,
) {
self.sort = match sort {
ColumnSort::Default => None,
sort => Some((col_ix, sort)),
};
self.apply_sort();
self.set_sort(col_ix, sort);
cx.notify();
}
+30 -14
View File
@@ -28,7 +28,7 @@ use gpui_kit::component::{
searchable_list::{SearchableGroup, SearchableListItem, SearchableVec},
select::{Select, SelectEvent, SelectState},
separator::Separator,
table::{DataTable, TableDelegate as _, TableState},
table::{DataTable, TableDelegate as _, TableEvent, TableState},
v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
@@ -132,7 +132,7 @@ pub struct PathChoice {
title: SharedString,
path: SharedString,
depth: usize,
home: bool,
place: Option<Place>,
side: PaneSide,
}
impl SearchableListItem for PathChoice {
@@ -144,10 +144,10 @@ impl SearchableListItem for PathChoice {
&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)
let icon = match self.place {
Some(Place::Home) => Icon::new(CatalogIcon::House),
Some(Place::Drive(..)) => Icon::new(IconName::HardDrive),
_ => Icon::new(IconName::Folder),
};
h_flex()
.gap_2()
@@ -182,7 +182,8 @@ pub struct FilePane {
host_id: HostId,
pub(super) path: String,
home: String,
/// Well-known local places for the 目录列表 select; empty for remote.
/// Well-known local places for the 目录列表 select, read with each
/// local listing; empty for remote.
places: Vec<(Place, String)>,
table: Entity<TableState<FileListing>>,
/// The path label part under the pointer, by the directory it opens.
@@ -236,7 +237,6 @@ impl FilePane {
explorer: ExplorerId,
host_id: HostId,
home: String,
places: Vec<(Place, String)>,
store: Entity<HostStore>,
dispatch: FocusHandle,
window: &mut Window,
@@ -272,6 +272,11 @@ impl FilePane {
}
},
),
cx.subscribe(&table, |_, table, event: &TableEvent, cx| {
if let TableEvent::ColumnWidthsChanged(widths) = event {
table.update(cx, |table, _| table.delegate_mut().set_widths(widths));
}
}),
cx.observe(&store, |_, _, cx| cx.notify()),
cx.observe_global::<ShowHiddenFiles>(|pane, cx| pane.sync_hidden_files(cx)),
// The column titles and the select's groups are kept by
@@ -290,7 +295,7 @@ impl FilePane {
host_id,
path: home.clone(),
home,
places,
places: Vec::new(),
table,
hovered_part: None,
label_width: Rc::default(),
@@ -496,6 +501,10 @@ impl FilePane {
pub fn is_busy(&self) -> bool {
self.busy
}
/// The places the 目录列表 select offers, as of the last local listing.
pub fn places(&self) -> &[(Place, String)] {
&self.places
}
/// Select this row once the next listing arrives.
pub fn select_after_load(&mut self, name: String) {
self.select_after_load = Some(name);
@@ -935,7 +944,7 @@ impl FilePane {
title: title.into(),
path: path.into(),
depth,
home: false,
place: None,
side,
}),
),
@@ -947,7 +956,7 @@ impl FilePane {
title: place.title(),
path: path.clone().into(),
depth: 0,
home: *place == Place::Home,
place: Some(*place),
side,
}),
),
@@ -969,14 +978,21 @@ impl FilePane {
) {
let id = self.begin_load(intent, cx);
self.load_task = Some(cx.spawn_in(window, async move |this, cx| {
let result = cx
let (result, places) = cx
.background_spawn(async move {
provider
let result = provider
.list(&PathBuf::from(path))
.map_err(|e| t!("explorer.list.read_failed", error = e).to_string())
.map_err(|e| t!("explorer.list.read_failed", error = e).to_string());
let places = provider
.places()
.into_iter()
.map(|(place, path)| (place, path.to_string_lossy().into_owned()))
.collect();
(result, places)
})
.await;
let _ = this.update_in(cx, |this, window, cx| {
this.places = places;
this.apply_listing(id, result, window, cx)
});
}));
+17 -48
View File
@@ -6,7 +6,7 @@ use anyhow::{Result, anyhow, bail};
use futures::StreamExt as _;
use russh_sftp::{
client::{RawSftpSession, error::Error},
protocol::{FileAttributes, OpenFlags, Packet, StatusCode},
protocol::{FileAttributes, OpenFlags, StatusCode},
};
use std::{
pin::Pin,
@@ -34,25 +34,29 @@ pub(crate) trait RemoteFs {
async fn open_read(&self, path: &RemotePath) -> Result<String>;
/// Up to `len` bytes at `offset`; may return fewer. `None` at end of file.
async fn read(&self, handle: &str, offset: u64, len: u32) -> Result<Option<Vec<u8>>>;
async fn sync(&self, handle: &str) -> Result<()>;
async fn write(&self, handle: &str, offset: u64, bytes: Vec<u8>) -> Result<()>;
async fn close(&self, handle: &str) -> Result<()>;
/// Set the modification time and permission bits of a closed file by its
/// path (`setstat`), as WinSCP does: a bastion host has been seen to
/// refuse `fsetstat` on a file still open.
async fn attributes(
&self,
handle: &str,
path: &RemotePath,
modified: Option<u32>,
permissions: Option<u32>,
) -> Result<()>;
async fn mkdir(&self, path: &RemotePath) -> Result<()>;
async fn symlink(&self, target: &str, path: &RemotePath) -> Result<()>;
async fn readlink(&self, path: &RemotePath) -> Result<String>;
async fn rename(&self, from: &RemotePath, to: &RemotePath, replace: bool) -> Result<()>;
/// A plain `rename`, which fails when `to` is there: replacing a file
/// moves the old one aside first, as WinSCP never sends
/// `posix-rename@openssh.com` either.
async fn rename(&self, from: &RemotePath, to: &RemotePath) -> 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;
}
pub(crate) struct SftpClient {
@@ -61,8 +65,6 @@ pub(crate) struct SftpClient {
/// Turns true once the server side of the SFTP stream has ended.
closed: watch::Receiver<bool>,
fingerprint: String,
atomic_replace: bool,
fsync: bool,
}
impl SftpClient {
pub async fn connect(
@@ -92,21 +94,14 @@ impl SftpClient {
if version.version != 3 {
bail!(t!("sftp.client.no_v3"));
}
let atomic_replace = version
.extensions
.get("posix-rename@openssh.com")
.is_some_and(|v| v == "1");
let fsync = version
.extensions
.get("fsync@openssh.com")
.is_some_and(|v| v == "1");
// No extension is used, even when offered: a bastion host that offers
// `fsync@openssh.com` has been seen to end the whole session on it.
// WinSCP sends none of them for a transfer.
Ok(Self {
raw,
ssh,
closed,
fingerprint,
atomic_replace,
fsync,
})
}
/// Only the Unix tests have a local `sftp-server` to talk to.
@@ -281,12 +276,6 @@ impl RemoteFs for SftpClient {
Err(error) => Err(error.into()),
}
}
async fn sync(&self, handle: &str) -> Result<()> {
if self.fsync {
self.raw.fsync(handle).await?;
}
Ok(())
}
async fn write(&self, handle: &str, offset: u64, bytes: Vec<u8>) -> Result<()> {
self.raw.write(handle, offset, bytes).await?;
Ok(())
@@ -298,13 +287,13 @@ impl RemoteFs for SftpClient {
async fn attributes(
&self,
handle: &str,
path: &RemotePath,
modified: Option<u32>,
permissions: Option<u32>,
) -> Result<()> {
self.raw
.fsetstat(
handle,
.setstat(
path.as_str(),
FileAttributes {
mtime: modified,
atime: modified,
@@ -337,25 +326,8 @@ impl RemoteFs for SftpClient {
.filename,
)
}
async fn rename(&self, from: &RemotePath, to: &RemotePath, replace: bool) -> Result<()> {
if replace {
let mut payload = Vec::new();
for value in [from.as_str(), to.as_str()] {
payload.extend_from_slice(&(value.len() as u32).to_be_bytes());
payload.extend_from_slice(value.as_bytes());
}
match self
.raw
.extended("posix-rename@openssh.com", payload)
.await?
{
Packet::Status(status) if status.status_code == StatusCode::Ok => {}
Packet::Status(status) => return Err(Error::Status(status).into()),
_ => bail!(t!("sftp.client.bad_rename_reply")),
}
} else {
self.raw.rename(from.as_str(), to.as_str()).await?;
}
async fn rename(&self, from: &RemotePath, to: &RemotePath) -> Result<()> {
self.raw.rename(from.as_str(), to.as_str()).await?;
Ok(())
}
async fn remove(&self, path: &RemotePath) -> Result<()> {
@@ -381,9 +353,6 @@ impl RemoteFs for SftpClient {
.await?;
Ok(())
}
fn atomic_replace(&self) -> bool {
self.atomic_replace
}
}
fn metadata(attrs: &FileAttributes) -> FileMetadata {
// Compare the whole type field: russh-sftp's `is_dir()` tests one bit,
+1 -4
View File
@@ -381,10 +381,7 @@ pub(crate) async fn write_in_place<F: RemoteFs>(
let message = format!("{error:#}");
error.context(SaveFailure::Interrupted(message))
};
let written = match write_all(fs, &handle, bytes).await {
Ok(()) => fs.sync(&handle).await,
Err(error) => Err(error),
};
let written = write_all(fs, &handle, bytes).await;
let close = fs.close(&handle).await;
written.map_err(interrupted)?;
close.map_err(interrupted)?;
+61 -2
View File
@@ -693,6 +693,18 @@ pub enum Place {
Desktop,
Documents,
Downloads,
/// A Windows drive by its letter, as WinSCP lists them: `C: 本地磁盘`.
Drive(char, DriveKind),
}
/// What a Windows drive is, which its title says.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DriveKind {
Local,
Removable,
Network,
Optical,
Other,
}
impl Place {
@@ -702,6 +714,16 @@ impl Place {
Self::Desktop => t!("sftp.place.desktop"),
Self::Documents => t!("sftp.place.documents"),
Self::Downloads => t!("sftp.place.downloads"),
Self::Drive(letter, kind) => {
let drive = format!("{letter}:");
match kind {
DriveKind::Local => t!("sftp.place.drive_local", drive = drive),
DriveKind::Removable => t!("sftp.place.drive_removable", drive = drive),
DriveKind::Network => t!("sftp.place.drive_network", drive = drive),
DriveKind::Optical => t!("sftp.place.drive_optical", drive = drive),
DriveKind::Other => drive.into(),
}
}
}
}
}
@@ -710,8 +732,9 @@ impl Place {
pub trait LocalDirectoryProvider: Send + Sync + 'static {
fn home(&self) -> PathBuf;
fn list(&self, path: &Path) -> Result<DirectoryListing>;
/// Well-known folders for the 目录列表 select, home first. Looked up
/// from the platform, without touching the file system.
/// Well-known folders for the 目录列表 select, home first, then the
/// drives on Windows. Read again with each listing, on the same
/// background executor, so a drive plugged in since shows up.
fn places(&self) -> Vec<(Place, PathBuf)> {
Vec::new()
}
@@ -746,6 +769,41 @@ pub trait LocalDirectoryProvider: Send + Sync + 'static {
) -> Result<FileStamp>;
}
pub type SharedLocalDirectoryProvider = std::sync::Arc<dyn LocalDirectoryProvider>;
/// Every drive letter in use, `A:\` to `Z:\`: without them the local pane
/// could not leave the drive it started on.
#[cfg(windows)]
fn drives() -> Vec<(Place, PathBuf)> {
use windows_sys::Win32::{
Storage::FileSystem::{GetDriveTypeW, GetLogicalDrives},
System::WindowsProgramming::{
DRIVE_CDROM, DRIVE_FIXED, DRIVE_RAMDISK, DRIVE_REMOTE, DRIVE_REMOVABLE,
},
};
// SAFETY: takes nothing and returns a bit mask, bit 0 for `A:`.
let letters = unsafe { GetLogicalDrives() };
(b'A'..=b'Z')
.filter(|letter| letters & (1 << (letter - b'A')) != 0)
.map(|letter| {
let root = format!("{}:\\", char::from(letter));
let wide: Vec<u16> = root.encode_utf16().chain([0]).collect();
// SAFETY: `wide` is a null-terminated UTF-16 string that outlives
// the call.
let kind = match unsafe { GetDriveTypeW(wide.as_ptr()) } {
DRIVE_FIXED | DRIVE_RAMDISK => DriveKind::Local,
DRIVE_REMOVABLE => DriveKind::Removable,
DRIVE_REMOTE => DriveKind::Network,
DRIVE_CDROM => DriveKind::Optical,
_ => DriveKind::Other,
};
(Place::Drive(char::from(letter), kind), PathBuf::from(root))
})
.collect()
}
#[cfg(not(windows))]
fn drives() -> Vec<(Place, PathBuf)> {
Vec::new()
}
#[derive(Default)]
pub struct SystemLocalDirectoryProvider;
impl LocalDirectoryProvider for SystemLocalDirectoryProvider {
@@ -761,6 +819,7 @@ impl LocalDirectoryProvider for SystemLocalDirectoryProvider {
]
.into_iter()
.filter_map(|(place, path)| Some((place, path?)))
.chain(drives())
.collect()
}
fn list(&self, path: &Path) -> Result<DirectoryListing> {
+1 -1
View File
@@ -18,7 +18,7 @@ pub(crate) async fn run<F: RemoteFs>(fs: &F, operation: &RemoteOperation) -> Res
if fs.metadata(to).await?.is_some() {
bail!(t!("sftp.error.exists", name = to.file_name()));
}
fs.rename(from, to, false).await
fs.rename(from, to).await
}
RemoteOperation::CreateDirectory { path } => {
if fs.metadata(path).await?.is_some() {
+135
View File
@@ -29,6 +29,7 @@ struct TestServer {
interrupts: Arc<AtomicUsize>,
hangup: Arc<tokio::sync::Notify>,
unsupported: bool,
bastion: bool,
}
struct Handler {
directory: PathBuf,
@@ -39,6 +40,8 @@ struct Handler {
/// Ends an SFTP session while nothing is asked of it.
hangup: Arc<tokio::sync::Notify>,
unsupported: bool,
/// Ends the SFTP session on what a bastion host refuses.
bastion: bool,
channels: HashMap<ChannelId, russh::Channel<server::Msg>>,
}
impl server::Server for TestServer {
@@ -52,6 +55,7 @@ impl server::Server for TestServer {
interrupts: self.interrupts.clone(),
hangup: self.hangup.clone(),
unsupported: self.unsupported,
bastion: self.bastion,
channels: HashMap::new(),
}
}
@@ -120,6 +124,7 @@ impl server::Handler for Handler {
let directory = self.directory.clone();
let interrupts = self.interrupts.clone();
let hangup = self.hangup.clone();
let bastion = self.bastion;
tokio::spawn(async move {
let executable = if cfg!(target_os = "macos") {
"/usr/libexec/sftp-server"
@@ -149,6 +154,9 @@ impl server::Handler for Handler {
}
let mut bytes = vec![0; size as usize];
reader.read_exact(&mut bytes).await?;
if bastion && refused_by_bastion(&bytes) {
return Err::<(), _>(std::io::Error::other("refused by the bastion host"));
}
// SSH_FXP_WRITE (6) for uploads, SSH_FXP_READ (5) for downloads.
if matches!(bytes.first(), Some(&5) | Some(&6)) {
writes += 1;
@@ -192,7 +200,27 @@ impl Drop for Running {
self.task.abort();
}
}
/// What a bastion host in front of OpenSSH was seen to refuse, though it
/// passes on OpenSSH's offer of extensions: `SSH_FXP_FSETSTAT` (10), which it
/// answered with a failure, and `fsync@openssh.com`, on which it ended the
/// session. Here every `SSH_FXP_EXTENDED` (200) is refused: like WinSCP,
/// ShellRS uses none to transfer or save a file.
fn refused_by_bastion(packet: &[u8]) -> bool {
matches!(packet.first(), Some(10 | 200))
}
async fn server(directory: PathBuf, interrupts: usize, unsupported: bool) -> Option<Running> {
serve(directory, interrupts, unsupported, false).await
}
/// A server that ends the SFTP session on what a bastion host refuses.
async fn bastion_server(directory: PathBuf) -> Option<Running> {
serve(directory, 0, false, true).await
}
async fn serve(
directory: PathBuf,
interrupts: usize,
unsupported: bool,
bastion: bool,
) -> Option<Running> {
let listener = match tokio::net::TcpListener::bind(("127.0.0.1", 0)).await {
Ok(l) => l,
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
@@ -215,6 +243,7 @@ async fn server(directory: PathBuf, interrupts: usize, unsupported: bool) -> Opt
interrupts: interrupts.clone(),
hangup: hangup.clone(),
unsupported,
bastion,
};
let key = russh::keys::PrivateKey::random(
&mut russh::keys::key::safe_rng(),
@@ -696,3 +725,109 @@ fn scp_style_transfers_land_where_scp_would_put_them() {
assert!(!remote.join("missing").exists());
});
}
/// Behind a bastion host that refuses `fsetstat` and extensions, an upload,
/// an upload over that file and an editor's save all finish on the one
/// connection, and the upload keeps the local file's modification time.
#[test]
fn uploads_and_saves_get_past_a_bastion_host_that_refuses_fsetstat_and_extensions() {
runtime().block_on(async {
let temp = tempfile::tempdir().unwrap();
let remote = temp.path().join("remote");
std::fs::create_dir(&remote).unwrap();
let Some(server) = bastion_server(remote.clone()).await else {
return;
};
let worker = worker(server.port, temp.path());
let home = loop {
match next(&worker).await {
SftpEvent::Connected { home } => break home,
SftpEvent::Disconnected(e) => panic!("connect failed: {e}"),
_ => {}
}
};
let source = temp.path().join("README.md");
let modified = std::time::UNIX_EPOCH + Duration::from_secs(1_500_000_000);
let write_source = |bytes: &[u8]| {
std::fs::write(&source, bytes).unwrap();
std::fs::File::options()
.write(true)
.open(&source)
.unwrap()
.set_modified(modified)
.unwrap();
};
let upload =
|| SftpCommand::Upload(UploadRequest::new(vec![source.clone()], home.clone()).unwrap());
let uploaded = remote.join("README.md");
write_source(b"first");
let done = transfer(&worker, upload()).await;
assert_eq!(done.phase(), TransferPhase::Completed);
assert_eq!(std::fs::read(&uploaded).unwrap(), b"first");
assert_eq!(
std::fs::metadata(&uploaded).unwrap().modified().unwrap(),
modified
);
write_source(b"second");
worker.commands.send(upload()).await.unwrap();
loop {
match next(&worker).await {
SftpEvent::Question(question) => {
assert_eq!(question.kind(), TransferQuestionKind::Conflict);
worker
.commands
.send(SftpCommand::Answer {
request_id: question.id(),
answer: TransferAnswer::new(TransferChoice::Overwrite, false),
})
.await
.unwrap();
}
SftpEvent::Progress(p) if p.phase() == TransferPhase::Completed => {
assert_eq!(p.succeeded(), 1);
break;
}
SftpEvent::Progress(p)
if matches!(
p.phase(),
TransferPhase::Stopped | TransferPhase::Reconnecting
) =>
{
panic!("the upload over the file broke off: {p:?}")
}
_ => {}
}
}
assert_eq!(std::fs::read(&uploaded).unwrap(), b"second");
assert_eq!(
std::fs::metadata(&uploaded).unwrap().modified().unwrap(),
modified
);
worker
.commands
.send(SftpCommand::WriteFile {
request_id: 7,
path: home.join("README.md").unwrap(),
bytes: b"edited".to_vec(),
expected: None,
})
.await
.unwrap();
loop {
match next(&worker).await {
SftpEvent::FileWritten { request_id, result } => {
assert_eq!(request_id, 7);
result.unwrap();
break;
}
SftpEvent::Disconnected(e) => panic!("the save broke off: {e}"),
_ => {}
}
}
assert_eq!(std::fs::read(&uploaded).unwrap(), b"edited");
assert_eq!(server.connections.load(Ordering::SeqCst), 1);
});
}
+74 -52
View File
@@ -41,11 +41,10 @@ struct Remote {
reads: Cell<usize>,
short_reads: Cell<bool>,
renames: Cell<usize>,
atomic: bool,
denied: RefCell<Option<String>>,
}
impl Remote {
fn new(atomic: bool) -> Self {
fn new() -> Self {
Self {
nodes: RefCell::new(BTreeMap::new()),
fault: Cell::new(None),
@@ -55,7 +54,6 @@ impl Remote {
reads: Cell::new(0),
short_reads: Cell::new(false),
renames: Cell::new(0),
atomic,
denied: RefCell::new(None),
}
}
@@ -225,9 +223,6 @@ impl RemoteFs for Remote {
self.writes.set(self.writes.get() + 1);
self.fail(Fault::Write(self.writes.get()))
}
async fn sync(&self, _: &str) -> Result<()> {
Ok(())
}
async fn close(&self, _: &str) -> Result<()> {
if self.fault.get() == Some(Fault::ChangeTargetOnClose) {
self.fault.set(None);
@@ -241,12 +236,12 @@ impl RemoteFs for Remote {
}
async fn attributes(
&self,
handle: &str,
path: &RemotePath,
modified: Option<u32>,
permissions: Option<u32>,
) -> Result<()> {
let mut nodes = self.nodes.borrow_mut();
let n = nodes.get_mut(handle).unwrap();
let n = nodes.get_mut(path.as_str()).unwrap();
n.metadata = FileMetadata::new(
EntryKind::File,
n.data.len() as u64,
@@ -283,13 +278,13 @@ impl RemoteFs for Remote {
async fn readlink(&self, path: &RemotePath) -> Result<String> {
Ok(self.nodes.borrow()[path.as_str()].link.clone())
}
async fn rename(&self, from: &RemotePath, to: &RemotePath, replace: bool) -> Result<()> {
async fn rename(&self, from: &RemotePath, to: &RemotePath) -> Result<()> {
if self.fault.get() == Some(Fault::RejectRename(self.renames.get() + 1)) {
self.fault.set(None);
bail!("publish denied");
}
let mut nodes = self.nodes.borrow_mut();
if !replace && nodes.contains_key(to.as_str()) {
if nodes.contains_key(to.as_str()) {
bail!("target exists");
}
let node = nodes
@@ -331,9 +326,6 @@ impl RemoteFs for Remote {
);
Ok(())
}
fn atomic_replace(&self) -> bool {
self.atomic
}
}
impl Remote {
/// The path a link chain ends at, as a server's `open` follows it.
@@ -496,7 +488,7 @@ fn recursive_upload_deduplicates_sources_and_preserves_empty_directories_and_lin
)
.await
.unwrap();
let remote = Remote::new(true);
let remote = Remote::new();
run(&mut batch, &remote, &answers.control).await.unwrap();
assert_eq!(remote.bytes("/dest/中文 目录/large.bin"), data);
assert_eq!(remote.bytes("/dest/中文 目录/零 字节"), Vec::<u8>::new());
@@ -540,7 +532,7 @@ fn winscp_style_upload_finishes_without_a_verification_phase() {
&answers.control,
)
.await;
let remote = Remote::new(true);
let remote = Remote::new();
run(&mut batch, &remote, &answers.control).await.unwrap();
tokio::task::yield_now().await;
@@ -583,7 +575,7 @@ fn resume_uses_remote_filepart_size_and_preserves_old_target() {
let data = vec![71; 150_000];
std::fs::write(&source, &data).unwrap();
let journal = Journal::new(tmp.path().join("journal"));
let remote = Remote::new(false);
let remote = Remote::new();
remote.file("/dest/file", b"old file intact");
remote.fault.set(Some(Fault::Write(2)));
let answers = Answers::new(vec![TransferChoice::Overwrite]);
@@ -633,7 +625,7 @@ fn filepart_larger_than_source_requires_explicit_restart() {
let source = tmp.path().join("file");
std::fs::write(&source, vec![1; 100_000]).unwrap();
let journal = Journal::new(tmp.path().join("journal"));
let remote = Remote::new(true);
let remote = Remote::new();
remote.fault.set(Some(Fault::Write(2)));
let answers = Answers::new(vec![]);
let mut first = batch(&source, journal.clone(), &answers.control).await;
@@ -663,7 +655,7 @@ fn winscp_style_resume_does_not_prevalidate_the_source_version() {
let data = vec![1; 100_000];
std::fs::write(&source, &data).unwrap();
let journal = Journal::new(tmp.path().join("journal"));
let remote = Remote::new(true);
let remote = Remote::new();
remote.fault.set(Some(Fault::Write(2)));
let answers = Answers::new(vec![]);
let mut first = batch(&source, journal.clone(), &answers.control).await;
@@ -700,7 +692,7 @@ fn winscp_style_resume_detects_filepart_without_a_local_record() {
let source = tmp.path().join("file");
let data = vec![3; 100_000];
std::fs::write(&source, &data).unwrap();
let remote = Remote::new(true);
let remote = Remote::new();
remote.file("/dest/file.filepart", &data[..32_768]);
let answers = Answers::new(vec![TransferChoice::Resume]);
let mut upload = batch(
@@ -724,17 +716,12 @@ fn winscp_style_resume_detects_filepart_without_a_local_record() {
#[test]
fn lost_replies_at_backup_publish_and_close_are_reconciled() {
runtime().block_on(async {
for (atomic, fault) in [
(true, Fault::Rename(1)),
(false, Fault::Rename(1)),
(false, Fault::Rename(2)),
(false, Fault::Close),
] {
for fault in [Fault::Rename(1), Fault::Rename(2), Fault::Close] {
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let source = tmp.path().join("file");
std::fs::write(&source, b"new contents").unwrap();
let journal = Journal::new(tmp.path().join("journal"));
let remote = Remote::new(atomic);
let remote = Remote::new();
remote.file("/dest/file", b"original");
remote.fault.set(Some(fault));
let answers = Answers::new(vec![TransferChoice::Overwrite]);
@@ -756,7 +743,7 @@ fn cancellation_retains_journal_and_skip_does_not_overwrite() {
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let source = tmp.path().join("file");
std::fs::write(&source, b"new").unwrap();
let remote = Remote::new(true);
let remote = Remote::new();
remote.file("/dest/file", b"old");
let answers = Answers::new(vec![TransferChoice::Skip]);
let journal = Journal::new(tmp.path().join("journal"));
@@ -783,7 +770,7 @@ fn directory_permission_error_is_reported_as_failure() {
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let source = tmp.path().join("blocked");
std::fs::create_dir(&source).unwrap();
let remote = Remote::new(true);
let remote = Remote::new();
*remote.denied.borrow_mut() = Some("/dest/blocked".into());
let answers = Answers::new(vec![TransferChoice::Skip]);
let mut batch = batch(
@@ -842,7 +829,7 @@ fn retrying_an_unreadable_folder_adds_only_its_files_to_the_upload() {
set_mode(0o755);
assert_eq!(batch.meter.progress.total_bytes(), 10_000);
let remote = Remote::new(true);
let remote = Remote::new();
run(&mut batch, &remote, &answers.control).await.unwrap();
assert_eq!(batch.meter.progress.failed(), 0);
assert_eq!(batch.meter.progress.total_bytes(), 10_300);
@@ -859,7 +846,7 @@ fn retrying_an_unreadable_folder_adds_only_its_files_to_the_download() {
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let out = tmp.path().join("out");
std::fs::create_dir(&out).unwrap();
let remote = Remote::new(true);
let remote = Remote::new();
remote.nodes.borrow_mut().insert(
"/srv/folder".into(),
Node {
@@ -939,6 +926,41 @@ fn paths_and_journals_reject_unsafe_identity() {
});
}
/// Drives are titled as WinSCP does: the letter, then what kind it is.
#[test]
fn drives_are_titled_by_letter_and_kind() {
assert_eq!(Place::Drive('C', DriveKind::Local).title(), "C: 本地磁盘");
assert_eq!(
Place::Drive('E', DriveKind::Removable).title(),
"E: 可移动磁盘"
);
assert_eq!(
Place::Drive('Z', DriveKind::Network).title(),
"Z: 网络驱动器"
);
assert_eq!(
Place::Drive('F', DriveKind::Optical).title(),
"F: CD 驱动器"
);
assert_eq!(Place::Drive('G', DriveKind::Other).title(), "G:");
}
/// Without the drives, the local pane could not leave the one it started on.
#[cfg(windows)]
#[test]
fn local_listing_places_include_the_system_drive() {
let system = std::env::var("SystemDrive").unwrap();
let letter = system.chars().next().unwrap().to_ascii_uppercase();
let places = SystemLocalDirectoryProvider.places();
assert!(
places.contains(&(
Place::Drive(letter, DriveKind::Local),
std::path::PathBuf::from(format!("{letter}:\\"))
)),
"{places:?}"
);
}
/// On Windows std canonicalizes to `\\?\C:\…`, which the pane showed as is.
#[test]
fn local_listing_reports_the_path_people_type() {
@@ -1066,7 +1088,7 @@ fn failed_compatibility_publish_restores_old_target_and_reports_failure() {
let temp = tempfile::tempdir().unwrap();
let source = temp.path().join("file");
std::fs::write(&source, b"new").unwrap();
let remote = Remote::new(false);
let remote = Remote::new();
remote.file("/dest/file", b"original");
remote.fault.set(Some(Fault::RejectRename(2)));
let answers = Answers::new(vec![TransferChoice::Overwrite, TransferChoice::Skip]);
@@ -1097,7 +1119,7 @@ fn target_changed_during_upload_requires_another_conflict_answer() {
let temp = tempfile::tempdir().unwrap();
let source = temp.path().join("file");
std::fs::write(&source, b"new").unwrap();
let remote = Remote::new(true);
let remote = Remote::new();
remote.file("/dest/file", b"original");
remote.fault.set(Some(Fault::ChangeTargetOnClose));
let answers = Answers::new(vec![TransferChoice::Overwrite, TransferChoice::Skip]);
@@ -1140,7 +1162,7 @@ fn target_removed_during_upload_still_requires_confirmation() {
let temp = tempfile::tempdir().unwrap();
let source = temp.path().join("file");
std::fs::write(&source, b"new").unwrap();
let remote = Remote::new(true);
let remote = Remote::new();
remote.file("/dest/file", b"old");
remote.fault.set(Some(Fault::RemoveTargetOnClose));
let answers = Answers::new(vec![TransferChoice::Overwrite, TransferChoice::Skip]);
@@ -1170,7 +1192,7 @@ fn remote_path(path: &str) -> RemotePath {
#[test]
fn deleting_a_tree_removes_links_but_never_what_they_point_to() {
runtime().block_on(async {
let remote = Remote::new(true);
let remote = Remote::new();
remote.dir("/keep", 0o755);
remote.file("/keep/important", b"data");
remote.dir("/gone", 0o755);
@@ -1198,7 +1220,7 @@ fn deleting_a_tree_removes_links_but_never_what_they_point_to() {
#[test]
fn recursive_permissions_skip_links_and_can_keep_directories_searchable() {
runtime().block_on(async {
let remote = Remote::new(true);
let remote = Remote::new();
remote.dir("/site", 0o700);
remote.dir("/site/css", 0o700);
remote.file("/site/css/a.css", b"a");
@@ -1231,7 +1253,7 @@ fn recursive_permissions_skip_links_and_can_keep_directories_searchable() {
#[test]
fn rename_and_create_refuse_existing_names() {
runtime().block_on(async {
let remote = Remote::new(true);
let remote = Remote::new();
remote.file("/a", b"a");
remote.file("/b", b"b");
let error = super::operations::run(
@@ -1329,7 +1351,7 @@ fn download_copies_trees_links_and_empty_directories_in_order() {
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);
let remote = Remote::new();
remote.short_reads.set(short_reads);
remote.dir("/srv/中文 目录", 0o755);
remote.file("/srv/中文 目录/large.bin", &data);
@@ -1385,7 +1407,7 @@ fn download_resumes_from_the_partial_file_after_a_lost_connection() {
runtime().block_on(async {
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let data: Vec<u8> = (0..300_000).map(|i| (i % 253) as u8).collect();
let remote = Remote::new(true);
let remote = Remote::new();
remote.file("/big", &data);
remote.fault.set(Some(Fault::Read(5)));
let journal = DownloadJournal::new(tmp.path().join("journal"));
@@ -1440,7 +1462,7 @@ fn download_asks_before_overwriting_and_keeps_the_replaced_permissions() {
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap();
}
let remote = Remote::new(true);
let remote = Remote::new();
remote.file("/file", b"new contents");
let journal = DownloadJournal::new(tmp.path().join("journal"));
let answers = Answers::new(vec![TransferChoice::Skip]);
@@ -1484,7 +1506,7 @@ fn download_asks_before_overwriting_and_keeps_the_replaced_permissions() {
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);
let remote = Remote::new();
remote.file("/file", &vec![7; 200_000]);
remote.fault.set(Some(Fault::Read(4)));
let journal = DownloadJournal::new(tmp.path().join("journal"));
@@ -1554,7 +1576,7 @@ fn a_changed_remote_file_is_not_resumed_and_discard_cleans_up() {
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);
let remote = Remote::new();
remote.file("/file", &vec![1; 100_000]);
let answers = Answers::new(vec![]);
let mut batch = download_batch(
@@ -1745,7 +1767,7 @@ fn an_scp_upload_takes_the_destination_name_and_keeps_the_execute_bits() {
let source = tmp.path().join("build.sh");
std::fs::write(&source, b"#!/bin/sh\n").unwrap();
std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o755)).unwrap();
let remote = Remote::new(true);
let remote = Remote::new();
let request = UploadRequest::scp(source.clone(), remote_path("/dest/deploy.sh"))
.resolved(remote_path("/dest"), Some("deploy.sh".into()));
@@ -1797,7 +1819,7 @@ fn an_scp_download_takes_the_destination_name_and_keeps_the_execute_bits() {
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let out = tmp.path().join("out");
std::fs::create_dir(&out).unwrap();
let remote = Remote::new(true);
let remote = Remote::new();
remote.file("/srv/run.sh", b"#!/bin/sh\n");
remote
.nodes
@@ -1856,7 +1878,7 @@ fn an_scp_download_takes_the_destination_name_and_keeps_the_execute_bits() {
#[test]
fn a_text_file_is_read_whole_through_short_and_reordered_reads() {
runtime().block_on(async {
let remote = Remote::new(true);
let remote = Remote::new();
let text: String = (0..40_000).map(|i| format!("line {i}\n")).collect();
remote.file("/etc/app.conf", text.as_bytes());
remote.short_reads.set(true);
@@ -1875,7 +1897,7 @@ fn a_text_file_is_read_whole_through_short_and_reordered_reads() {
#[test]
fn a_link_is_followed_to_the_file_it_points_at() {
runtime().block_on(async {
let remote = Remote::new(true);
let remote = Remote::new();
remote.file("/srv/real.conf", b"old\n");
remote.link("/etc/app.conf", "/srv/real.conf");
let path = remote_path("/etc/app.conf");
@@ -1896,7 +1918,7 @@ fn a_link_is_followed_to_the_file_it_points_at() {
#[test]
fn directories_large_files_and_binary_files_are_refused() {
runtime().block_on(async {
let remote = Remote::new(true);
let remote = Remote::new();
remote.dir("/etc", 0o755);
remote.file("/var/big.log", &vec![b'x'; EDIT_LIMIT as usize + 1]);
let mut binary = vec![b'a'; 1_000_000];
@@ -1924,7 +1946,7 @@ fn directories_large_files_and_binary_files_are_refused() {
#[test]
fn a_save_writes_in_place_and_keeps_the_permissions() {
runtime().block_on(async {
let remote = Remote::new(true);
let remote = Remote::new();
let long: Vec<u8> = (0..100_000u32).map(|i| (i % 251) as u8 + 1).collect();
remote.file("/etc/app.conf", &long);
let path = remote_path("/etc/app.conf");
@@ -1952,7 +1974,7 @@ fn a_save_writes_in_place_and_keeps_the_permissions() {
#[test]
fn a_save_over_a_file_changed_or_removed_since_is_refused() {
runtime().block_on(async {
let remote = Remote::new(true);
let remote = Remote::new();
remote.file("/etc/app.conf", b"mine\n");
let path = remote_path("/etc/app.conf");
let file = read_text(&remote, &path).await.unwrap();
@@ -1980,7 +2002,7 @@ fn a_save_over_a_file_changed_or_removed_since_is_refused() {
#[test]
fn a_save_that_fails_after_truncating_is_interrupted() {
runtime().block_on(async {
let remote = Remote::new(true);
let remote = Remote::new();
remote.file("/etc/app.conf", &vec![b'a'; 200_000]);
remote.fault.set(Some(Fault::Write(2)));
let error = write_in_place(
@@ -2123,7 +2145,7 @@ fn local_files_are_edited_in_place_through_links() {
#[test]
fn a_binary_file_is_read_whole_for_a_preview_within_its_limit() {
runtime().block_on(async {
let remote = Remote::new(true);
let remote = Remote::new();
let image: Vec<u8> = (0..300_000u32).map(|i| (i % 256) as u8).collect();
remote.file("/srv/photo.png", &image);
remote.short_reads.set(true);
@@ -2169,7 +2191,7 @@ fn a_sync_copies_only_what_changed_since_the_last() {
.unwrap();
std::fs::write(dist.join("assets/logo.png"), b"png").unwrap();
std::os::unix::fs::symlink("index.html", dist.join("home.html")).unwrap();
let remote = Remote::new(true);
let remote = Remote::new();
remote.dir("/srv", 0o755);
let answers = Answers::new(vec![]);
@@ -2217,7 +2239,7 @@ fn a_sync_with_delete_removes_what_is_not_here_and_nothing_it_cannot_see() {
std::fs::set_permissions(dist.join("locked"), std::fs::Permissions::from_mode(0o000))
.unwrap();
let remote = Remote::new(true);
let remote = Remote::new();
remote.dir("/srv/app", 0o755);
remote.file("/srv/app/keep.txt", b"k");
remote.file("/srv/app/extra.txt", b"x");
+29 -37
View File
@@ -532,25 +532,6 @@ impl UploadBatch {
uploaded += length;
self.meter.advance(length, uploaded, control);
}
control
.run(
fs.attributes(
&handle,
u32::try_from(source_metadata.modified_ns / 1_000_000_000).ok(),
record
.original
.as_ref()
.and_then(FileMetadata::permissions)
.or_else(|| {
self.preserve_mode
.then(|| item.metadata.permissions())
.flatten()
.map(|mode| mode & 0o777)
}),
),
)
.await?;
control.run(fs.sync(&handle)).await?;
Ok(())
}
.await;
@@ -559,7 +540,24 @@ impl UploadBatch {
let close = fs.close(&handle).await;
result?;
close?;
Ok(())
control
.run(
fs.attributes(
&record.temporary,
u32::try_from(source_metadata.modified_ns / 1_000_000_000).ok(),
record
.original
.as_ref()
.and_then(FileMetadata::permissions)
.or_else(|| {
self.preserve_mode
.then(|| item.metadata.permissions())
.flatten()
.map(|mode| mode & 0o777)
}),
),
)
.await
}
async fn matches_expected_item<F: RemoteFs>(
&self,
@@ -603,7 +601,7 @@ impl UploadBatch {
if fs.metadata(&record.target).await?.is_none()
&& fs.metadata(&record.backup).await?.is_some()
{
fs.rename(&record.backup, &record.target, false).await?;
fs.rename(&record.backup, &record.target).await?;
}
bail!(t!("sftp.upload.publish_unconfirmed"));
}
@@ -635,24 +633,22 @@ impl UploadBatch {
record.original = current;
self.journal.save(record).await?;
if let Some(source_metadata) = &record.source_metadata {
let handle = control.run(fs.open(&record.temporary, false)).await?;
let result = control
control
.run(fs.attributes(
&handle,
&record.temporary,
u32::try_from(source_metadata.modified_ns / 1_000_000_000).ok(),
record.original.as_ref().and_then(FileMetadata::permissions),
))
.await;
let closed = fs.close(&handle).await;
result?;
closed?;
.await?;
}
}
if backup.is_none() && record.original.is_some() && !fs.atomic_replace() {
// Over a file, the old one is moved aside first and removed once
// the new one is in its place: no `posix-rename@openssh.com`.
if backup.is_none() && record.original.is_some() {
record.phase = PublishPhase::BackingUp;
self.journal.save(record).await?;
control
.run(fs.rename(&record.target, &record.backup, false))
.run(fs.rename(&record.target, &record.backup))
.await?;
record.phase = PublishPhase::BackedUp;
self.journal.save(record).await?;
@@ -660,11 +656,7 @@ impl UploadBatch {
record.phase = PublishPhase::Publishing;
self.journal.save(record).await?;
let result = control
.run(fs.rename(
&record.temporary,
&record.target,
fs.atomic_replace() && record.original.is_some() && backup.is_none(),
))
.run(fs.rename(&record.temporary, &record.target))
.await;
if let Err(error) = result {
if !super::client::is_network_error(&error)
@@ -672,7 +664,7 @@ impl UploadBatch {
&& fs.metadata(&record.target).await?.is_none()
&& fs.metadata(&record.backup).await?.is_some()
{
fs.rename(&record.backup, &record.target, false).await?;
fs.rename(&record.backup, &record.target).await?;
record.phase = PublishPhase::Ready;
self.journal.save(record).await?;
}
@@ -701,7 +693,7 @@ impl UploadBatch {
)?;
if fs.metadata(&record.backup).await?.is_some() {
if fs.metadata(&record.target).await?.is_none() {
fs.rename(&record.backup, &record.target, false).await?;
fs.rename(&record.backup, &record.target).await?;
} else {
bail!(t!("sftp.upload.backup_and_target"));
}
+177
View File
@@ -1271,6 +1271,50 @@ async fn sftp_tab_reconnects_from_its_tab_bar_and_reports_at_the_bottom_left(
cx.update(|cx| assert_eq!(workspace.read(cx).explorers_of(HostId(DB_01), cx).len(), 2));
}
/// The 目录列表 select's places, Windows drives among them, are read again
/// with each local listing: a drive plugged in shows up on the next refresh.
#[gpui_kit::test]
async fn local_places_are_read_again_with_each_listing(cx: &mut TestAppContext) {
use shellrs::app::{ExplorerAction, ExplorerCommand};
use shellrs::sftp::{DriveKind, Place};
let local = FakeLocalDirectory::default();
let system = (Place::Drive('C', DriveKind::Local), "/c".to_string());
local
.places
.lock()
.unwrap()
.push((system.0, system.1.clone().into()));
let (handle, workspace) =
open_workspace_with_services(cx, Arc::new(FakeSftpProvider::default()), local.clone());
open_test_explorer(cx, handle).await;
let places = |cx: &mut TestAppContext| {
cx.update(|cx| {
let explorer = workspace.read(cx).explorer(ExplorerId(SFTP_TAB)).unwrap();
explorer.read(cx).local().read(cx).places().to_vec()
})
};
assert_eq!(places(cx), std::slice::from_ref(&system));
let stick = (Place::Drive('E', DriveKind::Removable), "/e".to_string());
local
.places
.lock()
.unwrap()
.push((stick.0, stick.1.clone().into()));
cx.update_window(handle.into(), |_, window, cx| {
window.dispatch_action(
Box::new(ExplorerAction::new(
ExplorerId(SFTP_TAB),
ExplorerCommand::Refresh { remote: false },
)),
cx,
);
})
.unwrap();
cx.run_until_parked();
assert_eq!(places(cx), [system, stick]);
}
#[gpui_kit::test]
async fn sftp_file_commands_confirm_validate_and_send_one_operation(cx: &mut TestAppContext) {
use shellrs::app::{ExplorerAction, ExplorerCommand};
@@ -1514,6 +1558,139 @@ async fn sftp_dragging_outside_the_names_selects_the_rows_crossed(cx: &mut TestA
cx.update(|cx| assert_eq!(pane_selection(&workspace, true, cx), ["目录", "链接目录"]));
}
/// A click on a column title sorts by the column, as one on its arrow does:
/// descending, ascending, then back to by name. The order outlives a
/// refresh, after which the next click carries on from it.
#[gpui_kit::test]
async fn sftp_clicking_a_column_title_sorts_by_it(cx: &mut TestAppContext) {
use shellrs::app::{ExplorerAction, ExplorerCommand};
let provider = Arc::new(FakeSftpProvider::default());
let (handle, workspace) = open_workspace_with_sftp(cx, provider);
open_test_explorer(cx, handle).await;
let pane = ("local-pane", SFTP_TAB);
let names = |cx: &mut TestAppContext| {
cx.run_until_parked();
cx.update(|cx| {
let explorer = workspace.read(cx).explorer(ExplorerId(SFTP_TAB)).unwrap();
let entries = explorer.read(cx).local().read(cx).entries(cx);
entries
.into_iter()
.map(|entry| entry.name.to_string())
.collect::<Vec<_>>()
})
};
let click_size = |cx: &mut TestAppContext| {
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.within(pane).click("column:size", cx);
})
.unwrap();
};
let by_name = ["..", "目录", "链接目录", "文件 乙.txt", "文件 甲.txt"];
assert_eq!(names(cx), by_name);
// Folders stay ahead of files whichever way.
click_size(cx);
let descending = ["..", "链接目录", "目录", "文件 乙.txt", "文件 甲.txt"];
assert_eq!(names(cx), descending);
cx.update_window(handle.into(), |_, window, cx| {
window.dispatch_action(
Box::new(ExplorerAction::new(
ExplorerId(SFTP_TAB),
ExplorerCommand::Refresh { remote: false },
)),
cx,
);
})
.unwrap();
assert_eq!(names(cx), descending);
click_size(cx);
assert_eq!(
names(cx),
["..", "目录", "链接目录", "文件 甲.txt", "文件 乙.txt"]
);
click_size(cx);
assert_eq!(names(cx), by_name);
}
/// A column dragged wider stays so with the next listing, which gives the
/// table its columns again.
#[gpui_kit::test]
async fn sftp_column_widths_outlive_a_refresh(cx: &mut TestAppContext) {
use gpui_kit::{Modifiers, MouseDownEvent, MouseUpEvent};
use shellrs::app::{ExplorerAction, ExplorerCommand};
let provider = Arc::new(FakeSftpProvider::default());
let (handle, _workspace) = open_workspace_with_sftp(cx, provider);
open_test_explorer(cx, handle).await;
let pane = ("local-pane", SFTP_TAB);
let width = |cx: &mut TestAppContext| {
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window
.within(pane)
.find(("col-header", 1usize))
.bounds()
.size
.width
})
.unwrap()
};
let before = width(cx);
// Drag the boundary right of 大小 by 60 pixels.
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
let bounds = window.within(pane).find(("col-header", 1usize)).bounds();
let start = point(bounds.right() - px(1.), bounds.center().y);
let event = |x: f32| MouseMoveEvent {
position: point(start.x + px(x), start.y),
pressed_button: Some(MouseButton::Left),
modifiers: Modifiers::default(),
};
window.dispatch_event(
MouseDownEvent {
button: MouseButton::Left,
position: start,
modifiers: Modifiers::default(),
click_count: 1,
first_mouse: false,
}
.to_platform_input(),
cx,
);
for x in [10., 30., 60.] {
window.dispatch_event(event(x).to_platform_input(), cx);
window.render_frame(cx);
}
window.dispatch_event(
MouseUpEvent {
button: MouseButton::Left,
position: point(start.x + px(60.), start.y),
modifiers: Modifiers::default(),
click_count: 1,
}
.to_platform_input(),
cx,
);
})
.unwrap();
let dragged = width(cx);
assert!(dragged > before + px(40.), "{before:?} -> {dragged:?}");
cx.update_window(handle.into(), |_, window, cx| {
window.dispatch_action(
Box::new(ExplorerAction::new(
ExplorerId(SFTP_TAB),
ExplorerCommand::Refresh { remote: false },
)),
cx,
);
})
.unwrap();
assert_eq!(width(cx), dragged);
}
/// As in WinSCP without full row select, only the name cell is the item: a
/// click on the rest of a row, or below the rows, is a click on empty space
/// and clears the selection. A ⌘ click there, or a click on a column title,
+5
View File
@@ -271,6 +271,8 @@ pub struct FakeLocalDirectory {
pub calls: Arc<Mutex<Vec<String>>>,
/// The files the editor reads and writes, in memory.
pub files: Arc<Mutex<std::collections::BTreeMap<std::path::PathBuf, Vec<u8>>>>,
/// The places it offers, such as a drive plugged in.
pub places: Arc<Mutex<Vec<(shellrs::sftp::Place, std::path::PathBuf)>>>,
}
impl FakeLocalDirectory {
@@ -287,6 +289,9 @@ impl LocalDirectoryProvider for FakeLocalDirectory {
fn list(&self, path: &std::path::Path) -> anyhow::Result<DirectoryListing> {
Ok(fake_listing(path.to_str().unwrap()))
}
fn places(&self) -> Vec<(shellrs::sftp::Place, std::path::PathBuf)> {
self.places.lock().unwrap().clone()
}
fn trash(&self, paths: &[std::path::PathBuf]) -> anyhow::Result<()> {
self.record(format!("trash {paths:?}"))
}