Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9799f88ade | ||
|
|
dc5adddd06 | ||
|
|
db0a0faf05 | ||
|
|
026e0d44d8 | ||
|
|
e91bab0d09 | ||
|
|
5ca6e53366 | ||
|
|
0aa1d4452d | ||
|
|
5fdfce239a | ||
|
|
3a9e72eab3 | ||
|
|
27822e7315 | ||
|
|
e1600ce05c | ||
|
|
5be3f56b0c | ||
|
|
eadbe6481a | ||
|
|
ecf9d92e47 |
No files matched your search
@@ -1,7 +1,7 @@
|
||||
# A release: push a tag `v<version>` matching Cargo.toml's version.
|
||||
#
|
||||
# verify the tag, the CHANGELOG section, the update keys
|
||||
# macos universal app: signed, notarized, stapled; update zip and DMG
|
||||
# macos per-architecture apps: signed, notarized, stapled; update zip and DMG
|
||||
# windows per-user Inno Setup installer (unsigned for now)
|
||||
# linux AppImage
|
||||
# publish (environment `release`, approved by hand) uploads to R2 behind
|
||||
@@ -83,8 +83,13 @@ jobs:
|
||||
macos:
|
||||
needs: verify
|
||||
runs-on: macos-14
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [aarch64, x86_64]
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
ARCH: ${{ matrix.arch }}
|
||||
VERSION: ${{ needs.verify.outputs.version }}
|
||||
SHELLRS_UPDATE_CHANNEL: ${{ needs.verify.outputs.channel }}
|
||||
SHELLRS_COMMIT: ${{ github.sha }}
|
||||
@@ -92,18 +97,17 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: rustup toolchain install
|
||||
- run: rustup target add aarch64-apple-darwin x86_64-apple-darwin
|
||||
- run: rustup target add "$ARCH-apple-darwin"
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Build both halves and join them
|
||||
with:
|
||||
key: macos-${{ matrix.arch }}
|
||||
- name: Build the architecture-specific app
|
||||
run: |
|
||||
export SHELLRS_BUILD_DATE=$(date -u +%Y-%m-%d)
|
||||
cargo build --release --locked --bin shellrs --target aarch64-apple-darwin
|
||||
cargo build --release --locked --bin shellrs --target x86_64-apple-darwin
|
||||
cargo build --release --locked --bin shellrs --target "$ARCH-apple-darwin"
|
||||
mkdir -p dist build
|
||||
lipo -create -output build/shellrs \
|
||||
target/aarch64-apple-darwin/release/shellrs \
|
||||
target/x86_64-apple-darwin/release/shellrs
|
||||
packaging/macos/bundle.sh build/shellrs "$VERSION" "$GITHUB_RUN_NUMBER" build
|
||||
lipo "target/$ARCH-apple-darwin/release/shellrs" -verify_arch "${ARCH/aarch64/arm64}"
|
||||
packaging/macos/bundle.sh "target/$ARCH-apple-darwin/release/shellrs" "$VERSION" "$GITHUB_RUN_NUMBER" build
|
||||
- name: Sign
|
||||
env:
|
||||
CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE_P12 }}
|
||||
@@ -147,12 +151,12 @@ jobs:
|
||||
xcrun stapler staple build/ShellRS.app
|
||||
# The update package: the stapled bundle, zipped the way the
|
||||
# updater unzips it (ditto keeps the signature intact).
|
||||
ditto -c -k --keepParent build/ShellRS.app "dist/ShellRS-$VERSION-macos-universal.app.zip"
|
||||
ditto -c -k --keepParent build/ShellRS.app "dist/ShellRS-$VERSION-macos-$ARCH.app.zip"
|
||||
# The first-install disk image, with the Applications shortcut.
|
||||
mkdir -p "$RUNNER_TEMP/dmg"
|
||||
cp -R build/ShellRS.app "$RUNNER_TEMP/dmg/"
|
||||
ln -s /Applications "$RUNNER_TEMP/dmg/Applications"
|
||||
dmg="dist/ShellRS-$VERSION-macos-universal.dmg"
|
||||
dmg="dist/ShellRS-$VERSION-macos-$ARCH.dmg"
|
||||
# hdiutil's own estimate of the image size once came out too small
|
||||
# for the app ("No space left on device"), so the size is given,
|
||||
# with room to spare; the compressed image is about half the app.
|
||||
@@ -171,7 +175,7 @@ jobs:
|
||||
spctl -a -vv -t install "$dmg"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos
|
||||
name: macos-${{ matrix.arch }}
|
||||
path: dist/*
|
||||
|
||||
windows:
|
||||
@@ -265,7 +269,7 @@ jobs:
|
||||
- name: Collect
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cp artifacts/macos/* artifacts/windows/* artifacts/linux/* dist/
|
||||
cp artifacts/macos-aarch64/* artifacts/macos-x86_64/* artifacts/windows/* artifacts/linux/* dist/
|
||||
cp artifacts/notes/notes.md notes.md
|
||||
ls -l dist
|
||||
- name: Signing key
|
||||
|
||||
@@ -2,6 +2,70 @@
|
||||
|
||||
每个版本一节,标题写成 `## [版本号]`,可以在后面加日期。发布流程(`.github/workflows/release.yml`)把与 tag 同版本的那一节原样作为更新说明:显示在 GitHub Release 和官网的更新日志里(ShellRS 的更新对话框用「查看更新内容」打开官网的更新日志),用 Markdown 书写,面向使用者。
|
||||
|
||||
## [0.1.8]
|
||||
|
||||
### 新增
|
||||
|
||||
- 设置 › 终端新增「终端交互」:选中即复制,右键可设为粘贴、显示菜单或无动作。
|
||||
|
||||
### 改进
|
||||
|
||||
- macOS 安装包分为 Apple Silicon 和 Intel 两种,下载更小。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复 ESXi 等主机「测试连接」报需要键盘交互式认证、保存的密码不起作用。
|
||||
- 修复启动后不久检查更新时,「重启并安装」报找不到文件。
|
||||
|
||||
## [0.1.8-beta.1]
|
||||
|
||||
### 新增
|
||||
|
||||
- 设置 › 终端新增「终端交互」:选中即复制,右键可设为粘贴、显示菜单或无动作。
|
||||
|
||||
### 改进
|
||||
|
||||
- macOS 安装包分为 Apple Silicon 和 Intel 两种,下载更小。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复 ESXi 等主机「测试连接」报需要键盘交互式认证、保存的密码不起作用。
|
||||
|
||||
## [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]
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -70,6 +70,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH
|
||||
- 只认 UTF-8(不做 GBK),上限 5 MB,约 15 种语言语法高亮。
|
||||
- 远程文件原地改写:截断后写原文件,不走 `.filepart` 加改名。保存前核对,被改过就问「覆盖」。
|
||||
- **设置 › 应用 › 窗口**:「记住窗口大小」(含最大化)和「记住窗口位置」两个开关,默认都开,下次启动生效。屏幕不在了或窗口不在屏幕上就居中,比屏幕大就缩小;全屏不记。
|
||||
- **终端交互**:「选中复制」默认关;「右键行为」无动作 / 粘贴 / 显示菜单,默认显示菜单。
|
||||
- **终端通知**:只做程序请求的(OSC 9 / 777)和响铃,不做 OSC 133 长命令通知:那要每台服务器装 shell 集成,收益不抵成本。不在前台发系统通知,在前台而终端在别的标签发应用内通知;响铃在终端就在眼前时不提醒。
|
||||
- **主题**:「应用外观」(浅色 / 深色 / 跟随系统)决定深浅;「外观 › 主题」左右两栏里浅色、深色各选一套,按当前外观生效。组里第一项「界面跟随主题」默认关闭:界面是中性的灰(浅色是 gpui-kit 默认主题,深色见下条),只有终端按主题配色;打开后界面也按主题配色。用户试过默认跟随,觉得中性界面更合适,别改回去。
|
||||
- 默认深色(ShellRS Dark)不用 gpui-kit 的近黑,用参照微信深色的炭灰:底色 `#1e1e1f`,标题栏、标签栏、状态栏 `#2f2f30`,边框和悬停 `#3b3b3d`,文字 `#e0e0e4`。用户嫌近黑刺眼。次要文字比微信的 `#7b7b7f` 亮一些(`#929296`),保持对比度 ≥ 4.5。侧栏三个列表和「最近连接」的选中行同色(`selected_row_color`):浅色是悬停色三倍的对比,深色那样太刺眼,用加深后的悬停色,列表和表格的悬停只有它的一半(`DARK_HOVER_SHARE`)。两种外观下悬停和选中都要分得开。页面「重置」对卡片和开关各管各的。
|
||||
@@ -280,6 +281,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`。「名称」单元格才是项目,拖文件只从图标和文件名开始,其余地方按下拖动是框选。
|
||||
@@ -355,7 +357,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
- **终端颜色走全局量 `TerminalColors`**(`theme.rs`):`settings::apply` 按当前外观设成那一栏选中的主题,系统外观变化时也重设。终端格子、边距、选区、链接、颜色查询(OSC 4 / 10 / 11 / 12)和设置里的两个预览都从它取,不用界面主题的 `background` / `foreground`;查找高亮仍用界面的 `warning`。主题的 `key` 存进 `settings.json`,发布后不能改;读不出或深浅不符的 key 退回该外观的默认。
|
||||
- **界面配色由所选主题推出**(`settings/app_theme.rs`):从 gpui-kit 默认的浅色或深色主题出发重新着色。灰色按它在默认背景与文字之间的位置,换到该主题背景与文字之间;默认里用作可读文字的灰色(对比度 ≥ 4.5)至少保留该主题正文对比度的 80%(最高 4.5)。`base.*` 按名字换成对应的 ANSI 色,其余彩色按色相换;`.light` 和彩色的 `*foreground` 删掉,交给 gpui-kit 自己推。ShellRS Light(以及「界面跟随主题」关闭时的浅色,`TerminalThemeSettings::app_theme`)直接用 gpui-kit 的默认浅色主题;ShellRS Dark(以及关闭时的深色)是 gpui-kit 默认深色主题按 `SOFT_DARK` 把每级灰换成炭灰,彩色不动。`settings::apply` 按名字(`theme_name`)判断要不要重建,把结果放进 `Theme` 的浅色或深色槽再 `Theme::change`。
|
||||
- **链接**:正则找出的地址和 OSC 8 标出的都算,只认 http / https(`links.rs`);OSC 8 的格子优先于正则。一直画成蓝色(终端主题的 ANSI 蓝,悬停时亮蓝;界面主题的 `link` 和正文同色)加下划线,⌘ / Ctrl 单击才打开,普通单击照旧选文字。
|
||||
- **鼠标上报**(`mouse.rs` 是纯编码):按住 Shift 不上报,右键始终是自己的菜单。写入走 `engine.write`,不算用户输入。
|
||||
- **鼠标上报**(`mouse.rs` 是纯编码):按住 Shift 不上报,右键从不上报,按「右键行为」处理。写入走 `engine.write`,不算用户输入。
|
||||
- **关键字高亮只叠加在显示上**(`highlight.rs`)。`snapshot()` 每帧对可见行把折行接成逻辑行,用 `regex` crate 匹配,结果写进 `TerminalCell.highlight`,prepaint 改文字色和粗细;不存坐标,所以缩放重排、回滚都不用失效处理。
|
||||
- 不用 alacritty 的 `RegexSearch`(查找、链接用的那套):它的 DFA 编不了 `\b`,`^` 只在第一行生效,`$` 会碰到行尾补的空格。
|
||||
- 通知在解析线程上:按 `\n` 切开输出,在 LF **之前**读光标所在的逻辑行(这时 `\r` 和颜色都已生效),锁外匹配。备用屏和同步更新进行中不读。
|
||||
|
||||
Generated
+1
-1
@@ -7738,7 +7738,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shellrs"
|
||||
version = "0.1.6"
|
||||
version = "0.1.8"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "shellrs"
|
||||
version = "0.1.6"
|
||||
version = "0.1.8"
|
||||
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",
|
||||
] }
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -118,7 +119,8 @@ Download from [shellrs.com/download](https://shellrs.com/download), which offers
|
||||
|
||||
| System | Package | Notes |
|
||||
| --- | --- | --- |
|
||||
| macOS 11+ (Apple Silicon and Intel) | `ShellRS-<version>-macos-universal.dmg` | Signed and notarized. Open the DMG and drag ShellRS into Applications |
|
||||
| macOS 11+ (Apple Silicon) | `ShellRS-<version>-macos-aarch64.dmg` | Signed and notarized. Open the DMG and drag ShellRS into Applications |
|
||||
| macOS 11+ (Intel) | `ShellRS-<version>-macos-x86_64.dmg` | Signed and notarized. Open the DMG and drag ShellRS into Applications |
|
||||
| Windows x64 | `ShellRS-<version>-windows-x86_64-setup.exe` | Installs per user, no administrator rights needed. The installer is not signed yet; if SmartScreen stops it, choose "More info › Run anyway" |
|
||||
| Linux x64 | `ShellRS-<version>-linux-x86_64.AppImage` | `chmod +x` and run. Needs glibc 2.35 or newer (Ubuntu 22.04, Debian 12 and later) |
|
||||
|
||||
|
||||
+3
-1
@@ -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>
|
||||
@@ -118,7 +119,8 @@ ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SF
|
||||
|
||||
| 系统 | 安装包 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| macOS 11+(Apple Silicon 和 Intel) | `ShellRS-<版本>-macos-universal.dmg` | 已签名并经过 Apple 公证。打开 DMG,把 ShellRS 拖进「应用程序」 |
|
||||
| macOS 11+(Apple Silicon) | `ShellRS-<版本>-macos-aarch64.dmg` | 已签名并经过 Apple 公证。打开 DMG,把 ShellRS 拖进「应用程序」 |
|
||||
| macOS 11+(Intel) | `ShellRS-<版本>-macos-x86_64.dmg` | 已签名并经过 Apple 公证。打开 DMG,把 ShellRS 拖进「应用程序」 |
|
||||
| Windows x64 | `ShellRS-<版本>-windows-x86_64-setup.exe` | 按当前用户安装,不需要管理员权限。安装程序暂未签名,SmartScreen 拦下时点「更多信息 › 仍要运行」 |
|
||||
| Linux x64 | `ShellRS-<版本>-linux-x86_64.AppImage` | `chmod +x` 后直接运行,需要 glibc 2.35 以上(Ubuntu 22.04、Debian 12 及更新的版本) |
|
||||
|
||||
|
||||
+7
-6
@@ -16,7 +16,7 @@ Each item saved in the left sidebar is a host: an address, a port and a way to a
|
||||
|
||||
The host dialog’s “Authentication” has three options:
|
||||
|
||||
- **Password**: enter a username and password; the password is kept in the system keychain. Leave the password empty to be asked at each connection. Only the password is used: the SSH agent and private keys aren’t tried first.
|
||||
- **Password**: enter a username and password; the password is kept in the system keychain. Leave the password empty to be asked at each connection. Only the password is used: the SSH agent and private keys aren’t tried first. When the server takes passwords only through keyboard-interactive authentication (ESXi, for one), the saved password answers its single hidden question; other questions, such as a verification code, are still asked.
|
||||
- **Credential**: choose a saved credential. The username and the way to log in both come from the credential (see “Credentials” below), so the dialog no longer asks for a username or password. “Test connection” also uses the password saved in the credential. To log in with a private key, create a key credential.
|
||||
- **No password**: enter only the username. ShellRS tries logging in without authentication, then the SSH agent, then the default private keys in `~/.ssh` (`id_ed25519`, `id_ecdsa`, `id_rsa`), the same as the `ssh` command does by default. If the server asks for a password, the connection fails with the reason instead of asking; if the server asks for a verification code after the key, you’re still asked for it.
|
||||
|
||||
@@ -108,6 +108,7 @@ The “Settings” button at the bottom of the sidebar, or ⌘, (Ctrl+, on Windo
|
||||
- **Appearance → Interface → Appearance**: Dark, Light or System, System by default; System switches along with the system’s light and dark mode. The theme button in the title bar also changes this setting.
|
||||
- **Appearance → Theme**: the left column has light themes and the right column dark themes; choose one in each. The light appearance uses the one chosen on the left, the dark appearance the one on the right; with System they switch along with the system, and the same happens when you switch with the title bar’s theme button. By default only terminals take the chosen theme’s colors, and the interface stays a neutral gray (the dark one is an easy-on-the-eyes charcoal, not pure black). Turn on “Match interface to theme” at the top and the interface takes the theme’s colors too: the sidebar, tab bar, dialogs and so on use the theme’s background and text colors, and states such as success, warning and error use the theme’s green, yellow and red. Each card shows a sample in that theme’s colors, and the chosen one has a blue check mark at its top right. The defaults are ShellRS Light and ShellRS Dark, which match the interface. There are 10 built-in light themes and 10 dark ones: light ShellRS Light, Solarized Light, Tokyo Day, Catppuccin Latte, Flexoki Light, One Half Light, Gruvbox Light, Kanagawa Lotus, Rosé Pine Dawn, Everforest Light; dark ShellRS Dark, Solarized Dark, Tokyo Night, Catppuccin Mocha, Flexoki Dark, One Half Dark, Gruvbox Dark, Kanagawa Wave, Dracula, Nord. The light and dark versions of the same theme sit on the same row of the two columns. Changes apply at once to every open terminal, and the font and highlighting previews use the current theme too; when vim or another program asks the terminal for its background color, it gets the current theme’s. **Not implemented yet: custom themes, importing color schemes, and per-host themes.**
|
||||
- **Terminal → Font**: Font family (lists only the monospaced fonts installed on this computer; the default is the system’s monospaced font, Menlo on macOS), Font size (10–28 pixels, default 13) and Line height (1.0–2.0 times the font size, default 1.54, the same as the earlier 20-pixel line height). Changes apply at once to every open terminal; in a terminal, ⌘= / ⌘- (Ctrl on Windows and Linux) also change the font size step by step, and ⌘0 goes back to the default. The preview below lays out letters, digits, easily confused characters, symbols, Chinese, tab stops and a command line the way the terminal does. If the font in the settings file isn’t installed on this computer, the default font is used.
|
||||
- **Terminal → Interaction**: with “Copy on select” on, text you select with the mouse is copied to the clipboard when you let go, without pressing ⌘C; it’s off by default, and a click that selects nothing leaves the clipboard as it was. “Right-click” sets what right-clicking in a terminal does: Show menu (the default), Paste (pastes the clipboard, like ⌘V) or Do nothing. With either of the last two, the menu’s Copy, Paste, Find and Clear screen are still on their shortcuts, and Open SFTP and Reconnect are in the tab’s context menu. Changes apply at once to every open terminal.
|
||||
- **Highlighting**: the main switch, the preview and the rules table, which color text in terminals by rule and alert you on matches; see “Terminal”.
|
||||
- **Keyboard shortcuts**: the shortcuts of the main features, in four groups (General, Tabs, Terminal, Editor); each row is a feature and its keys. Click the keys to get “Press a key combination”, then press a new combination to use it; Esc or clicking elsewhere cancels. The two buttons at the end of the row are Disable (the row then shows “None”; click again to restore the default) and Restore default; the “Reset All” button at the top of the page restores them all. Changes take effect at once, and the keys shown in tooltips and context menus follow.
|
||||
- A combination needs ⌘, ⌥ or ⌃ (Ctrl or Alt on Windows and Linux), or must be one of F1–F12; for “Switch to tab 1…9”, press any digit with a modifier, and all nine change together.
|
||||
@@ -191,13 +192,13 @@ Remote terminal tabs and SFTP tabs use the host’s system badge as their icon (
|
||||
|
||||
## Terminal
|
||||
|
||||
The terminal’s context menu has “Copy”, “Paste”, “Find…” and “Clear screen”; a remote terminal’s also has “Open SFTP”, “Reconnect” and “Disconnect”. “Disconnect” disconnects only this tab: the tab stays and can reconnect at any time, and other tabs and SFTP for the same host aren’t affected.
|
||||
The terminal’s context menu has “Copy”, “Paste”, “Find…” and “Clear screen”; a remote terminal’s also has “Open SFTP”, “Reconnect” and “Disconnect”. “Disconnect” disconnects only this tab: the tab stays and can reconnect at any time, and other tabs and SFTP for the same host aren’t affected. Settings › Terminal › Interaction can make right-click paste or do nothing instead, and can turn on “Copy on select”.
|
||||
|
||||
- **Scrollback**: each terminal keeps the last 10,000 lines. Scroll up with the wheel or trackpad and a scrollbar appears on the right (as in the app’s other lists, following the system’s “Show scroll bars” setting); drag it, or click the track to jump there. Full-screen programs such as vim, less and top have no scrollback and show no scrollbar.
|
||||
- **Find** (⌘F, or Ctrl+Shift+F on Windows and Linux): opens the find bar at the top right of the terminal and searches the screen and the scrollback for the literal text. An all-lowercase query ignores case; one with capital letters matches case. All matches are highlighted, the current one darker, and the find bar shows “current/total”. Enter or ⌘G (F3) goes to the next match (newer output), Shift+Enter or ⌘⇧G (Shift+F3) to the previous one (older output), wrapping around at the ends. Esc closes it. If text is selected when it opens, that text becomes the query.
|
||||
- **Clear screen** (⌘K, or Ctrl+Shift+K on Windows and Linux; when the terminal doesn’t have focus, ⌘K still focuses the sidebar’s search box): clears the screen and the scrollback, leaving only the prompt line the cursor is on, including anything typed but not yet entered. Clearing happens only locally; nothing is sent to the server. It isn’t available while a full-screen program such as vim or top is running.
|
||||
- **Links**: `http://` and `https://` addresses in the output, and links that programs mark explicitly (such as `ls --hyperlink` and gcc’s error messages), are shown in blue and underlined. Hold ⌘ (Ctrl on Windows and Linux) and click to open one in the system browser; a plain click still selects text. Rest the pointer on a link for a moment to see its real address: for a link a program marks, the text shown can differ from the address. A trailing period, comma or unmatched closing parenthesis isn’t part of the address, and a long address wrapped over two lines is still one link. Other schemes (such as `file://`) aren’t treated as links.
|
||||
- **Mouse**: when programs such as vim (`:set mouse=a`), htop, tmux (`set -g mouse on`) and mc ask for the mouse, clicks, drags and the scroll wheel all go to the program. Dragging with Shift held still selects text, and scrolling with Shift held still scrolls through the scrollback; right-clicking always opens ShellRS’s menu.
|
||||
- **Mouse**: when programs such as vim (`:set mouse=a`), htop, tmux (`set -g mouse on`) and mc ask for the mouse, clicks, drags and the scroll wheel all go to the program. Dragging with Shift held still selects text, and scrolling with Shift held still scrolls through the scrollback; right-clicks never go to the program and do what “Right-click” in the settings says.
|
||||
- **Notifications**: programs can ask for a notification (OSC 9, or OSC 777’s `notify;title;body`), and a terminal bell alerts you too. When ShellRS isn’t in front, you get a system notification (macOS asks for permission the first time), and clicking it takes you back to that terminal; when ShellRS is in front but the terminal is in another tab, a notice appears at the top right of the window, and clicking it switches there. Notifications that programs ask for appear even when the terminal is right in front of you; the bell alerts you only when you can’t see that terminal (Tab completion and the like ring it too). The notification title is the tab name, so you can tell which host it came from. Each terminal alerts at most once every 2 seconds for program notifications and once every 10 seconds for the bell. Settings › Terminal › Notifications can turn each one off. To be told when a command finishes, append `; printf '\e]9;Done\a'` or `; tput bel` to it; command-line tools such as Claude Code alert you the same way when they need you.
|
||||
- **Highlighting**: the rules in Settings › Highlighting show matching text in the rule’s color in every terminal, scrollback included. Only the display changes: the server’s output and copied text stay the same; matching keeps working after the window is resized and long lines wrap, and a word wrapped over two lines is colored in both parts. The feature is off by default and works only once you turn on “Highlight keywords” at the top of the page; turning it off disables all the rules at once. “Preview” shows sample output in the current terminal’s font and colors, and rule changes show up at once in the preview and in open terminals. The rules table has one rule per row, edited right in the table: “On”, “Regular expression” (case-sensitive; start it with `(?i)` to ignore case), “Note”, a color (click the swatch for a color picker, or type `#rrggbb` next to it) and “Notify”; the trash can at the end of the row deletes the rule. A mistake in a regular expression is pointed out below the field, and the rule doesn’t apply until it’s fixed. Rules apply from top to bottom, and where they overlap, the earlier rule wins; drag the handle at the start of a row to reorder. Three sample rules come to start with: `ERROR` in red, `WARN` in amber and IPv4 addresses in blue, all on and not notifying, so turning on the main switch shows the effect right away; you can change or delete them. A rule with “Notify” checked alerts you when you can’t see the terminal, the same way as the bell: a system notification when ShellRS isn’t in front, and a notice at the top right of the window when the terminal is in another tab. The title is “tab name: note” (the regular expression when the note is empty), and the body is the matching line. Only new output is checked, each line once it ends; each terminal alerts at most once every 10 seconds, and the same line redrawn over and over at most once every 5 minutes. Nothing is highlighted or alerted in full-screen programs such as vim, less, top and tmux. **Not implemented yet: per-host rules, text styles such as bold, and reordering with the keyboard.**
|
||||
- **Remote copy**: what remote programs copy with OSC 52 (such as tmux with `set -g set-clipboard on`, or vim’s OSC 52 plugins) goes straight to this computer’s clipboard. Remote programs can’t read this computer’s clipboard.
|
||||
@@ -251,7 +252,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 +282,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 +386,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.
|
||||
|
||||
|
||||
+7
-6
@@ -16,7 +16,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
|
||||
|
||||
主机对话框的「认证方式」有三种:
|
||||
|
||||
- **密码**:填用户名和密码,密码保存在系统钥匙串;密码留空则每次连接时询问。只用密码登录,不会先拿 SSH Agent 或私钥去试。
|
||||
- **密码**:填用户名和密码,密码保存在系统钥匙串;密码留空则每次连接时询问。只用密码登录,不会先拿 SSH Agent 或私钥去试。服务器只在键盘交互式认证里收密码时(如 ESXi),保存的密码会自动回答它唯一的隐藏问题;还有验证码等别的问题时照样询问。
|
||||
- **使用凭据**:选一条保存好的凭据,用户名和登录方式都来自凭据(见下面「凭据」),对话框里不再填用户名和密码。「测试连接」同样使用凭据保存的密码。要用私钥登录,就建一条密钥凭据。
|
||||
- **无密码**:只填用户名。依次尝试服务器免认证、SSH Agent 和 `~/.ssh` 下的默认私钥(`id_ed25519`、`id_ecdsa`、`id_rsa`),和命令行 `ssh` 默认的做法一样。服务器要求密码时连接失败并说明原因,不会询问;服务器在密钥之后再要验证码时照样询问。
|
||||
|
||||
@@ -108,6 +108,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
|
||||
- **外观 → 常规 → 应用外观**:深色、浅色、跟随系统,默认跟随系统;选「跟随系统」时随系统的深浅色切换。标题栏的主题按钮也会改写这一项。
|
||||
- **外观 → 主题**:左栏是浅色主题,右栏是深色主题,各选一套:应用外观为浅色时用左栏选中的那套,深色时用右栏的;跟随系统时随系统一起切换,标题栏的主题按钮切换时也一样。默认只有终端按所选主题配色,界面保持中性的灰色调(深色是不刺眼的炭灰,不是纯黑);打开上方的「界面跟随主题」,界面也按主题配色:侧栏、标签栏、对话框等用主题的底色和文字色,成功、警告、错误等状态用主题的绿、黄、红。每张卡片用该主题的颜色显示一段示例,选中的右上角有蓝色对勾。默认是 ShellRS Light 和 ShellRS Dark,与界面同色。内置浅色、深色各 10 套:浅色 ShellRS Light、Solarized Light、Tokyo Day、Catppuccin Latte、Flexoki Light、One Half Light、Gruvbox Light、Kanagawa Lotus、Rosé Pine Dawn、Everforest Light;深色 ShellRS Dark、Solarized Dark、Tokyo Night、Catppuccin Mocha、Flexoki Dark、One Half Dark、Gruvbox Dark、Kanagawa Wave、Dracula、Nord。同一主题的浅色版和深色版在两栏的同一行。改动立即作用于所有已打开的终端,字体和关键字高亮的预览也用当前主题;vim 等程序询问终端的背景色时,得到的是当前主题的颜色。**尚未实现:自定义主题、导入配色、按主机指定主题。**
|
||||
- **终端 → 字体配置**:字体(只列出本机安装的等宽字体,默认是系统的等宽字体,macOS 上为 Menlo)、字号(10–28 像素,默认 13)、行高(字号的 1.0–2.0 倍,默认 1.54,即原来的 20 像素行高)。改动立即作用于所有已打开的终端;在终端里按 ⌘= / ⌘-(其他平台 Ctrl)也能逐级调字号,⌘0 回到默认。下方的预览按终端的排法显示字母、数字、易混字符、符号、中文、制表符和一行命令。设置文件里的字体在本机没有安装时,使用默认字体。
|
||||
- **终端 → 终端交互**:「选中复制」打开后,用鼠标选中文字、松开时就复制到系统剪贴板,不用再按 ⌘C;默认关闭,单击没选中文字时剪贴板不变。「右键行为」决定在终端里按右键做什么:显示菜单(默认)、粘贴(直接粘贴剪贴板,同 ⌘V)、无动作。选后两种时,菜单里的复制、粘贴、查找、清屏仍可用快捷键,打开 SFTP、重新连接在标签的右键菜单里。改动立即作用于所有已打开的终端。
|
||||
- **关键字高亮**:总开关、预览和规则表,终端里按规则给文字上色、匹配时提醒,见「终端」一节。
|
||||
- **键盘快捷键**:主要功能的快捷键,分通用、标签、终端、编辑器四组,每行是功能名和它的按键。点按键进入「按下组合键」,按下新的组合就换上;Esc 或点别处放弃。行尾两个按钮:禁用(之后显示「无」,再点一下恢复默认)、恢复默认;页面上方的「重置」也能逐项恢复。改动立即生效,工具栏提示和右键菜单里显示的按键跟着变。
|
||||
- 组合键要带 ⌘、⌥ 或 ⌃(其他平台 Ctrl 或 Alt),或者是 F1–F12;「切换到标签 1…9」按任意一个带修饰键的数字即可,九个一起换。
|
||||
@@ -197,13 +198,13 @@ ShellRS /sessionname=标签名 sftp://用户[:密码]@地址[:端口]
|
||||
|
||||
## 终端
|
||||
|
||||
右键终端内容区可复制、粘贴、查找、清屏;远程终端另有打开 SFTP、重新连接和断开连接。断开连接只断开这一个标签,标签保留,可随时重新连接,同一台主机的其他标签和 SFTP 不受影响。
|
||||
右键终端内容区可复制、粘贴、查找、清屏;远程终端另有打开 SFTP、重新连接和断开连接。「设置 › 终端 › 终端交互」可以把右键改成直接粘贴或不做任何事,也可以打开「选中复制」。断开连接只断开这一个标签,标签保留,可随时重新连接,同一台主机的其他标签和 SFTP 不受影响。
|
||||
|
||||
- **回滚历史**:每个终端保留最近 10000 行。用滚轮或触控板往上翻看,这时右侧出现滚动条(和应用里其他列表一样,按系统的「显示滚动条」设置),可以拖动,点轨道直接跳到那个位置。vim、less、top 这类全屏程序没有回滚历史,不显示滚动条。
|
||||
- **查找**(⌘F,其他平台 Ctrl+Shift+F):在终端右上角打开查找条,按字面量搜索屏幕和回滚历史。查询全为小写时不区分大小写,含大写字母时区分。所有匹配高亮显示,当前项颜色更深,查找条显示「当前项/总数」。回车或 ⌘G(F3)跳到下一项(更新的输出),Shift+回车或 ⌘⇧G(Shift+F3)跳到上一项(更早的输出),到头后循环。Esc 关闭。打开时如有选中文字,会作为查询词。
|
||||
- **清屏**(⌘K,其他平台 Ctrl+Shift+K;焦点不在终端时 ⌘K 仍是聚焦侧栏的搜索框):清掉屏幕和回滚历史,只留下光标所在的提示符行,包括已输入但未回车的内容。清屏只在本地进行,不向服务器发送任何内容。vim、top 等全屏程序运行时不可用。
|
||||
- **链接**:输出里的 `http://`、`https://` 地址,以及程序明确标出的链接(如 `ls --hyperlink`、gcc 的报错),显示为带下划线的蓝色。按住 ⌘(其他平台 Ctrl)单击,用系统浏览器打开;不按时单击照旧是选择文字。鼠标在链接上停一会儿会显示它真正的地址:程序标出的链接,显示的文字可以和地址不同。地址末尾的句号、逗号和不成对的右括号不算在内;被折成两行的长地址仍是一个链接。其他协议(如 `file://`)不当作链接。
|
||||
- **鼠标**:vim(`:set mouse=a`)、htop、tmux(`set -g mouse on`)、mc 这类程序要求鼠标时,单击、拖动和滚轮都交给程序。按住 Shift 拖动仍是选择文字,按住 Shift 滚动仍是翻看回滚历史;右键始终是 ShellRS 的菜单。
|
||||
- **鼠标**:vim(`:set mouse=a`)、htop、tmux(`set -g mouse on`)、mc 这类程序要求鼠标时,单击、拖动和滚轮都交给程序。按住 Shift 拖动仍是选择文字,按住 Shift 滚动仍是翻看回滚历史;右键从不交给程序,按「右键行为」的设置处理。
|
||||
- **通知**:程序可以请求通知(OSC 9,或 OSC 777 的 `notify;标题;正文`),终端响铃时也会提醒。ShellRS 不在前台时发系统通知(macOS 第一次会询问是否允许),点通知回到那个终端;ShellRS 在前台但终端在别的标签时,在窗口右上角提示,点一下切过去。程序请求的通知即使终端就在眼前也会提示;响铃只在看不见那个终端时提醒(Tab 补全等也会响铃)。通知标题是标签名,看得出是哪台主机发的。同一终端的程序通知 2 秒内、响铃 10 秒内只提醒一次。设置 › 终端 › 通知可以分别关掉。命令跑完想被提醒,可以在后面加上 `; printf '\e]9;完成\a'` 或 `; tput bel`;Claude Code 等命令行工具需要你处理时也会这样提醒。
|
||||
- **关键字高亮**:设置 › 关键字高亮 里的规则把匹配的文字按规则的颜色显示在所有终端里,回滚历史里的也一样。只改显示:服务器的输出不变,复制出的文字也不变;窗口缩放、长行折行后照样匹配,被折成两行的词两段都上色。这个功能默认关闭,打开页面上方的「启用关键字高亮」后才生效,关掉它就一次停用全部规则;「预览」用当前终端的字体和配色显示一段示例输出,规则改动立即反映在预览和已打开的终端里。规则表一行一条,直接在表里改:「启用」、「正则表达式」(区分大小写,开头加 `(?i)` 不区分)、「备注」、颜色(点色块用取色器,或在旁边填 `#rrggbb`)、「通知」,行尾的垃圾桶删除这一条。正则写错时在框下方提示,这条规则暂不生效。规则从上到下应用,重叠处前面的规则优先;按住行首的把手拖动可调整顺序。初始有三条示例:`ERROR` 红色、`WARN` 琥珀色、IPv4 地址蓝色,都已启用、不通知,打开总开关就能看到效果,可以修改或删除。勾选「通知」的规则在看不到那个终端时提醒,和响铃一样:ShellRS 不在前台时发系统通知,终端在别的标签时在窗口右上角提示;标题是「标签名:备注」(没填备注时用正则),正文是匹配的那一行。只检查换了行的新输出,同一终端 10 秒内只提醒一次,反复重画的同一行 5 分钟内只提醒一次。vim、less、top、tmux 这类全屏程序里不高亮,也不提醒。**尚未实现:按主机区分规则、加粗等文字样式、用键盘调整顺序。**
|
||||
- **远程复制**:远程程序用 OSC 52 复制的内容(如 tmux 的 `set -g set-clipboard on`、vim 的 OSC 52 插件)直接进本机剪贴板。远程程序读不了本机剪贴板。
|
||||
@@ -257,7 +258,7 @@ bash 在 shell 退出时才把这次执行的命令写进历史文件,所以
|
||||
每一侧面板都有两行工具栏,下方是路径标签。面板太窄放不下时,按钮从右往左整个隐藏,不会换行(对应的命令在右键菜单和快捷键里仍然可用)。新开的 SFTP 标签本地、远程两侧各占一半;两侧和队列之间的分隔线拖到哪里,在这个标签关闭之前都会记住,切到别的标签再回来也不变:
|
||||
|
||||
- 第一行(导航):
|
||||
- 「目录列表」:列出从根到当前目录的每一级,本地另有主目录、桌面、文稿、下载,选中即前往。
|
||||
- 「目录列表」:列出从根到当前目录的每一级,本地另有主目录、桌面、文稿、下载,Windows 上还有各个驱动器(如「C: 本地磁盘」「D: 本地磁盘」),选中即前往。
|
||||
- 书签:点按钮打开「打开目录/书签」(见下);旁边的下拉箭头列出这台主机这一侧的书签,点击即前往,也可添加或移除当前路径。书签保存在本地数据库,删除主机时一并删除。
|
||||
- 上级目录、根目录、主目录、刷新、后退、前进。
|
||||
- 显示 / 不显示隐藏文件:名称以 `.` 开头的文件和目录。睁开的眼睛表示正在显示,划掉的眼睛表示不显示。本地和远程各自一个开关,互不影响;每一侧的选择对所有 SFTP 标签生效,保存在设置里,默认都不显示。不显示时面板底部写明「隐藏 N 项」;显示时隐藏文件的文字颜色浅一些。
|
||||
@@ -287,7 +288,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 +392,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` 的当前大小,在本地文件中跳过相同长度并继续传输;使用者需确保来源仍是同一版本。上传过程不会为了校验而回读远端文件内容。
|
||||
|
||||
|
||||
+3
-1
@@ -5,7 +5,7 @@
|
||||
在 `CHANGELOG.md` 里写好 `## [x.y.z]` 一节,把 `Cargo.toml` 的版本改成 `x.y.z`,提交后推送 tag `vx.y.z`。`.github/workflows/release.yml` 依次:
|
||||
|
||||
1. 核对 tag 与 `Cargo.toml` 一致,截出更新说明,确认代码里有发布公钥;
|
||||
2. 构建并打包:macOS 合成 universal,签名、公证、staple,产出升级用的 `.app.zip` 和首次安装用的 `.dmg`;Windows 用 Inno Setup 打安装程序;Linux 打 AppImage;
|
||||
2. 构建并打包:macOS 按 `aarch64`(Apple Silicon)和 `x86_64`(Intel)分别构建、签名、公证、staple,各自产出升级用的 `.app.zip` 和首次安装用的 `.dmg`;Windows 用 Inno Setup 打安装程序;Linux 打 AppImage;
|
||||
3. 在 `release` 环境里人工批准后:给每个包签名,上传到 R2(`dl.shellrs.com/releases/<版本>/`),建 GitHub Release,**最后**才替换更新清单 `dl.shellrs.com/update/v1/<通道>.json` 并清 CDN 缓存,然后调用官网的部署挂钩重新构建 shellrs.com(官网的下载页和更新日志在构建时读这份清单)。清单里每个包有两个下载地址:先 R2,下载失败时客户端接着试 GitHub Release(仓库是公开的,下载不用登录)。
|
||||
|
||||
版本号带 `-` 的(如 `0.3.0-beta.1`)只发到 beta 通道;正式版同时成为 beta 通道的最新版。在 Actions 里手动运行这个 workflow 只构建打包、不发布,用来演练。客户端只接受比自己新的版本,所以发错的版本撤不回来:把清单改回上一版能阻止更多人升级,修复要发新的补丁版。
|
||||
@@ -19,3 +19,5 @@
|
||||
- **GitHub secrets**:仓库级的 `APPLE_CERTIFICATE_P12`(base64)、`APPLE_CERTIFICATE_PASSWORD`、`APPLE_SIGNING_IDENTITY`、`APPLE_TEAM_ID`、`APPLE_API_KEY_P8`(base64)、`APPLE_API_KEY_ID`、`APPLE_API_ISSUER`;`release` 环境(设为需要审批)里的 `MINISIGN_SECRET_KEY`、`MINISIGN_PASSWORD`、`R2_ACCOUNT_ID`、`R2_ACCESS_KEY_ID`、`R2_SECRET_ACCESS_KEY`、`R2_BUCKET`、`CF_ZONE_ID`、`CF_API_TOKEN`、`SITE_DEPLOY_HOOK`(官网部署挂钩的地址,拿到它的人能触发官网重新构建,泄露了就删掉重建)。
|
||||
|
||||
清单格式见 `packaging/manifest.example.json`,由 `packaging/make-manifest.sh` 生成:一个文件里是清单原文和它的 minisign 签名,签名的 trusted comment 写明通道和版本。格式只增加字段、不改已有的;已发出去的 ShellRS 一直按 `/update/v1/` 这个地址检查。
|
||||
|
||||
macOS 产物名分别为 `ShellRS-<版本>-macos-aarch64.{app.zip,dmg}` 和 `ShellRS-<版本>-macos-x86_64.{app.zip,dmg}`。更新清单保留原有的两个平台键,分别记录对应架构包的地址、大小和 SHA-256;已有 universal 客户端按当前运行的架构升级。Apple Silicon 上通过 Rosetta 运行 Intel 版时仍会收到 Intel 包,需要手动安装 ARM 版才能切换。官网在独立仓库中,下载页也需提供 Apple Silicon / Intel 两个入口,读取清单中各自的 installer 地址。
|
||||
@@ -97,6 +97,31 @@ settings.terminal.preview_keywords:
|
||||
zh-CN: 预览,字体,字号,行高
|
||||
en: preview,font,size,line height
|
||||
|
||||
settings.interaction.title:
|
||||
zh-CN: 终端交互
|
||||
en: Interaction
|
||||
settings.interaction.copy_on_select:
|
||||
zh-CN: 选中复制
|
||||
en: Copy on select
|
||||
settings.interaction.copy_on_select_description:
|
||||
zh-CN: 选中文本自动进入系统剪贴板,无需按快捷键。
|
||||
en: Selected text is copied to the clipboard without pressing a shortcut.
|
||||
settings.interaction.right_click:
|
||||
zh-CN: 右键行为
|
||||
en: Right-click
|
||||
settings.interaction.right_click_description:
|
||||
zh-CN: 设置鼠标右键在终端中的行为。
|
||||
en: What right-clicking in a terminal does.
|
||||
settings.right_click.nothing:
|
||||
zh-CN: 无动作
|
||||
en: Do nothing
|
||||
settings.right_click.paste:
|
||||
zh-CN: 粘贴
|
||||
en: Paste
|
||||
settings.right_click.menu:
|
||||
zh-CN: 显示菜单
|
||||
en: Show menu
|
||||
|
||||
settings.notifications.title:
|
||||
zh-CN: 通知
|
||||
en: Notifications
|
||||
|
||||
+12
-3
@@ -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
|
||||
|
||||
+2
-2
@@ -365,5 +365,5 @@ ssh.exec.saved_passphrase_rejected:
|
||||
zh-CN: 保存的私钥口令不正确:请先在 ShellRS 中连接一次这台主机并保存密码
|
||||
en: The saved private key passphrase is incorrect. Connect to this host in ShellRS once and save the right one
|
||||
ssh.exec.keyboard_interactive:
|
||||
zh-CN: 服务器要求键盘交互式认证:请先在 ShellRS 中连接一次这台主机并保存密码
|
||||
en: The server asks for keyboard-interactive authentication. Connect to this host in ShellRS once and save the password
|
||||
zh-CN: 服务器在键盘交互式认证中提出了密码以外的问题(如验证码),外部 CLI 无法回答
|
||||
en: The server asks keyboard-interactive questions besides the password (such as a verification code), which the external CLI can’t answer
|
||||
@@ -23,8 +23,10 @@ out=$5
|
||||
base="https://dl.shellrs.com/releases/$version"
|
||||
mirror=${SHELLRS_MIRROR_BASE:-}
|
||||
|
||||
mac="ShellRS-$version-macos-universal.app.zip"
|
||||
dmg="ShellRS-$version-macos-universal.dmg"
|
||||
mac_arm="ShellRS-$version-macos-aarch64.app.zip"
|
||||
mac_intel="ShellRS-$version-macos-x86_64.app.zip"
|
||||
dmg_arm="ShellRS-$version-macos-aarch64.dmg"
|
||||
dmg_intel="ShellRS-$version-macos-x86_64.dmg"
|
||||
win="ShellRS-$version-windows-x86_64-setup.exe"
|
||||
linux="ShellRS-$version-linux-x86_64.AppImage"
|
||||
|
||||
@@ -58,10 +60,12 @@ jq -n -j \
|
||||
--arg published "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--rawfile notes "$notes" \
|
||||
--arg notes_url "https://shellrs.com/changelog#$version" \
|
||||
--argjson mac "$(asset "$mac")" \
|
||||
--argjson mac_arm "$(asset "$mac_arm")" \
|
||||
--argjson mac_intel "$(asset "$mac_intel")" \
|
||||
--argjson win "$(asset "$win")" \
|
||||
--argjson linux "$(asset "$linux")" \
|
||||
--arg dmg "$base/$dmg" \
|
||||
--arg dmg_arm "$base/$dmg_arm" \
|
||||
--arg dmg_intel "$base/$dmg_intel" \
|
||||
--arg win_url "$base/$win" \
|
||||
--arg linux_url "$base/$linux" \
|
||||
'{
|
||||
@@ -74,14 +78,14 @@ jq -n -j \
|
||||
notes: $notes,
|
||||
notes_url: $notes_url,
|
||||
assets: {
|
||||
"macos-aarch64": $mac,
|
||||
"macos-x86_64": $mac,
|
||||
"macos-aarch64": $mac_arm,
|
||||
"macos-x86_64": $mac_intel,
|
||||
"windows-x86_64": $win,
|
||||
"linux-x86_64": $linux
|
||||
},
|
||||
installers: {
|
||||
"macos-aarch64": $dmg,
|
||||
"macos-x86_64": $dmg,
|
||||
"macos-aarch64": $dmg_arm,
|
||||
"macos-x86_64": $dmg_intel,
|
||||
"windows-x86_64": $win_url,
|
||||
"linux-x86_64": $linux_url
|
||||
}
|
||||
|
||||
@@ -10,16 +10,16 @@
|
||||
"assets": {
|
||||
"macos-aarch64": {
|
||||
"urls": [
|
||||
"https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-universal.app.zip",
|
||||
"https://github.com/since2006/shell-rs/releases/download/v0.2.0/ShellRS-0.2.0-macos-universal.app.zip"
|
||||
"https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-aarch64.app.zip",
|
||||
"https://github.com/since2006/shell-rs/releases/download/v0.2.0/ShellRS-0.2.0-macos-aarch64.app.zip"
|
||||
],
|
||||
"size": 41234567,
|
||||
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
},
|
||||
"macos-x86_64": {
|
||||
"urls": [
|
||||
"https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-universal.app.zip",
|
||||
"https://github.com/since2006/shell-rs/releases/download/v0.2.0/ShellRS-0.2.0-macos-universal.app.zip"
|
||||
"https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-x86_64.app.zip",
|
||||
"https://github.com/since2006/shell-rs/releases/download/v0.2.0/ShellRS-0.2.0-macos-x86_64.app.zip"
|
||||
],
|
||||
"size": 41234567,
|
||||
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
@@ -42,8 +42,8 @@
|
||||
}
|
||||
},
|
||||
"installers": {
|
||||
"macos-aarch64": "https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-universal.dmg",
|
||||
"macos-x86_64": "https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-universal.dmg",
|
||||
"macos-aarch64": "https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-aarch64.dmg",
|
||||
"macos-x86_64": "https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-x86_64.dmg",
|
||||
"windows-x86_64": "https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-windows-x86_64-setup.exe",
|
||||
"linux-x86_64": "https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-linux-x86_64.AppImage"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
});
|
||||
}));
|
||||
|
||||
+10
-3
@@ -3,14 +3,16 @@ use gpui_kit::*;
|
||||
|
||||
use crate::explorer::{FileSizeFormat, ShowHiddenFiles};
|
||||
use crate::i18n::UiLocale;
|
||||
use crate::terminal::{TerminalColors, TerminalFont, TerminalHighlights, is_font_installed};
|
||||
use crate::terminal::{
|
||||
TerminalColors, TerminalFont, TerminalHighlights, TerminalInteraction, is_font_installed,
|
||||
};
|
||||
|
||||
use super::AppSettings;
|
||||
use super::app_theme::{ui_theme, ui_theme_name};
|
||||
|
||||
/// Bring the window in line with the settings: the theme chosen for the
|
||||
/// appearance, in the app and its terminals, the locale, the terminal font
|
||||
/// and highlight rules, the keyboard shortcuts, the SFTP size format and
|
||||
/// appearance, in the app and its terminals, the locale, the terminal font,
|
||||
/// mouse and highlight rules, the keyboard shortcuts, the SFTP size format and
|
||||
/// whether SFTP shows hidden files.
|
||||
/// Does nothing where they already agree, so it can run on every settings
|
||||
/// change and every change of the system appearance.
|
||||
@@ -63,6 +65,11 @@ pub fn apply(settings: AppSettings, window: &mut Window, cx: &mut App) {
|
||||
window.refresh();
|
||||
}
|
||||
|
||||
// Read when the mouse acts, so nothing is redrawn.
|
||||
if cx.try_global::<TerminalInteraction>() != Some(&settings.terminal_interaction) {
|
||||
cx.set_global(settings.terminal_interaction);
|
||||
}
|
||||
|
||||
// Compiled once here for every terminal, which observe it; only when
|
||||
// the rules change, not on every change of the system appearance.
|
||||
let highlight = settings.terminal_highlight;
|
||||
|
||||
+35
-1
@@ -7,7 +7,7 @@ use crate::explorer::{FileSizeFormat, ShowHiddenFiles};
|
||||
use crate::i18n::t;
|
||||
use crate::terminal::{
|
||||
DEFAULT_FONT_SIZE, DEFAULT_LINE_HEIGHT, FONT_SIZE_RANGE, HighlightRule, LINE_HEIGHT_RANGE,
|
||||
TerminalTheme, default_rules,
|
||||
RightClick, TerminalInteraction, TerminalTheme, default_rules,
|
||||
};
|
||||
use crate::update::Channel;
|
||||
|
||||
@@ -22,6 +22,8 @@ pub struct AppSettings {
|
||||
/// 外观 → 终端主题.
|
||||
pub terminal_theme: TerminalThemeSettings,
|
||||
pub terminal_font: TerminalFontSettings,
|
||||
/// 终端 → 终端交互.
|
||||
pub terminal_interaction: TerminalInteraction,
|
||||
pub external_cli: ExternalCliSettings,
|
||||
pub update: UpdateSettings,
|
||||
/// 关于 → 隐私.
|
||||
@@ -245,6 +247,26 @@ impl Choice for Channel {
|
||||
}
|
||||
}
|
||||
|
||||
impl Choice for RightClick {
|
||||
const ALL: &'static [Self] = &[Self::Nothing, Self::Paste, Self::Menu];
|
||||
|
||||
fn key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Nothing => "nothing",
|
||||
Self::Paste => "paste",
|
||||
Self::Menu => "menu",
|
||||
}
|
||||
}
|
||||
|
||||
fn label(self) -> SharedString {
|
||||
match self {
|
||||
Self::Nothing => t!("settings.right_click.nothing"),
|
||||
Self::Paste => t!("settings.right_click.paste"),
|
||||
Self::Menu => t!("settings.right_click.menu"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppSettings {
|
||||
/// The settings with every number in the range the app accepts. Applied
|
||||
/// to what the file holds and to every change, so neither a hand-edited
|
||||
@@ -435,6 +457,7 @@ mod tests {
|
||||
}
|
||||
check::<InterfaceLanguage>();
|
||||
check::<Appearance>();
|
||||
check::<RightClick>();
|
||||
assert_eq!(Appearance::from_key("sepia"), None);
|
||||
}
|
||||
|
||||
@@ -587,6 +610,17 @@ mod tests {
|
||||
assert!(settings.window.remember_size && !settings.window.remember_position);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminals_show_the_menu_and_copy_only_on_command_unless_the_file_says_otherwise() {
|
||||
let settings: AppSettings = serde_json::from_str("{}").unwrap();
|
||||
assert!(!settings.terminal_interaction.copy_on_select);
|
||||
assert_eq!(settings.terminal_interaction.right_click, RightClick::Menu);
|
||||
let settings: AppSettings =
|
||||
serde_json::from_str(r#"{"terminal_interaction":{"right_click":"paste"}}"#).unwrap();
|
||||
assert!(!settings.terminal_interaction.copy_on_select);
|
||||
assert_eq!(settings.terminal_interaction.right_click, RightClick::Paste);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_updates_are_on_unless_the_file_says_otherwise() {
|
||||
let settings: AppSettings = serde_json::from_str("{}").unwrap();
|
||||
|
||||
@@ -385,10 +385,51 @@ fn terminal_groups(panel: &SettingsPanel, cx: &App) -> Vec<SettingGroup> {
|
||||
})
|
||||
.keywords(keywords(t!("settings.terminal.preview_keywords"))),
|
||||
]),
|
||||
interaction_group(store),
|
||||
notification_group(store),
|
||||
]
|
||||
}
|
||||
|
||||
/// 终端 → 终端交互: what the mouse does besides selecting.
|
||||
fn interaction_group(store: &Entity<SettingsStore>) -> SettingGroup {
|
||||
let (reader, writer) = (store.clone(), store.clone());
|
||||
SettingGroup::new()
|
||||
.title(t!("settings.interaction.title"))
|
||||
.items([
|
||||
SettingItem::new(
|
||||
t!("settings.interaction.copy_on_select"),
|
||||
SettingField::switch(
|
||||
move |cx| {
|
||||
reader
|
||||
.read(cx)
|
||||
.settings()
|
||||
.terminal_interaction
|
||||
.copy_on_select
|
||||
},
|
||||
move |on, cx| {
|
||||
writer.update(cx, |store, cx| {
|
||||
store.update(
|
||||
|settings| settings.terminal_interaction.copy_on_select = on,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
},
|
||||
)
|
||||
.default_value(false),
|
||||
)
|
||||
.description(t!("settings.interaction.copy_on_select_description")),
|
||||
SettingItem::new(
|
||||
t!("settings.interaction.right_click"),
|
||||
choice_field(
|
||||
store,
|
||||
|settings| settings.terminal_interaction.right_click,
|
||||
|settings, action| settings.terminal_interaction.right_click = action,
|
||||
),
|
||||
)
|
||||
.description(t!("settings.interaction.right_click_description")),
|
||||
])
|
||||
}
|
||||
|
||||
/// 终端 → 通知: when the window is not in front, the system's notification;
|
||||
/// when another tab is, one in the window.
|
||||
fn notification_group(store: &Entity<SettingsStore>) -> SettingGroup {
|
||||
|
||||
+17
-48
@@ -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
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
@@ -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"));
|
||||
}
|
||||
|
||||
+89
-21
@@ -950,10 +950,14 @@ async fn authenticate(
|
||||
}
|
||||
|
||||
if method == LoginMethod::Password || partial {
|
||||
// The saved password goes to the server once, by whichever method
|
||||
// asks for it first.
|
||||
let mut saved_offered = false;
|
||||
let mut saved_rejected = false;
|
||||
if methods.contains(&MethodKind::Password) {
|
||||
// Try what the host has saved before bothering anyone.
|
||||
let mut saved_rejected = false;
|
||||
if let Some(saved) = saved_secret(secrets, &login.password) {
|
||||
saved_offered = true;
|
||||
let result = handle
|
||||
.authenticate_password(user, saved.to_string())
|
||||
.await
|
||||
@@ -1001,8 +1005,20 @@ async fn authenticate(
|
||||
}
|
||||
}
|
||||
if methods.contains(&MethodKind::KeyboardInteractive) {
|
||||
// A password login whose server takes passwords only this way
|
||||
// (ESXi) is asked for it as a single hidden question. After a
|
||||
// password that got partway, the question is a second factor.
|
||||
let mut password =
|
||||
(method == LoginMethod::Password && !partial).then(|| KeyboardPassword {
|
||||
saved: if saved_offered {
|
||||
None
|
||||
} else {
|
||||
saved_secret(secrets, &login.password)
|
||||
},
|
||||
rejected: saved_rejected,
|
||||
});
|
||||
for _ in 0..AUTH_RETRIES {
|
||||
if keyboard_interactive(handle, user, broker).await? {
|
||||
if keyboard_interactive(handle, user, broker, password.as_mut()).await? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
@@ -1011,6 +1027,14 @@ async fn authenticate(
|
||||
bail!(t!("ssh.auth.failed"))
|
||||
}
|
||||
|
||||
/// What a password login brings to keyboard-interactive authentication.
|
||||
struct KeyboardPassword {
|
||||
/// The saved password, until it is sent.
|
||||
saved: Option<Zeroizing<String>>,
|
||||
/// The server refused the saved password.
|
||||
rejected: bool,
|
||||
}
|
||||
|
||||
type Agent = russh::keys::agent::client::AgentClient<
|
||||
Box<dyn russh::keys::agent::client::AgentStream + Send + Unpin>,
|
||||
>;
|
||||
@@ -1234,44 +1258,88 @@ async fn ask_one_secret(
|
||||
}
|
||||
}
|
||||
|
||||
/// One keyboard-interactive exchange, `false` when the server refuses it.
|
||||
///
|
||||
/// For a password login, the exchange's first single hidden question asks
|
||||
/// for the password: the saved one answers it unasked, as WinSCP's does,
|
||||
/// and a connection that cannot ask says the password is what it lacks.
|
||||
async fn keyboard_interactive(
|
||||
handle: &mut SshHandle,
|
||||
user: &str,
|
||||
broker: Asker<'_>,
|
||||
mut password: Option<&mut KeyboardPassword>,
|
||||
) -> Result<bool> {
|
||||
let mut response = handle
|
||||
.authenticate_keyboard_interactive_start(user, None)
|
||||
.await
|
||||
.map_err(|_| anyhow!(t!("ssh.auth.keyboard_start_failed")))?;
|
||||
let mut password_asked = false;
|
||||
let mut saved_sent = false;
|
||||
loop {
|
||||
match response {
|
||||
KeyboardInteractiveAuthResponse::Success => return Ok(true),
|
||||
KeyboardInteractiveAuthResponse::Failure { .. } => return Ok(false),
|
||||
KeyboardInteractiveAuthResponse::Failure { .. } => {
|
||||
if saved_sent && let Some(password) = password.as_deref_mut() {
|
||||
password.rejected = true;
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
KeyboardInteractiveAuthResponse::InfoRequest {
|
||||
name,
|
||||
instructions,
|
||||
prompts,
|
||||
} => {
|
||||
let fields = prompts
|
||||
.into_iter()
|
||||
.map(|prompt| ConnectionPromptField::new(prompt.prompt, prompt.echo))
|
||||
.collect();
|
||||
let reply = broker
|
||||
.ask_credential(
|
||||
MissingCredential::KeyboardInteractive,
|
||||
ConnectionPromptKind::authentication(name, instructions, fields),
|
||||
)
|
||||
.await?;
|
||||
let ConnectionPromptReply::Answers(answers) = reply else {
|
||||
bail!(t!("ssh.auth.keyboard_cancelled"))
|
||||
let asks_password = password.is_some()
|
||||
&& !password_asked
|
||||
&& matches!(prompts.as_slice(), [prompt] if !prompt.echo);
|
||||
password_asked |= asks_password;
|
||||
let saved = password
|
||||
.as_deref_mut()
|
||||
.filter(|_| asks_password)
|
||||
.and_then(|password| password.saved.take());
|
||||
let answers = if prompts.is_empty() {
|
||||
// Only something to read, which OpenSSH passes on from
|
||||
// PAM this way; the server waits for the empty reply.
|
||||
Vec::new()
|
||||
} else if let Some(saved) = saved {
|
||||
saved_sent = true;
|
||||
vec![saved.to_string()]
|
||||
} else {
|
||||
let rejected = password
|
||||
.as_deref()
|
||||
.is_some_and(|password| password.rejected);
|
||||
let need = if asks_password {
|
||||
MissingCredential::Password { rejected }
|
||||
} else {
|
||||
MissingCredential::KeyboardInteractive
|
||||
};
|
||||
let instructions = if !(asks_password && rejected) {
|
||||
instructions
|
||||
} else if instructions.trim().is_empty() {
|
||||
t!("ssh.auth.saved_password_rejected").to_string()
|
||||
} else {
|
||||
format!("{}\n{instructions}", t!("ssh.auth.saved_password_rejected"))
|
||||
};
|
||||
let fields = prompts
|
||||
.into_iter()
|
||||
.map(|prompt| ConnectionPromptField::new(prompt.prompt, prompt.echo))
|
||||
.collect();
|
||||
let reply = broker
|
||||
.ask_credential(
|
||||
need,
|
||||
ConnectionPromptKind::authentication(name, instructions, fields),
|
||||
)
|
||||
.await?;
|
||||
let ConnectionPromptReply::Answers(answers) = reply else {
|
||||
bail!(t!("ssh.auth.keyboard_cancelled"))
|
||||
};
|
||||
answers
|
||||
.into_iter()
|
||||
.map(|answer| answer.into_inner())
|
||||
.collect()
|
||||
};
|
||||
response = handle
|
||||
.authenticate_keyboard_interactive_respond(
|
||||
answers
|
||||
.into_iter()
|
||||
.map(|answer| answer.into_inner())
|
||||
.collect(),
|
||||
)
|
||||
.authenticate_keyboard_interactive_respond(answers)
|
||||
.await
|
||||
.map_err(|_| anyhow!(t!("ssh.auth.keyboard_failed")))?;
|
||||
}
|
||||
|
||||
+154
-3
@@ -416,6 +416,9 @@ mod tests {
|
||||
auth: TestAuth,
|
||||
/// What an exec channel writes back for a given command.
|
||||
probe_reply: fn(&str) -> Option<&'static str>,
|
||||
/// This connection answered [`TestAuth::KeyboardPassword`]'s
|
||||
/// password question correctly.
|
||||
password_answered: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -423,6 +426,8 @@ mod tests {
|
||||
Password,
|
||||
PublicKey(PublicKey),
|
||||
KeyboardInteractive,
|
||||
/// The password, through keyboard-interactive only, as ESXi takes it.
|
||||
KeyboardPassword,
|
||||
}
|
||||
|
||||
impl TestAuth {
|
||||
@@ -430,12 +435,50 @@ mod tests {
|
||||
let method = match self {
|
||||
Self::Password => MethodKind::Password,
|
||||
Self::PublicKey(_) => MethodKind::PublicKey,
|
||||
Self::KeyboardInteractive => MethodKind::KeyboardInteractive,
|
||||
Self::KeyboardInteractive | Self::KeyboardPassword => {
|
||||
MethodKind::KeyboardInteractive
|
||||
}
|
||||
};
|
||||
MethodSet::from(&[method][..])
|
||||
}
|
||||
}
|
||||
|
||||
impl TestServer {
|
||||
/// One round of ESXi's exchange: the password as a single hidden
|
||||
/// question, then, as OpenSSH's PAM can, a round with nothing to
|
||||
/// answer.
|
||||
fn password_question(&mut self, response: Option<server::Response<'_>>) -> server::Auth {
|
||||
let Some(response) = response else {
|
||||
self.password_answered = false;
|
||||
return server::Auth::Partial {
|
||||
name: Cow::Borrowed(""),
|
||||
instructions: Cow::Borrowed(""),
|
||||
prompts: Cow::Owned(vec![(Cow::Borrowed("Password: "), false)]),
|
||||
};
|
||||
};
|
||||
let answers: Vec<_> = response.collect();
|
||||
match answers.as_slice() {
|
||||
[answer] if answer.as_ref() == TEST_PASSWORD.as_bytes() => {
|
||||
self.password_answered = true;
|
||||
server::Auth::Partial {
|
||||
name: Cow::Borrowed(""),
|
||||
instructions: Cow::Borrowed("Password expires in 7 days"),
|
||||
prompts: Cow::Owned(Vec::new()),
|
||||
}
|
||||
}
|
||||
[] if self.password_answered => server::Auth::Accept,
|
||||
// Refused, with keyboard-interactive still on offer, as
|
||||
// OpenSSH does.
|
||||
_ => server::Auth::Reject {
|
||||
proceed_with_methods: Some(MethodSet::from(
|
||||
&[MethodKind::KeyboardInteractive][..],
|
||||
)),
|
||||
partial_success: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl server::Server for TestServer {
|
||||
type Handler = Self;
|
||||
|
||||
@@ -486,8 +529,10 @@ mod tests {
|
||||
_: &str,
|
||||
response: Option<server::Response<'a>>,
|
||||
) -> Result<server::Auth, Self::Error> {
|
||||
if !matches!(self.auth, TestAuth::KeyboardInteractive) {
|
||||
return Ok(server::Auth::UnsupportedMethod);
|
||||
match self.auth {
|
||||
TestAuth::KeyboardInteractive => {}
|
||||
TestAuth::KeyboardPassword => return Ok(self.password_question(response)),
|
||||
_ => return Ok(server::Auth::UnsupportedMethod),
|
||||
}
|
||||
let Some(mut response) = response else {
|
||||
return Ok(server::Auth::Partial {
|
||||
@@ -693,6 +738,7 @@ mod tests {
|
||||
state: server_state,
|
||||
auth,
|
||||
probe_reply,
|
||||
password_answered: false,
|
||||
};
|
||||
let running = server.run_on_socket(config, &listener);
|
||||
ready_tx.send(Ok((port, running.handle()))).unwrap();
|
||||
@@ -1919,6 +1965,111 @@ mod tests {
|
||||
assert!(saw_challenge);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_connection_test_answers_a_keyboard_interactive_password_question() {
|
||||
let Some(server) = start_server(TestAuth::KeyboardPassword) else {
|
||||
eprintln!("loopback sockets are unavailable in this sandbox; skipping");
|
||||
return;
|
||||
};
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let known_hosts = directory.path().join("known_hosts");
|
||||
let test = |request| {
|
||||
test_login(
|
||||
request,
|
||||
&known_hosts,
|
||||
Arc::new(InMemorySecretStore::default()),
|
||||
true,
|
||||
)
|
||||
.0
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
test(login_request(server.port).with_password(TEST_PASSWORD)),
|
||||
Ok(())
|
||||
);
|
||||
assert_eq!(
|
||||
test(login_request(server.port).with_password("wrong")),
|
||||
Err("用户名或密码错误".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
test(login_request(server.port)),
|
||||
Err("未填写密码".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_answers_a_keyboard_interactive_password_question_with_the_saved_password() {
|
||||
let Some(server) = start_server(TestAuth::KeyboardPassword) else {
|
||||
eprintln!("loopback sockets are unavailable in this sandbox; skipping");
|
||||
return;
|
||||
};
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let known_hosts = directory.path().join("known_hosts");
|
||||
trust_server(server.port, &known_hosts);
|
||||
let host = password_host(server.port);
|
||||
let saved = |password: Option<&str>| {
|
||||
let keychain = Arc::new(InMemorySecretStore::default());
|
||||
if let Some(password) = password {
|
||||
keychain.set(&host.password_secret(), password).unwrap();
|
||||
}
|
||||
keychain
|
||||
};
|
||||
|
||||
let (result, stdout, _) = run_cli_command(&host, &known_hosts, saved(Some(TEST_PASSWORD)));
|
||||
assert_eq!(result, Ok(crate::ssh::ExecExit::Code(3)));
|
||||
assert_eq!(stdout, b"out");
|
||||
|
||||
for (password, message) in [
|
||||
(None, "没有保存密码"),
|
||||
(Some("wrong"), "保存的密码被服务器拒绝"),
|
||||
] {
|
||||
let (result, _, _) = run_cli_command(&host, &known_hosts, saved(password));
|
||||
let error = result.unwrap_err();
|
||||
assert_eq!(error.kind, crate::ssh::ExecErrorKind::MissingCredential);
|
||||
assert!(error.message.contains(message), "{}", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rejected_saved_password_asks_the_keyboard_interactive_question_again() {
|
||||
let Some(server) = start_server(TestAuth::KeyboardPassword) else {
|
||||
eprintln!("loopback sockets are unavailable in this sandbox; skipping");
|
||||
return;
|
||||
};
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let host = password_host(server.port);
|
||||
let keychain = Arc::new(InMemorySecretStore::default());
|
||||
keychain.set(&host.password_secret(), "wrong").unwrap();
|
||||
|
||||
let report = connect_with_secrets(
|
||||
host,
|
||||
&directory.path().join("known_hosts"),
|
||||
keychain,
|
||||
|prompt| match prompt {
|
||||
ConnectionPromptKind::UnknownHost(_) => ConnectionPromptReply::TrustAndSave,
|
||||
ConnectionPromptKind::Authentication(authentication) => {
|
||||
assert_eq!(
|
||||
authentication.instructions(),
|
||||
"已保存的密码被服务器拒绝,请重新输入"
|
||||
);
|
||||
assert_eq!(authentication.fields().len(), 1);
|
||||
assert_eq!(authentication.fields()[0].label(), "Password: ");
|
||||
assert!(!authentication.fields()[0].echo());
|
||||
ConnectionPromptReply::Answers(vec![ConnectionSecret::new(TEST_PASSWORD)])
|
||||
}
|
||||
other => panic!("unexpected prompt: {other:?}"),
|
||||
},
|
||||
);
|
||||
// Asked once, for the password; the round with nothing to answer
|
||||
// after it is answered without asking.
|
||||
let asked = report
|
||||
.prompts
|
||||
.iter()
|
||||
.filter(|prompt| matches!(prompt, ConnectionPromptKind::Authentication(_)))
|
||||
.count();
|
||||
assert_eq!(asked, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_host_key_is_reported_and_blocked() {
|
||||
let Some(server) = start_server(TestAuth::Password) else {
|
||||
|
||||
+2
-1
@@ -30,7 +30,8 @@ pub use highlight::{
|
||||
pub use local_pty::LocalPtyTransportFactory;
|
||||
pub use local_terminal_panel::{LocalTerminalPanel, LocalTerminalPanelEvent};
|
||||
pub use model::{
|
||||
LocalTerminalId, RemoteTerminalId, TerminalLifecycle, TerminalSize, TerminalStatus,
|
||||
LocalTerminalId, RemoteTerminalId, RightClick, TerminalInteraction, TerminalLifecycle,
|
||||
TerminalSize, TerminalStatus,
|
||||
};
|
||||
pub use notices::TerminalNotice;
|
||||
pub use run::{
|
||||
|
||||
+37
-1
@@ -1,7 +1,8 @@
|
||||
use std::fmt;
|
||||
|
||||
use alacritty_terminal::grid::Dimensions;
|
||||
use gpui_kit::SharedString;
|
||||
use gpui_kit::{App, Global, SharedString};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::i18n::t;
|
||||
|
||||
@@ -142,3 +143,38 @@ impl TerminalStatus {
|
||||
self.rows
|
||||
}
|
||||
}
|
||||
|
||||
/// 终端 → 终端交互: what the mouse does in terminals besides selecting. A
|
||||
/// global, set from the settings, because terminals do not depend on them.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct TerminalInteraction {
|
||||
/// 选中复制: what a selection takes in goes to the clipboard once the
|
||||
/// mouse lets go. Off until the user turns it on.
|
||||
pub copy_on_select: bool,
|
||||
/// 右键行为.
|
||||
pub right_click: RightClick,
|
||||
}
|
||||
|
||||
impl Global for TerminalInteraction {}
|
||||
|
||||
impl TerminalInteraction {
|
||||
/// What the terminals do now.
|
||||
pub fn current(cx: &App) -> Self {
|
||||
cx.try_global::<Self>().copied().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// What right-clicking in a terminal does. A program that asked for the
|
||||
/// mouse never hears the right button.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RightClick {
|
||||
/// 无动作.
|
||||
Nothing,
|
||||
/// 粘贴: the clipboard, as ⌘V does.
|
||||
Paste,
|
||||
/// 显示菜单: copy, paste, find, clear and the owner's commands.
|
||||
#[default]
|
||||
Menu,
|
||||
}
|
||||
@@ -33,8 +33,9 @@ use super::mouse::{self, MouseReport, ReportButton, ReportKind};
|
||||
use super::scrollbar::ScrollbackHandle;
|
||||
use super::search::SearchMark;
|
||||
use super::{
|
||||
SearchDirection, SharedTerminalTransportFactory, TerminalColors, TerminalEngine, TerminalEvent,
|
||||
TerminalFont, TerminalLifecycle, TerminalSize, TerminalSnapshot, TerminalStatus, TerminalTheme,
|
||||
RightClick, SearchDirection, SharedTerminalTransportFactory, TerminalColors, TerminalEngine,
|
||||
TerminalEvent, TerminalFont, TerminalInteraction, TerminalLifecycle, TerminalSize,
|
||||
TerminalSnapshot, TerminalStatus, TerminalTheme,
|
||||
};
|
||||
|
||||
pub const TERMINAL_KEY_CONTEXT: &str = "Terminal";
|
||||
@@ -641,6 +642,15 @@ impl TerminalView {
|
||||
.update(cx, |engine, cx| engine.update_selection(point, side, cx));
|
||||
}
|
||||
|
||||
/// The mouse let go of a selection. With 选中复制 on, what it took in
|
||||
/// goes to the clipboard; a click that selected nothing leaves it be.
|
||||
fn finish_selection(&mut self, cx: &mut Context<Self>) {
|
||||
self.selecting = false;
|
||||
if TerminalInteraction::current(cx).copy_on_select {
|
||||
self.copy_selection(cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell the program of the mouse, if it asked to hear of it. Shift keeps
|
||||
/// the mouse for selecting text, unless a reported button is down.
|
||||
/// Whether the program was told.
|
||||
@@ -736,6 +746,18 @@ impl TerminalView {
|
||||
self.engine.read(cx).prepare_for_user_input();
|
||||
}
|
||||
|
||||
/// Right-clicking does what 右键行为 says.
|
||||
fn right_click(&mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context<Self>) {
|
||||
match TerminalInteraction::current(cx).right_click {
|
||||
RightClick::Nothing => {}
|
||||
RightClick::Paste => {
|
||||
self.focus(window, cx);
|
||||
self.paste_clipboard(cx);
|
||||
}
|
||||
RightClick::Menu => self.open_context_menu(event, window, cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn open_context_menu(
|
||||
&mut self,
|
||||
event: &MouseDownEvent,
|
||||
@@ -951,7 +973,7 @@ impl Render for TerminalView {
|
||||
this.send_user_input(b"\x1b[Z".to_vec(), cx);
|
||||
}))
|
||||
.on_key_down(cx.listener(Self::on_key_down))
|
||||
.on_mouse_down(MouseButton::Right, cx.listener(Self::open_context_menu))
|
||||
.on_mouse_down(MouseButton::Right, cx.listener(Self::right_click))
|
||||
.child(element)
|
||||
.when_some(context_menu, |terminal, (menu, position)| {
|
||||
terminal.child(
|
||||
@@ -1591,7 +1613,8 @@ impl Element for TerminalElement {
|
||||
let (point, side) = viewport.grid_point(event.position);
|
||||
view.update(cx, |view, cx| view.update_selection(point, side, cx));
|
||||
} else {
|
||||
view.update(cx, |view, _| view.selecting = false);
|
||||
// Let go where the window did not hear it.
|
||||
view.update(cx, |view, cx| view.finish_selection(cx));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1603,7 +1626,7 @@ impl Element for TerminalElement {
|
||||
let (point, side) = viewport.grid_point(event.position);
|
||||
view.update(cx, |view, cx| {
|
||||
view.update_selection(point, side, cx);
|
||||
view.selecting = false;
|
||||
view.finish_selection(cx);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+12
-3
@@ -207,8 +207,9 @@ pub trait Installer: Send + Sync {
|
||||
|
||||
/// Remove what earlier updates left next to the installed copy. Called a
|
||||
/// while after start, so a version that crashes right away leaves the
|
||||
/// previous one's backup behind.
|
||||
fn clean_up(&self);
|
||||
/// previous one's backup behind. An update newer than `current` stays:
|
||||
/// it may have been staged since start and be waiting for the restart.
|
||||
fn clean_up(&self, current: &Version);
|
||||
}
|
||||
|
||||
/// For copies that cannot replace themselves.
|
||||
@@ -237,7 +238,7 @@ impl Installer for NoInstaller {
|
||||
))
|
||||
}
|
||||
|
||||
fn clean_up(&self) {}
|
||||
fn clean_up(&self, _: &Version) {}
|
||||
}
|
||||
|
||||
/// The installer for this copy of ShellRS. `bundle` is `cx.app_path()`.
|
||||
@@ -296,6 +297,14 @@ pub(crate) fn sibling(path: &Path, name: &str) -> PathBuf {
|
||||
path.with_file_name(name)
|
||||
}
|
||||
|
||||
/// Whether `name` is an update staged as `<prefix><version>` that this copy
|
||||
/// no longer needs: one not newer than `current`, or not named by a version.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux", test))]
|
||||
pub(crate) fn is_stale_update(name: &str, prefix: &str, current: &Version) -> bool {
|
||||
name.strip_prefix(prefix)
|
||||
.is_some_and(|version| Version::parse(version).map_or(true, |version| version <= *current))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -10,7 +10,9 @@ use std::os::unix::fs::PermissionsExt as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::error::UpdateError;
|
||||
use super::install::{InstallKind, Installer, Relaunch, Staged, sibling};
|
||||
use semver::Version;
|
||||
|
||||
use super::install::{InstallKind, Installer, Relaunch, Staged, is_stale_update, sibling};
|
||||
use super::manifest::Release;
|
||||
|
||||
pub struct AppImageInstaller {
|
||||
@@ -59,13 +61,13 @@ impl Installer for AppImageInstaller {
|
||||
})
|
||||
}
|
||||
|
||||
fn clean_up(&self) {
|
||||
fn clean_up(&self, current: &Version) {
|
||||
let Some(folder) = self.file.parent() else {
|
||||
return;
|
||||
};
|
||||
let prefix = self.prefix();
|
||||
for entry in fs::read_dir(folder).into_iter().flatten().flatten() {
|
||||
if entry.file_name().to_string_lossy().starts_with(&prefix) {
|
||||
if is_stale_update(&entry.file_name().to_string_lossy(), &prefix, current) {
|
||||
let _ = fs::remove_file(entry.path());
|
||||
}
|
||||
}
|
||||
@@ -115,13 +117,26 @@ mod tests {
|
||||
"stale",
|
||||
)
|
||||
.unwrap();
|
||||
installer.clean_up();
|
||||
let left: Vec<_> = fs::read_dir(folder.path())
|
||||
// Staged since start and waiting for the restart: kept.
|
||||
fs::write(
|
||||
sibling(&file, ".ShellRS-x86_64.AppImage.update-0.3.0"),
|
||||
"next",
|
||||
)
|
||||
.unwrap();
|
||||
installer.clean_up(&Version::new(0, 2, 0));
|
||||
let mut left: Vec<_> = fs::read_dir(folder.path())
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|entry| entry.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(left.len(), 2, "{left:?}");
|
||||
assert!(left.contains(&"ShellRS-x86_64.AppImage".to_string()));
|
||||
left.sort();
|
||||
assert_eq!(
|
||||
left,
|
||||
[
|
||||
".ShellRS-x86_64.AppImage.update-0.3.0",
|
||||
"ShellRS-x86_64.AppImage",
|
||||
"downloaded"
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,11 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
|
||||
use semver::Version;
|
||||
|
||||
use super::build_info::APPLE_TEAM_ID;
|
||||
use super::error::UpdateError;
|
||||
use super::install::{InstallKind, Installer, Relaunch, Staged, sibling};
|
||||
use super::install::{InstallKind, Installer, Relaunch, Staged, is_stale_update, sibling};
|
||||
use super::manifest::Release;
|
||||
use crate::i18n::t;
|
||||
|
||||
@@ -217,7 +219,7 @@ impl Installer for MacInstaller {
|
||||
})
|
||||
}
|
||||
|
||||
fn clean_up(&self) {
|
||||
fn clean_up(&self, current: &Version) {
|
||||
let Some(folder) = self.bundle.parent() else {
|
||||
return;
|
||||
};
|
||||
@@ -227,7 +229,7 @@ impl Installer for MacInstaller {
|
||||
);
|
||||
for entry in fs::read_dir(folder).into_iter().flatten().flatten() {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if name.starts_with(&old) || name.starts_with(&update) {
|
||||
if name.starts_with(&old) || is_stale_update(&name, &update, current) {
|
||||
let _ = fs::remove_dir_all(entry.path());
|
||||
}
|
||||
}
|
||||
@@ -349,10 +351,40 @@ mod tests {
|
||||
assert_eq!(names.len(), 2, "{names:?}");
|
||||
assert!(names[0].starts_with(".ShellRS.app.old-"), "{names:?}");
|
||||
|
||||
installer.clean_up();
|
||||
// Run by 0.2.0, once it has started.
|
||||
installer.clean_up(&Version::new(0, 2, 0));
|
||||
assert_eq!(names_in(folder.path()), ["ShellRS.app"]);
|
||||
}
|
||||
|
||||
/// The clean-up runs a while after start; an update downloaded and
|
||||
/// staged before then is still there to install on restart.
|
||||
#[test]
|
||||
fn the_clean_up_keeps_an_update_staged_for_the_restart() {
|
||||
let (folder, bundle) = installed();
|
||||
let installer = MacInstaller::new(bundle.clone(), FakeTools::new("0.2.0", None));
|
||||
for stale in [
|
||||
".ShellRS.app.update-0.1.0",
|
||||
".ShellRS.app.update-later",
|
||||
".ShellRS.app.old-1",
|
||||
] {
|
||||
fs::create_dir(folder.path().join(stale)).unwrap();
|
||||
}
|
||||
let staged = installer
|
||||
.stage(Path::new("package.zip"), &release("0.2.0"))
|
||||
.unwrap();
|
||||
|
||||
installer.clean_up(&Version::new(0, 1, 0));
|
||||
assert_eq!(
|
||||
names_in(folder.path()),
|
||||
[".ShellRS.app.update-0.2.0", "ShellRS.app"]
|
||||
);
|
||||
assert_eq!(
|
||||
installer.apply(&staged, true),
|
||||
Ok(Relaunch::Restart(bundle.clone()))
|
||||
);
|
||||
assert_eq!(version_at(&bundle), "0.2.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_swap_puts_the_old_bundle_back() {
|
||||
let (folder, bundle) = installed();
|
||||
@@ -367,8 +399,12 @@ mod tests {
|
||||
Err(UpdateError::Install(_))
|
||||
));
|
||||
assert_eq!(version_at(&bundle), "0.1.0");
|
||||
installer.clean_up();
|
||||
assert_eq!(names_in(folder.path()), ["ShellRS.app"]);
|
||||
installer.clean_up(&Version::new(0, 1, 0));
|
||||
assert!(
|
||||
!names_in(folder.path())
|
||||
.iter()
|
||||
.any(|name| name.starts_with(".ShellRS.app.old-"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -484,7 +520,7 @@ mod tests {
|
||||
.check_signature(&bundle, Some("SHELLRS"))
|
||||
.is_err()
|
||||
);
|
||||
installer.clean_up();
|
||||
installer.clean_up(&Version::new(0, 2, 0));
|
||||
assert_eq!(names_in(&installed), ["ShellRS.app"]);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use semver::Version;
|
||||
|
||||
use super::error::UpdateError;
|
||||
use super::install::{InstallKind, Installer, Launch, Relaunch, Staged};
|
||||
use super::manifest::Release;
|
||||
@@ -99,7 +101,8 @@ impl Installer for SetupInstaller {
|
||||
}
|
||||
|
||||
/// The setup program removes what the previous version left.
|
||||
fn clean_up(&self) {}
|
||||
// The package waits in the downloads folder, which the updater tidies.
|
||||
fn clean_up(&self, _: &Version) {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
//! Which package of a release fits this machine.
|
||||
//!
|
||||
//! The keys are part of the manifest format, so they never change. A
|
||||
//! universal macOS build runs its native half, so an Apple Silicon Mac asks
|
||||
//! for `macos-aarch64` even though both keys point at the same package; an
|
||||
//! Intel-only build under Rosetta would ask for `macos-x86_64` and keep
|
||||
//! getting Intel builds, which is why macOS ships universal.
|
||||
//! The keys are part of the manifest format, so they never change. macOS
|
||||
//! packages match the running binary's architecture. Existing universal
|
||||
//! builds select their native half's package; an Intel build under Rosetta
|
||||
//! keeps receiving Intel packages until the user installs the ARM build.
|
||||
|
||||
/// The manifest's key for this build's platform.
|
||||
pub fn platform_key() -> &'static str {
|
||||
|
||||
@@ -507,7 +507,7 @@ impl Updater {
|
||||
return;
|
||||
};
|
||||
cx.background_spawn(async move {
|
||||
services.installer.clean_up();
|
||||
services.installer.clean_up(&services.current);
|
||||
clean_downloads(&services.folder, &services.current);
|
||||
})
|
||||
.detach();
|
||||
|
||||
+31
-9
@@ -319,7 +319,8 @@ pub(crate) mod tests {
|
||||
let dist = dir.path().join("dist");
|
||||
std::fs::create_dir(&dist).unwrap();
|
||||
for name in [
|
||||
"ShellRS-0.2.0-macos-universal.app.zip",
|
||||
"ShellRS-0.2.0-macos-aarch64.app.zip",
|
||||
"ShellRS-0.2.0-macos-x86_64.app.zip",
|
||||
"ShellRS-0.2.0-windows-x86_64-setup.exe",
|
||||
"ShellRS-0.2.0-linux-x86_64.AppImage",
|
||||
] {
|
||||
@@ -355,15 +356,36 @@ pub(crate) mod tests {
|
||||
};
|
||||
assert_eq!(manifest.version, Version::new(0, 2, 0));
|
||||
assert_eq!(manifest.notes, "### 新增\n\n- 在线升级\n");
|
||||
let mac = &manifest.assets["macos-aarch64"];
|
||||
assert_eq!(mac, &manifest.assets["macos-x86_64"]);
|
||||
// dl.shellrs.com first, the GitHub release only when it fails.
|
||||
for arch in ["aarch64", "x86_64"] {
|
||||
let key = format!("macos-{arch}");
|
||||
let name = format!("ShellRS-0.2.0-{key}.app.zip");
|
||||
let asset = &manifest.assets[&key];
|
||||
// Each architecture has its own package and both download sources.
|
||||
assert_eq!(
|
||||
asset.urls,
|
||||
[
|
||||
format!("https://dl.shellrs.com/releases/0.2.0/{name}"),
|
||||
format!(
|
||||
"https://github.com/since2006/shell-rs/releases/download/v0.2.0/{name}"
|
||||
),
|
||||
]
|
||||
);
|
||||
assert_eq!(verify_file(&dist.join(&name), asset), Ok(()));
|
||||
assert_eq!(
|
||||
manifest.installers[&key],
|
||||
format!("https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-{key}.dmg")
|
||||
);
|
||||
}
|
||||
assert_ne!(
|
||||
manifest.assets["macos-aarch64"].sha256,
|
||||
manifest.assets["macos-x86_64"].sha256
|
||||
);
|
||||
assert_eq!(
|
||||
mac.urls,
|
||||
[
|
||||
"https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-universal.app.zip",
|
||||
"https://github.com/since2006/shell-rs/releases/download/v0.2.0/ShellRS-0.2.0-macos-universal.app.zip",
|
||||
]
|
||||
verify_file(
|
||||
&dist.join("ShellRS-0.2.0-macos-x86_64.app.zip"),
|
||||
&manifest.assets["macos-aarch64"],
|
||||
),
|
||||
Err(UpdateError::Corrupt)
|
||||
);
|
||||
let package = dist.join("ShellRS-0.2.0-linux-x86_64.AppImage");
|
||||
assert_eq!(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:?}"))
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! the font and the size, links, the mouse for programs and their copies.
|
||||
|
||||
use crate::support::*;
|
||||
use shellrs::terminal::RightClick;
|
||||
|
||||
fn open_workspace_with_factory(
|
||||
cx: &mut TestAppContext,
|
||||
@@ -363,6 +364,93 @@ async fn terminal_selection_copies_only_on_command_and_finishes_outside_view(
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn copy_on_select_puts_what_the_mouse_selects_on_the_clipboard(cx: &mut TestAppContext) {
|
||||
let factory = Arc::new(FakeTerminalFactory::default());
|
||||
let (handle, workspace) = open_running_local_terminal(cx, factory).await;
|
||||
let settings = cx.update(|cx| workspace.read(cx).settings().clone());
|
||||
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
||||
window.render_frame(cx);
|
||||
local_screen(&workspace, cx).contains("会议纪要")
|
||||
})
|
||||
.await;
|
||||
|
||||
// 终端交互 is the second group of 终端: 选中复制, off, then 右键行为.
|
||||
in_frame(cx, handle, |window, cx| window.click("open-settings", cx));
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.within("settings").click("0-1", cx)
|
||||
});
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
let mut settings = window.within("settings");
|
||||
let mut group = settings.within("group-1");
|
||||
assert_eq!(group.within("item-0").find("check").checked(), Some(false));
|
||||
assert_eq!(group.within("item-1").find("btn").label(), Some("显示菜单"));
|
||||
group.within("item-0").click("check", cx);
|
||||
});
|
||||
cx.update(|cx| {
|
||||
assert!(
|
||||
settings
|
||||
.read(cx)
|
||||
.settings()
|
||||
.terminal_interaction
|
||||
.copy_on_select
|
||||
)
|
||||
});
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click(("local-terminal-tab", 1_u64), cx)
|
||||
});
|
||||
|
||||
cx.update(|cx| cx.write_to_clipboard(ClipboardItem::new_string("原剪贴板".into())));
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
let bounds = window.find(("local-terminal", 1_u64)).bounds();
|
||||
let from = point(bounds.left() + px(1.), bounds.top() + px(8.));
|
||||
let to = point(bounds.right() - px(1.), bounds.top() + px(8.));
|
||||
// A click selects nothing, and the clipboard keeps what it had.
|
||||
click_at(window, from, gpui_kit::Modifiers::none(), cx);
|
||||
assert_eq!(
|
||||
cx.read_from_clipboard().and_then(|item| item.text()),
|
||||
Some("原剪贴板".into())
|
||||
);
|
||||
window.drag(from, to, cx);
|
||||
assert_eq!(
|
||||
cx.read_from_clipboard().and_then(|item| item.text()),
|
||||
Some("run:1$ alpha.txt 会议纪要.md".into())
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn right_click_pastes_or_does_nothing_when_set_to(cx: &mut TestAppContext) {
|
||||
let factory = Arc::new(FakeTerminalFactory::default());
|
||||
let (handle, workspace) = open_running_local_terminal(cx, factory.clone()).await;
|
||||
let settings = cx.update(|cx| workspace.read(cx).settings().clone());
|
||||
let right_click = |cx: &mut TestAppContext, action: RightClick, clipboard: &str| {
|
||||
cx.update(|cx| {
|
||||
settings.update(cx, |settings, cx| {
|
||||
settings.update(
|
||||
|settings| settings.terminal_interaction.right_click = action,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(clipboard.into()));
|
||||
});
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.right_click(("local-terminal", 1_u64), cx)
|
||||
});
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert!(window.try_find("popup-menu").is_none())
|
||||
});
|
||||
};
|
||||
|
||||
right_click(cx, RightClick::Paste, "粘贴一次");
|
||||
take_written(cx, handle, &factory, "粘贴一次").await;
|
||||
|
||||
// Nothing is pasted: had it been, the next paste would follow it.
|
||||
right_click(cx, RightClick::Nothing, "不该粘贴");
|
||||
right_click(cx, RightClick::Paste, "再粘贴");
|
||||
take_written(cx, handle, &factory, "再粘贴").await;
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn cjk_input_is_sent_once_and_resize_commands_are_deduplicated(cx: &mut TestAppContext) {
|
||||
let factory = Arc::new(FakeTerminalFactory::default());
|
||||
|
||||
+1
-1
@@ -99,7 +99,7 @@ impl Installer for FakeInstaller {
|
||||
})
|
||||
}
|
||||
|
||||
fn clean_up(&self) {}
|
||||
fn clean_up(&self, _: &Version) {}
|
||||
}
|
||||
|
||||
/// A copy of ShellRS that can install updates itself.
|
||||
|
||||
Reference in new issue
Block a user